diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index e7433ef6..9591111a 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -117,6 +117,8 @@ struct DictationReservation { session_id: SessionId, resources: Arc, inserter: Arc, + prefix_pcm: Option>, + elapsed_offset_ms: u64, } /// Core-owned audio-to-Agent session shared by native hosts. @@ -4226,6 +4228,18 @@ impl OpenLessBackend { Ok(()) } + pub fn upsert_history( + &self, + session: DictationSession, + retention_days: u32, + max_entries: Option, + ) -> Result<(), BackendError> { + self.history + .upsert_with_retention(session, retention_days, max_entries)?; + self.publish_history_changed(); + Ok(()) + } + pub fn delete_history(&self, id: &str) -> Result<(), BackendError> { self.history.delete(id)?; self.publish_history_changed(); @@ -4629,6 +4643,15 @@ impl OpenLessBackend { fn reserve_dictation_session( &self, insert_text: bool, + ) -> Result { + self.reserve_dictation_session_with_id(insert_text, SessionId::new(), None) + } + + fn reserve_dictation_session_with_id( + &self, + insert_text: bool, + session_id: SessionId, + prefix_pcm: Option>, ) -> Result { { let state = self.state.read().expect("backend state lock poisoned"); @@ -4640,7 +4663,10 @@ impl OpenLessBackend { )); } } - let session_id = SessionId::new(); + let elapsed_offset_ms = prefix_pcm + .as_ref() + .map(|pcm| crate::asr::pcm::pcm_duration_ms(pcm)) + .unwrap_or(0); self.voice_sessions.acquire( session_id, crate::voice_session::VoiceSessionKind::Dictation, @@ -4665,6 +4691,7 @@ impl OpenLessBackend { state.dictation = DictationStateSnapshot { phase: DictationPhase::Starting, session_id: Some(session_id), + elapsed_ms: elapsed_offset_ms, ..DictationStateSnapshot::default() }; state.dictation_translation_requested = None; @@ -4678,6 +4705,8 @@ impl OpenLessBackend { session_id, resources: starting_resources, inserter, + prefix_pcm, + elapsed_offset_ms, }) } @@ -4689,6 +4718,34 @@ impl OpenLessBackend { self.start_reserved_dictation(reservation, options).await } + pub async fn resume_dictation_recording( + &self, + session_id: SessionId, + pcm: Vec, + ) -> Result { + let history_id = session_id.to_string(); + let recoverable = self.history.list()?.into_iter().any(|entry| { + entry.id == history_id + && entry.error_code.as_deref() == Some("recordingCancelled") + && entry.has_audio_recording == Some(true) + }); + if !recoverable { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "history entry is not a recoverable cancelled recording", + )); + } + if pcm.is_empty() || !pcm.len().is_multiple_of(2) { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "recording recovery requires complete non-empty signed 16-bit PCM", + )); + } + let reservation = self.reserve_dictation_session_with_id(true, session_id, Some(pcm))?; + self.start_reserved_dictation(reservation, DictationStartOptions::default()) + .await + } + async fn start_reserved_dictation( &self, reservation: DictationReservation, @@ -4698,18 +4755,23 @@ impl OpenLessBackend { session_id, resources: starting_resources, inserter, + prefix_pcm, + elapsed_offset_ms, } = reservation; - let context = match self + let mut context = match self .capture_dictation_context(&options, DictationContextPurpose::Dictation) .await { - Ok(context) => Arc::new(context), + Ok(context) => context, Err(error) => { self.mark_dictation_failed(session_id, &error); self.reset_dictation_session(session_id); return Err(error); } }; + context.recording.prefix_pcm = prefix_pcm; + context.recording.elapsed_offset_ms = elapsed_offset_ms; + let context = Arc::new(context); let context = { let mut state = self.state.write().expect("backend state lock poisoned"); if let Err(error) = ensure_running(&state) { @@ -5345,7 +5407,7 @@ impl OpenLessBackend { asr_ms: attribution.asr_ms, polish_ms: attribution.polish_ms, }; - if let Err(error) = self.append_history( + if let Err(error) = self.upsert_history( session, preferences.history_retention_days, preferences.history_max_entries, @@ -5379,6 +5441,47 @@ impl OpenLessBackend { llm_call_label: Option, ) { let preferences = self.get_preferences(); + let session = self.build_failed_dictation_session( + context, + session_id, + error_code, + raw_text, + final_text, + polish_source, + duration_ms, + asr_ms, + polish_ms, + has_audio_recording, + llm_used, + asr_call_label, + llm_call_label, + ); + if let Err(error) = self.upsert_history( + session, + preferences.history_retention_days, + preferences.history_max_entries, + ) { + log::warn!("failed to persist dictation failure history: {error}"); + } + } + + #[allow(clippy::too_many_arguments)] + fn build_failed_dictation_session( + &self, + context: &DictationContext, + session_id: SessionId, + error_code: &str, + raw_text: String, + final_text: String, + polish_source: Option, + duration_ms: Option, + asr_ms: Option, + polish_ms: Option, + has_audio_recording: Option, + llm_used: bool, + asr_call_label: Option, + llm_call_label: Option, + ) -> DictationSession { let front_app = crate::shared_types::split_front_app_opt(context.polish.front_app.as_deref()); let pipeline_mode = match context.pipeline_mode { @@ -5393,7 +5496,7 @@ impl OpenLessBackend { asr_call_label.as_ref(), llm_call_label.as_ref(), ); - let session = DictationSession { + DictationSession { id: session_id.to_string(), created_at: self.clock.now_utc().to_rfc3339(), source: HistorySource::Voice, @@ -5418,13 +5521,6 @@ impl OpenLessBackend { pipeline_mode: Some(pipeline_mode.to_string()), asr_ms: attribution.asr_ms, polish_ms: attribution.polish_ms, - }; - if let Err(error) = self.append_history( - session, - preferences.history_retention_days, - preferences.history_max_entries, - ) { - log::warn!("failed to persist dictation failure history: {error}"); } } @@ -5492,6 +5588,28 @@ impl OpenLessBackend { ) } + fn cancel_session_adapters_preserving_archive( + &self, + session_id: SessionId, + ) -> futures_util::future::BoxFuture< + 'static, + Result>, BackendError>, + > { + let engine = Arc::clone(&self.deps.dictation_engine); + let resources = self.voice_sessions.hold_resources(session_id).ok(); + let insertion = self.cancel_text_insertion(session_id); + self.voice_sessions.release(session_id); + own_voice_effect( + &self.deps.task_spawner, + Box::pin(async move { + let _resources = resources; + let archive = engine.cancel_preserving_archive(session_id).await?; + insertion.await?; + Ok(archive) + }), + ) + } + async fn finish_text_insertion( &self, session_id: SessionId, @@ -5601,7 +5719,26 @@ impl OpenLessBackend { &self, session_id: Option, ) -> Result<(), BackendError> { - let active = { + self.cancel_dictation_impl(session_id, false) + .await + .map(|_| ()) + } + + pub async fn cancel_dictation_after_escape( + &self, + session_id: Option, + ) -> Result, BackendError> { + self.cancel_dictation_impl(session_id, true).await + } + + async fn cancel_dictation_impl( + &self, + session_id: Option, + recover_after_escape: bool, + ) -> Result, BackendError> { + let recovery_enabled = + recover_after_escape && self.get_preferences().esc_recording_recovery_enabled; + let (active, recovery_context) = { let mut state = self.state.write().expect("backend state lock poisoned"); ensure_running(&state)?; let active = state.dictation.session_id.ok_or_else(|| { @@ -5616,6 +5753,13 @@ impl OpenLessBackend { "session id does not match the active session", )); } + let recovery_context = (recovery_enabled + && matches!( + state.dictation.phase, + DictationPhase::Starting | DictationPhase::Recording + )) + .then(|| state.dictation_context.clone()) + .flatten(); state.dictation.phase = DictationPhase::Cancelled; self.events.publish( Some(active), @@ -5626,17 +5770,71 @@ impl OpenLessBackend { state.silence_monitor = None; state.transcripts.remove(&active); self.phase_changed.notify_waiters(); - active + (active, recovery_context) }; - let cancel_result = self.cancel_session_adapters(active).await; + let archive = self + .cancel_session_adapters_preserving_archive(active) + .await?; // The state can already display cancellation, but native audio/input // cleanup still owns the shared resource. Reject new capture until that // cleanup finishes, including on its error path. self.voice_sessions.release(active); - let host_result = self.hide_dictation_feedback(active); - cancel_result?; - host_result?; - Ok(()) + let context = match recovery_context { + Some(context) => { + self.hide_dictation_feedback(active)?; + context + } + None => { + let discard_result = match archive { + Some(archive) => archive.discard().await, + None => Ok(()), + }; + let hide_result = self.hide_dictation_feedback(active); + discard_result?; + hide_result?; + return Ok(None); + } + }; + let Some(archive) = archive else { + return Ok(None); + }; + let pcm = match archive.read_pcm().await { + Ok(pcm) if !pcm.is_empty() && pcm.len().is_multiple_of(2) => pcm, + Ok(_) => return Ok(None), + Err(error) => { + log::warn!("failed to read cancelled recording for recovery: {error}"); + return Ok(None); + } + }; + let duration_ms = crate::asr::pcm::pcm_duration_ms(&pcm); + let preferences = self.get_preferences(); + let session = self.build_failed_dictation_session( + &context, + active, + "recordingCancelled", + String::new(), + String::new(), + None, + Some(duration_ms), + None, + None, + Some(true), + false, + None, + None, + ); + if let Err(error) = self.upsert_history( + session, + preferences.history_retention_days, + preferences.history_max_entries, + ) { + log::warn!("failed to persist cancelled recording recovery: {error}"); + return Ok(None); + } + Ok(Some(crate::types::DictationRecordingRecovery { + session_id: active, + duration_ms, + })) } async fn capture_dictation_context( @@ -5997,6 +6195,136 @@ mod tests { } } + struct RecoveryArchive { + pcm: Vec, + discards: std::sync::atomic::AtomicUsize, + } + + impl crate::ports::RecordingArchive for RecoveryArchive { + fn is_available(&self) -> bool { + true + } + + fn read_pcm(&self) -> BoxFuture<'static, Result, BackendError>> { + let pcm = self.pcm.clone(); + boxed(async move { Ok(pcm) }) + } + + fn discard(&self) -> BoxFuture<'static, Result<(), BackendError>> { + self.discards.fetch_add(1, Ordering::AcqRel); + boxed(async { Ok(()) }) + } + } + + struct RecoveryEngine { + archive: Arc, + contexts: Mutex>>, + cancels: std::sync::atomic::AtomicUsize, + preserving_cancels: std::sync::atomic::AtomicUsize, + fail_finish: bool, + } + + impl DictationEngine for RecoveryEngine { + fn start( + &self, + _session_id: SessionId, + context: Arc, + _progress: Arc, + ) -> BoxFuture<'static, Result<(), BackendError>> { + self.contexts.lock().unwrap().push(context); + boxed(async { Ok(()) }) + } + + fn finish( + &self, + _session_id: SessionId, + _progress: Arc, + ) -> BoxFuture<'static, Result> { + let fail = self.fail_finish; + boxed(async move { + if fail { + Err(EngineFailure::from(BackendError::new( + BackendErrorCode::Provider, + "fixture provider failure", + ))) + } else { + Ok(EngineResult { + raw_text: "raw".into(), + asr_transcript: None, + polished_text: "polished".into(), + polish_source: None, + duration_ms: 1000, + polish_failed: false, + asr_ms: None, + polish_ms: None, + has_audio_recording: Some(true), + asr_call_label: None, + llm_call_label: None, + }) + } + }) + } + + fn cancel(&self, _session_id: SessionId) -> BoxFuture<'static, Result<(), BackendError>> { + self.cancels.fetch_add(1, Ordering::AcqRel); + boxed(async { Ok(()) }) + } + + fn cancel_preserving_archive( + &self, + _session_id: SessionId, + ) -> BoxFuture<'static, Result>, BackendError>> + { + self.preserving_cancels.fetch_add(1, Ordering::AcqRel); + let archive: Arc = self.archive.clone(); + boxed(async move { Ok(Some(archive)) }) + } + } + + fn backend_with_recovery_engine( + label: &str, + fail_finish: bool, + ) -> (TestBackend, Arc) { + let data_dir = TestDataDir::new(label); + let engine = Arc::new(RecoveryEngine { + archive: Arc::new(RecoveryArchive { + pcm: vec![0; 32_000], + discards: std::sync::atomic::AtomicUsize::new(0), + }), + contexts: Mutex::new(Vec::new()), + cancels: std::sync::atomic::AtomicUsize::new(0), + preserving_cancels: std::sync::atomic::AtomicUsize::new(0), + fail_finish, + }); + let backend = OpenLessBackend::new( + BackendConfig { + data_dir: data_dir.path().to_path_buf(), + ..BackendConfig::default() + }, + BackendDependencies { + host_actions: Arc::new(FakeHost::default()), + text_inserter: Arc::new(FakeInserter), + dictation_engine: engine.clone(), + task_spawner: Arc::new(TokioTaskSpawner), + credential_store: Arc::new(crate::credentials::InMemoryCredentialStore::default()), + services: crate::domains::BackendServices::unsupported(), + local_asr_runtime: None, + marketplace_config: None, + selection_runtime: None, + selection_polisher: None, + qa_runtime: None, + }, + ) + .unwrap(); + ( + TestBackend { + backend, + _data_dir: data_dir, + }, + engine, + ) + } + struct FakeInserter; impl TextInserter for FakeInserter { @@ -11223,6 +11551,119 @@ mod tests { ); } + #[tokio::test] + async fn ordinary_cancel_discards_the_archive_without_creating_recovery_history() { + let (backend, engine) = backend_with_recovery_engine("ordinary-cancel-archive", false); + let mut preferences = backend.get_preferences(); + preferences.esc_recording_recovery_enabled = true; + preferences.record_audio_for_debug = true; + backend.set_preferences(preferences).unwrap(); + backend.start().await.unwrap(); + let session_id = backend.start_dictation().await.unwrap(); + + backend.cancel_dictation(Some(session_id)).await.unwrap(); + + assert_eq!(engine.preserving_cancels.load(Ordering::Acquire), 1); + assert_eq!(engine.archive.discards.load(Ordering::Acquire), 1); + assert!(backend.list_history().unwrap().is_empty()); + } + + #[tokio::test] + async fn escape_cancel_preserves_archive_and_resume_reuses_the_session_and_prefix() { + let (backend, engine) = backend_with_recovery_engine("escape-cancel-recovery", false); + let mut preferences = backend.get_preferences(); + preferences.esc_recording_recovery_enabled = true; + backend.set_preferences(preferences).unwrap(); + backend.start().await.unwrap(); + let session_id = backend.start_dictation().await.unwrap(); + + let recovery = backend + .cancel_dictation_after_escape(Some(session_id)) + .await + .unwrap() + .unwrap(); + + assert_eq!(recovery.session_id, session_id); + assert_eq!(recovery.duration_ms, 1000); + assert_eq!(engine.archive.discards.load(Ordering::Acquire), 0); + let history = backend.list_history().unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].id, session_id.to_string()); + assert_eq!(history[0].error_code.as_deref(), Some("recordingCancelled")); + assert_eq!(history[0].has_audio_recording, Some(true)); + + let prefix = vec![9, 0, 8, 0]; + assert_eq!( + backend + .resume_dictation_recording(session_id, prefix.clone()) + .await + .unwrap(), + session_id + ); + assert_eq!( + engine + .contexts + .lock() + .unwrap() + .last() + .unwrap() + .recording + .prefix_pcm, + Some(prefix) + ); + backend.cancel_dictation(Some(session_id)).await.unwrap(); + } + + #[tokio::test] + async fn resume_rejects_missing_wrong_or_audio_less_history() { + let (backend, _) = backend_with_recovery_engine("resume-history-validation", false); + backend.start().await.unwrap(); + let missing = SessionId::new(); + assert_eq!( + backend + .resume_dictation_recording(missing, vec![1, 0]) + .await + .unwrap_err() + .code, + BackendErrorCode::InvalidArgument + ); + + for (error_code, has_audio_recording) in [ + (Some("recordingFailed"), Some(true)), + (Some("recordingCancelled"), Some(false)), + ] { + let session_id = SessionId::new(); + let mut entry = history_session(&session_id.to_string()); + entry.error_code = error_code.map(str::to_string); + entry.has_audio_recording = has_audio_recording; + backend.append_history(entry, 30, Some(20)).unwrap(); + assert_eq!( + backend + .resume_dictation_recording(session_id, vec![1, 0]) + .await + .unwrap_err() + .code, + BackendErrorCode::InvalidArgument + ); + } + assert_eq!(backend.snapshot().dictation.phase, DictationPhase::Idle); + } + + #[tokio::test] + async fn failure_cleanup_keeps_the_archive_for_history_retry() { + let (backend, engine) = backend_with_recovery_engine("failure-keeps-archive", true); + backend.start().await.unwrap(); + backend.start_dictation().await.unwrap(); + + assert_eq!( + backend.stop_dictation().await.unwrap_err().code, + BackendErrorCode::Provider + ); + assert_eq!(engine.cancels.load(Ordering::Acquire), 1); + assert_eq!(engine.preserving_cancels.load(Ordering::Acquire), 0); + assert_eq!(engine.archive.discards.load(Ordering::Acquire), 0); + } + #[tokio::test] async fn engine_receives_start_finish_and_cancel_lifecycle_calls() { use crate::testing::{ diff --git a/openless-all/app/crates/openless-core/src/asr/qwen_realtime.rs b/openless-all/app/crates/openless-core/src/asr/qwen_realtime.rs index 7d8fb4c1..3e928f73 100644 --- a/openless-all/app/crates/openless-core/src/asr/qwen_realtime.rs +++ b/openless-all/app/crates/openless-core/src/asr/qwen_realtime.rs @@ -123,6 +123,8 @@ struct SyncState { pending_audio: Vec, audio_scratch: Vec, bytes_received: u64, + /// 恢复录音会一次性排入旧 PCM;记录实际发送量,给收尾超时补足实时回放时长。 + bytes_sent: u64, session_started: bool, session_finished: bool, session_start_error: Option, @@ -217,9 +219,18 @@ impl Qwen3RealtimeASR { let weak_self_for_worker = Arc::downgrade(self); let task_spawner = Arc::clone(&self.task_spawner); task_spawner.spawn(Box::pin(async move { + let mut next_audio_send_at: Option = None; while let Some(item) = send_rx.recv().await { match item { SendItem::Audio(chunk) => { + let now = tokio::time::Instant::now(); + let frame_started_at = match next_audio_send_at { + Some(deadline) if deadline > now => { + tokio::time::sleep_until(deadline).await; + deadline + } + _ => now, + }; if let Err(e) = send_text(&writer_for_worker, append_audio_message(&chunk)).await { @@ -229,6 +240,12 @@ impl Qwen3RealtimeASR { } break; } + if let Some(this) = weak_self_for_worker.upgrade() { + let mut state = this.state.lock(); + state.bytes_sent = state.bytes_sent.saturating_add(chunk.len() as u64); + } + next_audio_send_at = + Some(frame_started_at + realtime_audio_duration(chunk.len() as u64)); } SendItem::Finish(done) => { let result = send_text(&writer_for_worker, finish_session_message()) @@ -301,7 +318,17 @@ impl Qwen3RealtimeASR { } pub async fn send_last_frame(&self) -> Result<(), Qwen3ASRError> { - let result = tokio::time::timeout(FINAL_RESULT_TIMEOUT, async { + let (finish_timeout, pending_audio_bytes) = { + let state = self.state.lock(); + let pending = state.bytes_received.saturating_sub(state.bytes_sent); + (final_result_timeout(pending), pending) + }; + if pending_audio_bytes > TARGET_AUDIO_CHUNK_BYTES as u64 { + log::info!( + "[qwen3-asr] draining {pending_audio_bytes} queued audio bytes at realtime cadence before finish" + ); + } + let result = tokio::time::timeout(finish_timeout, async { let finished = self.session_finished.notified(); tokio::pin!(finished); finished.as_mut().enable(); @@ -607,6 +634,15 @@ fn drain_audio_chunks(buffer: &mut Vec) -> Vec> { chunks } +/// PCM 为 16kHz / 16-bit / mono,即每毫秒 32 字节。向上取整避免尾帧得到 0ms。 +fn realtime_audio_duration(bytes: u64) -> Duration { + Duration::from_millis(bytes.saturating_add(BYTES_PER_MS - 1) / BYTES_PER_MS) +} + +fn final_result_timeout(pending_audio_bytes: u64) -> Duration { + FINAL_RESULT_TIMEOUT.saturating_add(realtime_audio_duration(pending_audio_bytes)) +} + /// VAD 句段拼接:CJK 之间直接相连;拉丁词之间补空格,避免英文句段黏连。 /// `stepfun_realtime` 的多句段收尾复用同一套拼接逻辑,故 `pub(crate)`。 pub(crate) fn join_segments(segments: &[String]) -> String { @@ -1004,4 +1040,21 @@ mod tests { assert_eq!(chunks.len(), 2); assert_eq!(buffer.len(), 17); } + + #[test] + fn realtime_pacing_uses_pcm_duration() { + assert_eq!( + realtime_audio_duration(TARGET_AUDIO_CHUNK_BYTES as u64), + Duration::from_millis(100) + ); + assert_eq!(realtime_audio_duration(1), Duration::from_millis(1)); + } + + #[test] + fn finish_timeout_includes_queued_replay_duration() { + assert_eq!( + final_result_timeout(64_000), + FINAL_RESULT_TIMEOUT + Duration::from_secs(2) + ); + } } diff --git a/openless-all/app/crates/openless-core/src/audio.rs b/openless-all/app/crates/openless-core/src/audio.rs index ee38f544..095ee46d 100644 --- a/openless-all/app/crates/openless-core/src/audio.rs +++ b/openless-all/app/crates/openless-core/src/audio.rs @@ -57,6 +57,20 @@ pub fn encode_dictation_wav(pcm_i16_le: &[u8]) -> Result, crate::Backend Ok(wav) } +pub fn decode_dictation_wav(wav: &[u8]) -> Result, crate::BackendError> { + if wav.len() <= 44 + || &wav[..4] != b"RIFF" + || &wav[8..12] != b"WAVE" + || !(wav.len() - 44).is_multiple_of(2) + { + return Err(crate::BackendError::new( + crate::BackendErrorCode::Persistence, + "dictation recording archive is not canonical PCM WAV", + )); + } + Ok(wav[44..].to_vec()) +} + impl PcmNormalizer { pub fn process( &mut self, @@ -188,9 +202,14 @@ mod tests { assert_eq!(&wav[8..12], b"WAVE"); assert_eq!(u32::from_le_bytes(wav[24..28].try_into().unwrap()), 16_000); assert_eq!(&wav[44..], &pcm); + assert_eq!(decode_dictation_wav(&wav).unwrap(), pcm); assert_eq!( encode_dictation_wav(&[1]).unwrap_err().code, crate::BackendErrorCode::InvalidArgument ); + assert_eq!( + decode_dictation_wav(&wav[..44]).unwrap_err().code, + crate::BackendErrorCode::Persistence + ); } } diff --git a/openless-all/app/crates/openless-core/src/cloud_providers.rs b/openless-all/app/crates/openless-core/src/cloud_providers.rs index ac5a06de..09cda8f0 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -1730,6 +1730,9 @@ impl DictationEngine for SharedOmniDictationEngine { } return Ok(()); } + if let Some(prefix) = context.recording.prefix_pcm.as_deref() { + pcm.consume_pcm_chunk(prefix); + } let consumer: Arc = pcm; let level_progress: Arc = Arc::new(OmniRecordingProgress { session_id, @@ -1996,19 +1999,29 @@ impl DictationEngine for SharedOmniDictationEngine { } fn cancel(&self, session_id: SessionId) -> BoxFuture<'static, Result<(), BackendError>> { + let cancelled = self.cancel_preserving_archive(session_id); + Box::pin(async move { cancelled.await.map(|_| ()) }) + } + + fn cancel_preserving_archive( + &self, + session_id: SessionId, + ) -> BoxFuture<'static, Result>, BackendError>> + { let session = self.sessions.lock().get(&session_id).cloned(); let sessions = Arc::clone(&self.sessions); Box::pin(async move { let Some(session) = session else { - return Ok(()); + return Ok(None); }; session.cancellation.cancel(); let recording = session.recording.lock().take(); + let archive = recording.as_ref().and_then(|recording| recording.archive()); if let Some(recording) = recording { recording.stop().await?; } remove_omni_session(&sessions, session_id, &session); - Ok(()) + Ok(archive.filter(|archive| archive.is_available())) }) } } diff --git a/openless-all/app/crates/openless-core/src/dictation_context.rs b/openless-all/app/crates/openless-core/src/dictation_context.rs index deb85234..4e0fee39 100644 --- a/openless-all/app/crates/openless-core/src/dictation_context.rs +++ b/openless-all/app/crates/openless-core/src/dictation_context.rs @@ -133,6 +133,9 @@ pub struct DictationInsertionContext { pub struct RecordingPlan { pub microphone_device_name: Option, pub mute_during_recording: bool, + /// PCM from a recoverable cancelled recording, replayed before live input. + pub prefix_pcm: Option>, + pub elapsed_offset_ms: u64, /// Whether the Host may create an audio archive at all. QA/Selection Voice /// keep PCM in memory; successful-recording retention is a separate policy. pub archive_enabled: bool, @@ -246,6 +249,8 @@ impl DictationContext { recording: RecordingPlan { microphone_device_name: non_blank(&preferences.microphone_device_name), mute_during_recording: preferences.mute_during_recording, + prefix_pcm: None, + elapsed_offset_ms: 0, archive_enabled: true, archive_successful_recording: preferences.record_audio_for_debug, retention_days: preferences.history_retention_days, diff --git a/openless-all/app/crates/openless-core/src/dictation_engine.rs b/openless-all/app/crates/openless-core/src/dictation_engine.rs index 1df40a72..493422be 100644 --- a/openless-all/app/crates/openless-core/src/dictation_engine.rs +++ b/openless-all/app/crates/openless-core/src/dictation_engine.rs @@ -172,6 +172,9 @@ impl DictationEngine for PipelineDictationEngine { let audio_consumer: Arc = Arc::new(SessionAudioConsumer { session: Arc::clone(&transcription), }); + if let Some(prefix) = context.recording.prefix_pcm.as_deref() { + audio_consumer.consume_pcm_chunk(prefix); + } let recording_progress: Arc = Arc::new(RecordingProgressForwarder { session_id, @@ -618,6 +621,15 @@ impl DictationEngine for PipelineDictationEngine { } fn cancel(&self, session_id: SessionId) -> BoxFuture<'static, Result<(), BackendError>> { + let cancelled = self.cancel_preserving_archive(session_id); + Box::pin(async move { cancelled.await.map(|_| ()) }) + } + + fn cancel_preserving_archive( + &self, + session_id: SessionId, + ) -> BoxFuture<'static, Result>, BackendError>> + { let sessions = Arc::clone(&self.sessions); let polisher = Arc::clone(&self.polisher); Box::pin(async move { @@ -627,10 +639,10 @@ impl DictationEngine for PipelineDictationEngine { .get(&session_id) .cloned() else { - return Ok(()); + return Ok(None); }; if session.cancelled.swap(true, Ordering::AcqRel) { - return Ok(()); + return Ok(None); } let (recording, transcription) = { @@ -640,6 +652,7 @@ impl DictationEngine for PipelineDictationEngine { .expect("pipeline resource lock poisoned"); (resources.recording.take(), resources.transcription.clone()) }; + let archive = recording.as_ref().and_then(|recording| recording.archive()); let mut first_error = None; if let Some(recording) = recording { retain_first_error(&mut first_error, recording.stop().await); @@ -659,7 +672,7 @@ impl DictationEngine for PipelineDictationEngine { remove_session(&sessions, session_id, &session); match first_error { Some(error) => Err(error), - None => Ok(()), + None => Ok(archive.filter(|archive| archive.is_available())), } }) } @@ -1259,6 +1272,35 @@ mod tests { } } + #[tokio::test] + async fn recovery_prefix_reaches_asr_and_cancel_returns_the_archive() { + let fixture = fixture_engine( + false, + Ok(crate::ports::PolishOutput::text("unused")), + None, + None, + ); + let session_id = SessionId::new(); + let mut context = DictationContext::default(); + context.recording.prefix_pcm = Some(vec![9, 0, 8, 0]); + fixture + .engine + .start(session_id, Arc::new(context), fixture.progress.clone()) + .await + .unwrap(); + + assert_eq!(*fixture.pcm.lock().unwrap(), vec![9, 0, 8, 0, 1, 0, 2, 0]); + let archive = fixture + .engine + .cancel_preserving_archive(session_id) + .await + .unwrap() + .unwrap(); + assert_eq!(archive.read_pcm().await.unwrap(), vec![1, 0, 2, 0]); + assert_eq!(fixture.recorder_stops.load(Ordering::Acquire), 1); + assert_eq!(fixture.transcription_cancels.load(Ordering::Acquire), 1); + } + #[tokio::test] async fn pipeline_streams_pcm_progress_and_terminal_deltas() { let fixture = fixture_engine( diff --git a/openless-all/app/crates/openless-core/src/history.rs b/openless-all/app/crates/openless-core/src/history.rs index c7221803..5a66825e 100644 --- a/openless-all/app/crates/openless-core/src/history.rs +++ b/openless-all/app/crates/openless-core/src/history.rs @@ -40,6 +40,28 @@ impl HistoryStore { let _guard = self.lock_store()?; let mut sessions = self.read_locked()?; sessions.insert(0, session); + self.write_with_retention(sessions, retention_days, max_entries) + } + + pub fn upsert_with_retention( + &self, + session: DictationSession, + retention_days: u32, + max_entries: Option, + ) -> Result<(), BackendError> { + let _guard = self.lock_store()?; + let mut sessions = self.read_locked()?; + sessions.retain(|existing| existing.id != session.id); + sessions.insert(0, session); + self.write_with_retention(sessions, retention_days, max_entries) + } + + fn write_with_retention( + &self, + mut sessions: Vec, + retention_days: u32, + max_entries: Option, + ) -> Result<(), BackendError> { if retention_days > 0 { let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days)); sessions.retain(|session| { @@ -200,4 +222,20 @@ mod tests { assert!(store.recent_within_minutes(0).unwrap().is_empty()); let _ = std::fs::remove_file(path); } + + #[test] + fn upsert_replaces_the_same_session_without_duplicates() { + let path = std::env::temp_dir().join(format!( + "openless-core-history-upsert-{}.json", + uuid::Uuid::new_v4().simple() + )); + let store = HistoryStore::at_path(path.clone()); + let mut entry = session("same", chrono::Utc::now().to_rfc3339()); + store.upsert_with_retention(entry.clone(), 0, None).unwrap(); + entry.error_code = Some("recordingCancelled".into()); + store.upsert_with_retention(entry.clone(), 0, None).unwrap(); + + assert_eq!(store.list().unwrap(), vec![entry]); + let _ = std::fs::remove_file(path); + } } diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 94f66b7c..bf966c3d 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -211,7 +211,10 @@ pub use api::{ DictationHotkeyEdge, LessComputerHotkeyAction, LessComputerVoiceSession, OpenLessBackend, QaVoiceCaptureResult, QaVoiceCaptureSession, StartupSnapshot, VoiceTranscriptionSession, }; -pub use audio::{encode_dictation_wav, NormalizedPcmChunk, PcmNormalizer, DICTATION_SAMPLE_RATE}; +pub use audio::{ + decode_dictation_wav, encode_dictation_wav, NormalizedPcmChunk, PcmNormalizer, + DICTATION_SAMPLE_RATE, +}; pub use auxiliary::{ AsrCallLabel, AuxiliaryApi, RepolishRequest, RetranscriptionFailure, RetranscriptionResult, }; @@ -346,10 +349,10 @@ pub use style_pack_store::{ pub use style_packs::*; pub use types::InsertStatus as DictationInsertStatus; pub use types::{ - CorrectionRule, DictationPhase, DictationResult, DictationSession, DictationStateSnapshot, - DictionaryEntry, DownloadProgress, HistoryChange, HistoryInsertStatus, HistorySource, - InsertFallbackPayload, NotificationLevel, NotificationPayload, PermissionSnapshot, - PermissionState, PolishDelta, PolishMode, PreferencesChange, RuleSource, + CorrectionRule, DictationPhase, DictationRecordingRecovery, DictationResult, DictationSession, + DictationStateSnapshot, DictionaryEntry, DownloadProgress, HistoryChange, HistoryInsertStatus, + HistorySource, InsertFallbackPayload, NotificationLevel, NotificationPayload, + PermissionSnapshot, PermissionState, PolishDelta, PolishMode, PreferencesChange, RuleSource, SelectionVoiceIntentMode, SelectionVoiceManualIntent, SessionId, StylePackChange, TranscriptAccumulator, TranscriptDelta, VocabPreset, VocabPresetStore, VocabularyChange, }; diff --git a/openless-all/app/crates/openless-core/src/ports.rs b/openless-all/app/crates/openless-core/src/ports.rs index f570c515..5166a034 100644 --- a/openless-all/app/crates/openless-core/src/ports.rs +++ b/openless-all/app/crates/openless-core/src/ports.rs @@ -299,6 +299,19 @@ pub trait DictationEngine: Send + Sync { )) } + /// Cancel while retaining the stopped recording archive for an opt-in + /// recovery flow. Engines without archive support keep normal cancellation. + fn cancel_preserving_archive( + &self, + session_id: SessionId, + ) -> BoxFuture<'static, Result>, BackendError>> { + let cancelled = self.cancel(session_id); + Box::pin(async move { + cancelled.await?; + Ok(None) + }) + } + fn cancel(&self, session_id: SessionId) -> BoxFuture<'static, Result<(), BackendError>>; } diff --git a/openless-all/app/crates/openless-core/src/provider_registry.rs b/openless-all/app/crates/openless-core/src/provider_registry.rs index e5b8dac1..e5f2cb7a 100644 --- a/openless-all/app/crates/openless-core/src/provider_registry.rs +++ b/openless-all/app/crates/openless-core/src/provider_registry.rs @@ -445,6 +445,15 @@ impl DictationEngine for DictationEngineRouter { } fn cancel(&self, session_id: SessionId) -> BoxFuture<'static, Result<(), BackendError>> { + let cancelled = self.cancel_preserving_archive(session_id); + Box::pin(async move { cancelled.await.map(|_| ()) }) + } + + fn cancel_preserving_archive( + &self, + session_id: SessionId, + ) -> BoxFuture<'static, Result>, BackendError>> + { let routed = self .active .read() @@ -454,10 +463,10 @@ impl DictationEngine for DictationEngineRouter { let active = Arc::clone(&self.active); Box::pin(async move { let Some(routed) = routed else { - return Ok(()); + return Ok(None); }; routed.cancelled.store(true, Ordering::Release); - let result = routed.engine.cancel(session_id).await; + let result = routed.engine.cancel_preserving_archive(session_id).await; if routed.started.load(Ordering::Acquire) { remove_routed_session(&active, session_id, &routed); } diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index c6c50adf..b3b8721e 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -349,6 +349,9 @@ pub struct UserPreferences { /// 语音后的连续静音阈值(秒)。可选 1 / 1.5 / 2 / 3 / 4 / 5,默认 3。 #[serde(default = "default_silence_auto_stop_seconds")] pub silence_auto_stop_seconds: f32, + /// 录音中按 Esc 时保留已录 WAV、写入待处理历史并提供继续入口。默认关闭。 + #[serde(default)] + pub esc_recording_recovery_enabled: bool, /// 录音输入设备名称。空字符串 = 使用系统默认麦克风。 #[serde(default)] pub microphone_device_name: String, @@ -779,6 +782,8 @@ struct UserPreferencesWire { #[serde(default = "default_silence_auto_stop_seconds")] silence_auto_stop_seconds: f32, #[serde(default)] + esc_recording_recovery_enabled: bool, + #[serde(default)] microphone_device_name: String, active_asr_provider: String, active_llm_provider: String, @@ -1005,6 +1010,7 @@ impl Default for UserPreferencesWire { audio_cue_on_record: prefs.audio_cue_on_record, silence_auto_stop_enabled: prefs.silence_auto_stop_enabled, silence_auto_stop_seconds: prefs.silence_auto_stop_seconds, + esc_recording_recovery_enabled: prefs.esc_recording_recovery_enabled, microphone_device_name: prefs.microphone_device_name, active_asr_provider: prefs.active_asr_provider, active_llm_provider: prefs.active_llm_provider, @@ -1161,6 +1167,7 @@ impl<'de> Deserialize<'de> for UserPreferences { audio_cue_on_record: wire.audio_cue_on_record, silence_auto_stop_enabled: wire.silence_auto_stop_enabled, silence_auto_stop_seconds: wire.silence_auto_stop_seconds, + esc_recording_recovery_enabled: wire.esc_recording_recovery_enabled, microphone_device_name: wire.microphone_device_name, active_asr_provider: wire.active_asr_provider, active_llm_provider: wire.active_llm_provider, @@ -1517,6 +1524,7 @@ impl Default for UserPreferences { audio_cue_on_record: true, silence_auto_stop_enabled: false, silence_auto_stop_seconds: default_silence_auto_stop_seconds(), + esc_recording_recovery_enabled: false, microphone_device_name: String::new(), active_asr_provider: default_active_asr_provider(), active_llm_provider: "ark".into(), @@ -2324,6 +2332,9 @@ pub struct CapsulePayload { /// 窗口,但前端据此切换为一行状态提示,避免改变既有语音光效与文案。 #[serde(default)] pub selection_polish: bool, + /// Esc 取消后可继续的录音 session id;其它状态为 None。 + #[serde(default)] + pub recovery_session_id: Option, } /// Snapshot of credentials read from vault — only what the UI needs to know @@ -2915,6 +2926,24 @@ mod tests { assert!(!restored.audio_cue_on_record); } + #[test] + fn esc_recording_recovery_defaults_off_and_round_trips_enabled() { + let missing: UserPreferences = serde_json::from_str("{}").unwrap(); + assert!(!missing.esc_recording_recovery_enabled); + + let enabled = UserPreferences { + esc_recording_recovery_enabled: true, + ..Default::default() + }; + let json = serde_json::to_string(&enabled).unwrap(); + assert!(json.contains(r#""escRecordingRecoveryEnabled":true"#)); + assert!( + serde_json::from_str::(&json) + .unwrap() + .esc_recording_recovery_enabled + ); + } + #[test] fn action_hotkeys_default_to_enabled() { // issue #576:默认仍开启(Some 默认键),对老用户零行为变化。 diff --git a/openless-all/app/crates/openless-core/src/types.rs b/openless-all/app/crates/openless-core/src/types.rs index 8b33cc45..c0d42cd6 100644 --- a/openless-all/app/crates/openless-core/src/types.rs +++ b/openless-all/app/crates/openless-core/src/types.rs @@ -251,6 +251,12 @@ pub struct DictationResult { pub inserted: InsertStatus, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DictationRecordingRecovery { + pub session_id: SessionId, + pub duration_ms: u64, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum InsertStatus { diff --git a/openless-all/app/scripts/recording-recovery-prompt-contract.test.mjs b/openless-all/app/scripts/recording-recovery-prompt-contract.test.mjs new file mode 100644 index 00000000..bb74e619 --- /dev/null +++ b/openless-all/app/scripts/recording-recovery-prompt-contract.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const capsule = readFileSync(join(appRoot, 'src/components/Capsule.tsx'), 'utf8'); +const english = readFileSync(join(appRoot, 'src/i18n/en.ts'), 'utf8'); +const simplifiedChinese = readFileSync(join(appRoot, 'src/i18n/zh-CN.ts'), 'utf8'); +const promptStart = capsule.indexOf('function RecordingRecoveryPrompt('); +const promptEnd = capsule.indexOf('// ───────── 经典药丸', promptStart); + +assert.notEqual(promptStart, -1, 'recording recovery prompt must exist'); +assert.notEqual(promptEnd, -1, 'recording recovery prompt boundary must exist'); + +const prompt = capsule.slice(promptStart, promptEnd); +const buttonCount = prompt.match(/ void resume\(\)\}/); +assert.doesNotMatch(prompt, /capsule\.recovery\.question/); +assert.doesNotMatch(prompt, /role="status"/); +assert.match(english, /continue: 'Continue recording'/); +assert.match(simplifiedChinese, /continue: '继续录音'/); + +console.log('recording recovery prompt contract tests passed'); diff --git a/openless-all/app/src-tauri/src/commands/dictation.rs b/openless-all/app/src-tauri/src/commands/dictation.rs index d29ba981..4eb66f4b 100644 --- a/openless-all/app/src-tauri/src/commands/dictation.rs +++ b/openless-all/app/src-tauri/src/commands/dictation.rs @@ -48,6 +48,22 @@ pub async fn cancel_dictation( Ok(()) } +#[tauri::command] +pub async fn resume_cancelled_recording( + coord: CoordinatorState<'_>, + session_id: String, +) -> Result<(), String> { + coord.resume_cancelled_recording(&session_id).await +} + +#[tauri::command] +pub fn dismiss_cancelled_recording_recovery( + coord: CoordinatorState<'_>, + session_id: Option, +) { + coord.dismiss_cancelled_recording_recovery(session_id.as_deref()); +} + #[tauri::command] pub async fn handle_window_hotkey_event( coord: CoordinatorState<'_>, diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index cdb1559a..fd76c41e 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -234,6 +234,11 @@ pub async fn set_settings( // 主线程闭包的 ~30Hz 同步(Windows 主线程拥塞时闭包延迟 → 整场显示旧样式)。 // 前端也会通过 prefs:changed 广播收到新样式,录音中切换即时换肤。 coord.sync_capsule_style_from_preferences(); + // 用户关掉 Esc 录音恢复时,已经显示或正等待显示的恢复入口也必须立即收起。 + // 历史/WAV 已经落盘,不在设置切换时删除;开关只控制后续 Esc 是否创建恢复记录。 + if remote_prev.esc_recording_recovery_enabled && !prefs.esc_recording_recovery_enabled { + coord.dismiss_cancelled_recording_recovery(None); + } // 系统代理开关变化时立即重建客户端连接池(issue #869)。 if remote_prev.use_system_proxy != prefs.use_system_proxy { crate::net::set_use_system_proxy(prefs.use_system_proxy); diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index cf7aca06..e9fdc607 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -500,6 +500,8 @@ struct Inner { /// 选区润色的轻量提示仍在显示或处理中。已有语音/QA 的旧 auto-hide timer 必须在 /// 此期间让路,避免把选区润色浮窗提前收掉。 selection_polish_capsule_active: AtomicBool, + /// 正在收尾或等待显示 Esc 恢复提示的 session。录音与历史归 Core 管理。 + cancelled_recording_recovery: Mutex>, /// Tauri QA window visibility. All QA business state belongs to openless-core. qa_context: Arc, /// 预备态标志:按下热键即"乐观显示"胶囊(带入场动画),此时麦克风还在 cpal @@ -637,6 +639,7 @@ impl Coordinator { capsule_event_epoch: AtomicU64::new(0), capsule_event_lock: Mutex::new(()), selection_polish_capsule_active: AtomicBool::new(false), + cancelled_recording_recovery: Mutex::new(None), qa_context: Arc::clone(&qa_context), shutdown: AtomicBool::new(false), }); @@ -744,6 +747,7 @@ impl Coordinator { capsule_event_epoch: AtomicU64::new(0), capsule_event_lock: Mutex::new(()), selection_polish_capsule_active: AtomicBool::new(false), + cancelled_recording_recovery: Mutex::new(None), qa_context: Arc::clone(&qa_context), shutdown: AtomicBool::new(false), }); @@ -1543,6 +1547,40 @@ impl Coordinator { dictation::cancel_active_session(&self.inner).await; } + pub async fn resume_cancelled_recording(&self, session_id: &str) -> Result<(), String> { + let session_id = openless_core::SessionId::from_uuid( + uuid::Uuid::parse_str(session_id).map_err(|_| "invalid session id".to_string())?, + ); + let path = crate::persistence::recording_path_for_session(&session_id.to_string()) + .map_err(|error| error.to_string())?; + let wav = tokio::fs::read(path).await.map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + "recording not found".to_string() + } else { + format!("read wav failed: {error}") + } + })?; + let pcm = openless_core::decode_dictation_wav(&wav).map_err(|error| error.to_string())?; + dictation::dismiss_cancelled_recording_recovery(&self.inner, Some(&session_id.to_string())); + if !self.inner.backend.snapshot().running { + self.inner + .backend + .start() + .await + .map_err(|error| error.to_string())?; + } + self.inner + .backend + .resume_dictation_recording(session_id, pcm) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) + } + + pub fn dismiss_cancelled_recording_recovery(&self, session_id: Option<&str>) { + dictation::dismiss_cancelled_recording_recovery(&self.inner, session_id); + } + pub(crate) async fn cancel_dictation_from_cli( &self, ) -> Result { diff --git a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs index 4ccdfc1d..bf99be1b 100644 --- a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs +++ b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs @@ -117,11 +117,16 @@ pub(crate) fn restore_focus_target_if_possible(_target: Option) -> bool { /// Esc 独占判定:胶囊显示「进行中」(录音/转写/润色)且确为 dictation 会话(phase 非 /// Idle)时为 true——tap/hook 吞掉 Esc 不透传宿主应用。phase 条件专门排除 QA:QA 也走 /// 胶囊,但它的 Esc 由聚焦浮窗处理(#161),全局吞键反而会把它挡掉。纯函数便于表格测试。 -fn esc_exclusive_for_capsule(state: CapsuleState, session_active: bool) -> bool { - matches!( - state, - CapsuleState::Recording | CapsuleState::Transcribing | CapsuleState::Polishing - ) && session_active +fn esc_exclusive_for_capsule( + state: CapsuleState, + session_active: bool, + recording_recovery_active: bool, +) -> bool { + recording_recovery_active + || (matches!( + state, + CapsuleState::Recording | CapsuleState::Transcribing | CapsuleState::Polishing + ) && session_active) } pub(super) fn emit_capsule( @@ -207,6 +212,7 @@ fn emit_capsule_with_context_locked( && !dictation.recording_ready, selection_polish, capsule_style: inner.host.cached_capsule_style(), + recovery_session_id: None, }; emit_capsule_payload_locked(inner, payload) } @@ -277,7 +283,9 @@ fn emit_capsule_payload_locked(inner: &Arc, payload: CapsulePayload) -> u != openless_core::DictationPhase::Idle || inner.backend.less_computer_active_session().is_some() || selection_voice_active; - let esc_exclusive = esc_exclusive_for_capsule(state, session_active); + let recovery_active = payload.recovery_session_id.is_some() + || inner.cancelled_recording_recovery.lock().is_some(); + let esc_exclusive = esc_exclusive_for_capsule(state, session_active, recovery_active); crate::hotkey::set_esc_exclusive(esc_exclusive); // 即使窗口尚未绑定,也保留卡片期间最新的完整反馈,重显时不能倒退到旧准备态。 defer_capsule_payload_if_fallback_active(inner, &payload); @@ -490,6 +498,13 @@ pub(super) fn hide_selection_polish_capsule_if_current(inner: &Arc, expec mod epoch_tests { use super::*; + #[test] + fn pending_recording_recovery_keeps_escape_exclusive_through_terminal_frames() { + for state in [CapsuleState::Cancelled, CapsuleState::Idle] { + assert!(esc_exclusive_for_capsule(state, false, true)); + } + } + #[test] fn queued_qa_terminal_and_timer_cannot_hide_direct_native_feedback() { for initial in [CapsuleState::Polishing, CapsuleState::Error] { @@ -538,6 +553,7 @@ mod tests { warming: false, selection_polish: false, capsule_style: CapsuleStyle::Siri, + recovery_session_id: None, } } @@ -572,7 +588,7 @@ mod tests { (CapsuleState::Recording, SessionPhase::Inserting), ] { assert!( - esc_exclusive_for_capsule(state, phase), + esc_exclusive_for_capsule(state, phase, false), "{state:?} @ {phase:?} 应独占 Esc" ); } @@ -585,7 +601,7 @@ mod tests { (CapsuleState::Idle, SessionPhase::Idle), ] { assert!( - !esc_exclusive_for_capsule(state, phase), + !esc_exclusive_for_capsule(state, phase, false), "{state:?} @ {phase:?} 不应独占 Esc" ); } @@ -597,7 +613,7 @@ mod tests { (CapsuleState::Polishing, SessionPhase::Idle), ] { assert!( - !esc_exclusive_for_capsule(state, phase), + !esc_exclusive_for_capsule(state, phase, false), "{state:?} @ {phase:?}(QA)不应独占 Esc" ); } diff --git a/openless-all/app/src-tauri/src/coordinator/dictation_core.rs b/openless-all/app/src-tauri/src/coordinator/dictation_core.rs index fb37d3e9..3b127555 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation_core.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation_core.rs @@ -2,6 +2,9 @@ use std::sync::Arc; use super::{qa::handle_qa_option_edge, Inner}; +const RECORDING_RECOVERY_PROMPT_DELAY_MS: u64 = 120; +const RECORDING_RECOVERY_TIMEOUT_MS: u64 = 3_000; + #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct LessComputerEventReplay { @@ -59,6 +62,7 @@ pub(super) async fn handle_pressed_edge( pressed_at: std::time::Instant, press_id: u64, ) { + dismiss_cancelled_recording_recovery(inner, None); if inner.qa_context.is_panel_visible() && inner.backend.snapshot().dictation.phase == openless_core::DictationPhase::Idle { @@ -157,6 +161,161 @@ pub(super) async fn cancel_active_session(inner: &Arc) -> bool { } } +pub(super) async fn cancel_active_session_after_escape(inner: &Arc) -> bool { + match super::hotkey_loops::cancel_active_less_computer(inner).await { + Ok(true) => return true, + Ok(false) => {} + Err(error) => { + log::warn!("[coord] Less Computer cancel failed: {error}"); + return false; + } + } + let snapshot = inner.backend.snapshot(); + let Some(active_session_id) = snapshot.dictation.session_id else { + return match inner.backend.cancel_active_voice_session(None).await { + Ok(()) => true, + Err(error) if error.code == openless_core::BackendErrorCode::InvalidState => false, + Err(error) => { + log::warn!("[coord] voice cancel after Esc failed: {error}"); + false + } + }; + }; + let pending_session_id = active_session_id.to_string(); + let recovery_armed = inner + .backend + .get_preferences() + .esc_recording_recovery_enabled + && matches!( + snapshot.dictation.phase, + openless_core::DictationPhase::Starting | openless_core::DictationPhase::Recording + ); + if recovery_armed { + *inner.cancelled_recording_recovery.lock() = Some(pending_session_id.clone()); + crate::hotkey::set_esc_exclusive(true); + } + match inner.backend.cancel_dictation_after_escape(None).await { + Ok(Some(recovery)) => { + inner.host.hide_less_computer_glow(); + let session_id = recovery.session_id.to_string(); + let should_schedule = should_schedule_cancelled_recording_recovery(inner, &session_id); + if should_schedule { + schedule_cancelled_recording_recovery_prompt( + inner, + session_id, + recovery.duration_ms, + ); + } else { + dismiss_cancelled_recording_recovery(inner, Some(&session_id)); + } + true + } + Ok(None) => { + inner.host.hide_less_computer_glow(); + if recovery_armed { + dismiss_cancelled_recording_recovery(inner, Some(&pending_session_id)); + } + true + } + Err(error) => { + if recovery_armed { + dismiss_cancelled_recording_recovery(inner, Some(&pending_session_id)); + } + log::warn!("[coord] dictation cancel after Esc failed: {error}"); + false + } + } +} + +fn should_schedule_cancelled_recording_recovery(inner: &Arc, session_id: &str) -> bool { + inner + .backend + .get_preferences() + .esc_recording_recovery_enabled + && inner.cancelled_recording_recovery.lock().as_deref() == Some(session_id) +} + +pub(super) fn dismiss_cancelled_recording_recovery( + inner: &Arc, + session_id: Option<&str>, +) -> bool { + let dismissed = { + let mut pending = inner.cancelled_recording_recovery.lock(); + match (pending.as_deref(), session_id) { + (Some(active), Some(requested)) if active != requested => false, + (Some(_), _) => { + pending.take(); + true + } + (None, _) => false, + } + }; + if dismissed { + super::capsule_focus::hide_capsule_if_all_sessions_idle(inner); + } + dismissed +} + +fn schedule_cancelled_recording_recovery_prompt( + inner: &Arc, + session_id: String, + elapsed_ms: u64, +) { + let inner = Arc::clone(inner); + let spawner = inner.host.clone(); + spawner.spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis( + RECORDING_RECOVERY_PROMPT_DELAY_MS, + )) + .await; + let still_pending = + inner.cancelled_recording_recovery.lock().as_deref() == Some(session_id.as_str()); + if !still_pending { + return; + } + if !inner + .backend + .get_preferences() + .esc_recording_recovery_enabled + { + dismiss_cancelled_recording_recovery(&inner, Some(&session_id)); + return; + } + if inner.backend.snapshot().dictation.phase != openless_core::DictationPhase::Idle { + return; + } + let epoch = super::capsule_focus::emit_core_capsule( + &inner, + crate::types::CapsulePayload { + state: crate::types::CapsuleState::Cancelled, + level: 0.0, + elapsed_ms, + message: None, + inserted_chars: None, + translation: false, + operating: false, + warming: false, + capsule_style: inner.backend.get_preferences().capsule_style, + selection_polish: false, + recovery_session_id: Some(session_id.clone()), + }, + None, + ); + tokio::time::sleep(std::time::Duration::from_millis( + RECORDING_RECOVERY_TIMEOUT_MS, + )) + .await; + let still_pending = + inner.cancelled_recording_recovery.lock().as_deref() == Some(session_id.as_str()); + if still_pending { + *inner.cancelled_recording_recovery.lock() = None; + if let Some(epoch) = epoch { + super::capsule_focus::hide_core_capsule_if_current(&inner, epoch); + } + } + }); +} + #[cfg(target_os = "windows")] pub(super) fn windows_sendinput_options_from_prefs( preferences: &crate::types::UserPreferences, @@ -165,3 +324,85 @@ pub(super) fn windows_sendinput_options_from_prefs( newline_mode: preferences.windows_sendinput_newline_mode, } } + +#[cfg(all(test, target_os = "windows"))] +mod recording_recovery_tests { + use super::*; + + #[tokio::test] + async fn escape_arms_recovery_before_async_cancellation_finishes() { + let (coordinator, _, data_dir) = + super::super::hotkey_loops::windows_less_computer_tests::fixture_coordinator( + crate::types::HotkeyMode::Toggle, + std::time::Duration::from_millis(100), + ); + let inner = &coordinator.inner; + let mut preferences = inner.backend.get_preferences(); + preferences.esc_recording_recovery_enabled = true; + crate::set_backend_preferences_for_test(&inner.backend, preferences); + inner.backend.start().await.unwrap(); + let backend = Arc::clone(&inner.backend); + let starting = tokio::spawn(async move { backend.start_dictation().await }); + while inner.backend.snapshot().dictation.session_id.is_none() { + tokio::task::yield_now().await; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let session_id = inner + .backend + .snapshot() + .dictation + .session_id + .unwrap() + .to_string(); + + let mut cancelling = Box::pin(cancel_active_session_after_escape(inner)); + assert!(futures_util::poll!(cancelling.as_mut()).is_pending()); + assert_eq!( + inner.cancelled_recording_recovery.lock().as_deref(), + Some(session_id.as_str()) + ); + + assert!(cancelling.await); + assert_eq!( + starting.await.unwrap().unwrap_err().code, + openless_core::BackendErrorCode::Cancelled + ); + drop(coordinator); + std::fs::remove_dir_all(data_dir).unwrap(); + } + + #[test] + fn second_escape_and_disabled_setting_prevent_a_late_recovery_prompt() { + for disable_setting in [false, true] { + let (coordinator, _, data_dir) = + super::super::hotkey_loops::windows_less_computer_tests::fixture_coordinator( + crate::types::HotkeyMode::Toggle, + std::time::Duration::ZERO, + ); + let inner = &coordinator.inner; + let session_id = openless_core::SessionId::new().to_string(); + let mut preferences = inner.backend.get_preferences(); + preferences.esc_recording_recovery_enabled = true; + crate::set_backend_preferences_for_test(&inner.backend, preferences); + *inner.cancelled_recording_recovery.lock() = Some(session_id.clone()); + assert!(should_schedule_cancelled_recording_recovery( + inner, + &session_id + )); + + if disable_setting { + let mut preferences = inner.backend.get_preferences(); + preferences.esc_recording_recovery_enabled = false; + crate::set_backend_preferences_for_test(&inner.backend, preferences); + } else { + assert!(dismiss_cancelled_recording_recovery(inner, None)); + } + assert!(!should_schedule_cancelled_recording_recovery( + inner, + &session_id + )); + drop(coordinator); + std::fs::remove_dir_all(data_dir).unwrap(); + } + } +} diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 63af12fd..64c54981 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -20,7 +20,7 @@ pub(super) fn esc_cancel_bridge_loop(inner: Arc, rx: mpsc::Receiver<()>) esc_cancel_bridge_loop_with(inner, rx, |inner| { inner .host - .block_on(super::dictation::cancel_active_session(inner)); + .block_on(super::dictation::cancel_active_session_after_escape(inner)); }); } @@ -33,6 +33,9 @@ fn esc_cancel_bridge_loop_with( if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } + if super::dictation::dismiss_cancelled_recording_recovery(&inner, None) { + continue; + } cancel(&inner); } } @@ -2269,6 +2272,7 @@ pub(crate) mod windows_less_computer_tests { capsule_event_epoch: AtomicU64::new(0), capsule_event_lock: Mutex::new(()), selection_polish_capsule_active: AtomicBool::new(false), + cancelled_recording_recovery: Mutex::new(None), qa_context: Arc::new(TauriQaHostContext::default()), shutdown: AtomicBool::new(false), }); @@ -2342,6 +2346,7 @@ pub(crate) mod windows_less_computer_tests { operating: true, selection_polish: false, capsule_style: CapsuleStyle::Classic, + recovery_session_id: None, }; coordinator.present_core_capsule(expected.clone()); let (_, actual) = coordinator.inner.host.dismiss_insert_fallback_card(); diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index 2ef82fa7..72a84774 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -2415,17 +2415,7 @@ impl RecordingArchive for TauriRecordingArchive { format!("read dictation recording archive: {error}"), ) })?; - if wav.len() <= 44 - || &wav[..4] != b"RIFF" - || &wav[8..12] != b"WAVE" - || !(wav.len() - 44).is_multiple_of(2) - { - return Err(BackendError::new( - BackendErrorCode::Persistence, - "dictation recording archive is not canonical PCM WAV", - )); - } - Ok(wav[44..].to_vec()) + openless_core::decode_dictation_wav(&wav) }) } @@ -2512,6 +2502,29 @@ fn prepare_recording_options( (archive_path, mute) } +fn restore_or_discard_failed_start_archive(path: Option<&std::path::Path>, prefix: Option<&[u8]>) { + let Some(path) = path else { + return; + }; + if let Some(prefix) = prefix { + let result = openless_core::encode_dictation_wav(prefix).and_then(|wav| { + std::fs::write(path, wav).map_err(|error| { + BackendError::new( + BackendErrorCode::Persistence, + format!("restore cancelled recording archive: {error}"), + ) + }) + }); + if let Err(error) = result { + log::error!( + "[recorder] failed to restore recovery archive after startup error: {error}" + ); + } + } else { + let _ = std::fs::remove_file(path); + } +} + impl AudioRecorder for TauriAudioRecorder { fn start( &self, @@ -2554,26 +2567,36 @@ impl AudioRecorder for TauriAudioRecorder { recording_plan.mute_during_recording, crate::audio_mute::AudioMuteGuard::activate, ); + let elapsed_offset_ms = recording_plan.elapsed_offset_ms; let started_at = Instant::now(); let level_progress = Arc::clone(&progress); let level_handler: Arc = Arc::new(move |level| { - let _ = level_progress - .publish_level(started_at.elapsed().as_millis() as u64, level); + let elapsed_ms = + elapsed_offset_ms.saturating_add(started_at.elapsed().as_millis() as u64); + let _ = level_progress.publish_level(elapsed_ms, level); }); let consumer: Arc = Arc::new(AudioConsumerBridge { inner: consumer }); let recorder_archive_path = archive_path.clone(); - let start_result = - Recorder::start(microphone, consumer, level_handler, recorder_archive_path); + let archive_prefix = recording_plan.prefix_pcm; + let recovery_backup = archive_prefix.clone(); + let start_result = Recorder::start_with_archive_prefix( + microphone, + consumer, + level_handler, + recorder_archive_path, + archive_prefix, + ); let (recorder, runtime_errors, archive_active) = match start_result { Ok(started) => started, Err(error) => { - // Recorder::start may create the WAV before the native - // stream fails. No archive handle is returned on this - // path, so remove that partial file here. - if let Some(path) = &archive_path { - let _ = std::fs::remove_file(path); - } + // Recorder::start may replace the WAV before native + // startup fails. Restore a recovery prefix; otherwise + // discard the new partial file. + restore_or_discard_failed_start_archive( + archive_path.as_deref(), + recovery_backup.as_deref(), + ); return Err(map_recorder_error(error)); } }; @@ -3271,6 +3294,23 @@ mod tests { assert!(mute.is_none()); } + #[test] + fn failed_resume_start_restores_the_original_recording() { + let path = std::env::temp_dir().join(format!( + "openless-recovery-start-failure-{}.wav", + uuid::Uuid::new_v4() + )); + std::fs::write(&path, b"partial").unwrap(); + let prefix = [1, 0, 2, 0]; + + restore_or_discard_failed_start_archive(Some(&path), Some(&prefix)); + + let wav = std::fs::read(&path).unwrap(); + assert_eq!(openless_core::decode_dictation_wav(&wav).unwrap(), prefix); + restore_or_discard_failed_start_archive(Some(&path), None); + assert!(!path.exists()); + } + #[test] fn native_callback_tasks_run_on_the_tauri_host_runtime() { use openless_core::TaskSpawner; diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 13a9dc5f..c6158022 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -252,6 +252,8 @@ macro_rules! app_invoke_handler_desktop { commands::start_dictation, commands::stop_dictation, commands::cancel_dictation, + commands::resume_cancelled_recording, + commands::dismiss_cancelled_recording_recovery, coding_agent::commands::coding_agent_detect, coding_agent::commands::coding_agent_detect_opencode, coding_agent::commands::coding_agent_detect_cli, @@ -498,6 +500,8 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::start_dictation, $crate::commands::stop_dictation, $crate::commands::cancel_dictation, + $crate::commands::resume_cancelled_recording, + $crate::commands::dismiss_cancelled_recording_recovery, $crate::commands::qa_window_dismiss, $crate::commands::qa_toggle_recording, $crate::commands::qa_submit_text, diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index f5ee9dde..78e54249 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -101,6 +101,24 @@ impl Recorder { consumer: Arc, level_handler: Arc, audio_archive_path: Option, + ) -> Result<(Self, Receiver, bool), RecorderError> { + Self::start_with_archive_prefix( + microphone_device_name, + consumer, + level_handler, + audio_archive_path, + None, + ) + } + + /// 恢复被 Esc 打断的录音:`archive_prefix_pcm` 是旧 WAV 的 PCM data,创建新归档时 + /// 先写回它,再继续追加实时麦克风数据。这样中途恢复不会覆盖误按前已经说过的内容。 + pub fn start_with_archive_prefix( + microphone_device_name: Option, + consumer: Arc, + level_handler: Arc, + audio_archive_path: Option, + archive_prefix_pcm: Option>, ) -> Result<(Self, Receiver, bool), RecorderError> { // 启动信号:子线程构造 Stream 完成后通过 startup_tx 报告结果。 let (startup_tx, startup_rx) = channel::>(); @@ -111,11 +129,16 @@ impl Recorder { // 同步路径上尝试创建 WavArchiver——成功 / 失败都立刻知道,传给 caller 决定 // 是否在 history 标 has_audio_recording。失败仅 log::warn 不抛错,主路径继续。 - let archiver = audio_archive_path.and_then(|path| match WavArchiver::create(&path) { - Ok(arch) => Some(Arc::new(Mutex::new(arch))), - Err(err) => { - log::warn!("[recorder] wav archive create failed at {path:?}: {err}"); - None + let archiver = audio_archive_path.and_then(|path| { + match WavArchiver::create_with_prefix( + &path, + archive_prefix_pcm.as_deref().unwrap_or_default(), + ) { + Ok(arch) => Some(Arc::new(Mutex::new(arch))), + Err(err) => { + log::warn!("[recorder] wav archive create failed at {path:?}: {err}"); + None + } } }); let archive_active = archiver.is_some(); @@ -775,16 +798,17 @@ struct WavArchiver { } impl WavArchiver { - fn create(path: &Path) -> std::io::Result { + fn create_with_prefix(path: &Path, prefix_pcm: &[u8]) -> std::io::Result { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let mut file = std::fs::File::create(path)?; use std::io::Write; file.write_all(&build_wav_header(0))?; + file.write_all(prefix_pcm)?; Ok(Self { file, - bytes_written: 0, + bytes_written: prefix_pcm.len().min(u32::MAX as usize) as u32, }) } @@ -852,6 +876,22 @@ mod tests { .collect() } + #[test] + fn wav_archiver_prefix_is_preserved_before_new_audio() { + let path = std::env::temp_dir().join(format!( + "openless-recorder-resume-{}.wav", + uuid::Uuid::new_v4() + )); + { + let mut archiver = WavArchiver::create_with_prefix(&path, &[1, 0, 2, 0]).unwrap(); + archiver.append(&[3, 0, 4, 0]); + } + let wav = std::fs::read(&path).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(&wav[44..], &[1, 0, 2, 0, 3, 0, 4, 0]); + assert_eq!(u32::from_le_bytes(wav[40..44].try_into().unwrap()), 8); + } + #[test] fn downmix_to_mono_averages_complete_interleaved_frames() { let mono = downmix_to_mono(&[1.0, -1.0, 0.5, 0.25, 0.0], 2); diff --git a/openless-all/app/src-tauri/src/tauri_events.rs b/openless-all/app/src-tauri/src/tauri_events.rs index 6bb39ff8..8a353ce7 100644 --- a/openless-all/app/src-tauri/src/tauri_events.rs +++ b/openless-all/app/src-tauri/src/tauri_events.rs @@ -177,6 +177,7 @@ async fn forward_legacy_event( warming: false, capsule_style: backend.get_preferences().capsule_style, selection_polish: false, + recovery_session_id: None, }); } } @@ -254,6 +255,7 @@ async fn forward_legacy_event( warming: *phase == LessComputerVoicePhase::Starting, capsule_style: backend.get_preferences().capsule_style, selection_polish: false, + recovery_session_id: None, }); } } @@ -459,6 +461,7 @@ fn map_dictation_state( ) && !snapshot.recording_ready, capsule_style, selection_polish: false, + recovery_session_id: None, } } @@ -524,6 +527,7 @@ fn transcription_notice_payload( warming: false, capsule_style: style, selection_polish: false, + recovery_session_id: None, }) } @@ -641,6 +645,7 @@ fn qa_capsule_payload( warming: state == CapsuleState::Recording && !matches!(event, BackendEventKind::QaLevel(_)), capsule_style: style, selection_polish: false, + recovery_session_id: None, }) } diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index e377f696..cc678ea4 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -6,7 +6,7 @@ import { warmUpSiriShaders } from './SiriGL'; import { VoiceOrbStage } from './VoiceOrbStage'; import { TypelessCapsule } from './TypelessCapsule'; import { getSettings } from '../lib/ipc/settings'; -import { cancelDictation, stopDictation } from '../lib/ipc/dictation'; +import { cancelDictation, resumeCancelledRecording, stopDictation } from '../lib/ipc/dictation'; import { getCapsuleHostMetrics, getCapsuleMessageLayout, @@ -172,6 +172,72 @@ function SelectionPolishNotice({ state, message }: SelectionPolishNoticeProps) { ); } +function RecordingRecoveryPrompt({ sessionId, os }: { sessionId: string; os: OS }) { + const { t } = useTranslation(); + const [resuming, setResuming] = useState(false); + const [failed, setFailed] = useState(false); + + const resume = async () => { + if (resuming) return; + setResuming(true); + setFailed(false); + try { + await resumeCancelledRecording(sessionId); + } catch (error) { + console.error('[capsule] resume cancelled recording failed', error); + setFailed(true); + setResuming(false); + } + }; + + const continueLabel = t('capsule.recovery.continue'); + const failedLabel = t('capsule.recovery.failed'); + + return ( + + ); +} + // ───────── 经典药丸(Openless 默认风格)─────────────────────────────── // 从 1.3.14 的胶囊实现恢复:毛玻璃药丸 + 音量条 + 取消/确认按钮。尺寸与当时 // capsuleLayout 的常量保持一致(Siri 光效舞台是 460×180 全幅,经典版是居中小药丸)。 @@ -622,6 +688,7 @@ function getPreviewCapsulePayload() { translation: false, warming: false, selectionPolish: false, + recoverySessionId: null, style: 'siri' as CapsuleStyle, }; } @@ -639,6 +706,7 @@ function getPreviewCapsulePayload() { translation: params.get('translation') === '1', warming: params.get('warming') === '1', selectionPolish: params.get('selectionPolish') === '1', + recoverySessionId: params.get('recoverySessionId'), style: parseCapsuleStyle(params.get('style')) ?? 'siri', }; } @@ -663,6 +731,9 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { }); const [translation, setTranslation] = useState(preview.translation); const [selectionPolish, setSelectionPolish] = useState(preview.selectionPolish); + const [recoverySessionId, setRecoverySessionId] = useState( + preview.recoverySessionId, + ); // 偏好事件即时换肤;录音状态携带同一个样式,保证首次显示也能正确呈现。 const [capsuleStyle, setCapsuleStyle] = useState(preview.style); const stylePreferenceReadyRef = useRef(false); @@ -688,6 +759,9 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { preview.selectionPolish, ); const [lastVisibleMessage, setLastVisibleMessage] = useState(preview.message); + const [lastVisibleRecoverySessionId, setLastVisibleRecoverySessionId] = useState( + preview.recoverySessionId, + ); // 退出动画时长跟随样式;leaving effect 故意只依赖 state,所以经 ref 读取最新值。 const exitMsRef = useRef(capsuleStyle === 'siri' ? EXIT_ANIM_MS_SIRI : EXIT_ANIM_MS_CLASSIC); exitMsRef.current = capsuleStyle === 'siri' ? EXIT_ANIM_MS_SIRI : EXIT_ANIM_MS_CLASSIC; @@ -733,6 +807,7 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { setTranslation(p.translation === true); setWarming(p.warming === true); setSelectionPolish(p.selectionPolish === true); + setRecoverySessionId(p.recoverySessionId ?? null); const style = parseCapsuleStyle(p.capsuleStyle); if (style && !stylePreferenceReadyRef.current) setCapsuleStyle(style); if (p.insertedChars != null) insertedCharsRef.current = p.insertedChars; @@ -840,7 +915,8 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { if (state === 'idle') return; setLastVisibleSelectionPolish(selectionPolish); setLastVisibleMessage(message); - }, [state, selectionPolish, message]); + setLastVisibleRecoverySessionId(recoverySessionId); + }, [state, selectionPolish, message, recoverySessionId]); // 学习「预备态持续多久」= 麦克风就绪耗时,EMA 更新 warmupMs(预测展开的基准时长)。 useEffect(() => { @@ -891,6 +967,8 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { : state === 'transcribing' && localAsrText ? localAsrText : message; + const renderedRecoverySessionId = + state === 'idle' ? lastVisibleRecoverySessionId : recoverySessionId; return (
- {!renderedSelectionPolish && + {renderedRecoverySessionId ? ( + + ) : ( + !renderedSelectionPolish && (isClassic ? ( - ))} - {renderedSelectionPolish && ( + )) + )} + {!renderedRecoverySessionId && renderedSelectionPolish && ( )}
diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 9c159bf1..d4a7d8ad 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -82,6 +82,11 @@ export const de: typeof zhCN = { error: 'Ein Fehler ist aufgetreten', inserted: '{{count}} eingefügt', translating: 'Wird übersetzt', + recovery: { + continue: 'Aufnahme fortsetzen', + resuming: 'Wird fortgesetzt…', + failed: 'Fortsetzen fehlgeschlagen', + }, selectionPolish: { polishing: 'Wird überarbeitet…', replaced: 'Ersetzt', @@ -513,6 +518,15 @@ export const de: typeof zhCN = { retranscribe: 'Erneut transkribieren', retranscribing: 'Wird transkribiert…', retranscribeFailed: 'Erneute Transkription fehlgeschlagen: {{err}}', + cancelledRecording: 'Aufnahme abgebrochen · Audio gespeichert', + cancelledRecordingHint: + 'Diese Aufnahme wurde noch nicht transkribiert. Setze die Aufnahme fort oder transkribiere sie jetzt mit dem aktuellen Sprachmodell.', + continueRecording: 'Aufnahme fortsetzen', + continuingRecording: 'Wird wiederhergestellt…', + continueRecordingFailed: 'Aufnahme konnte nicht fortgesetzt werden: {{err}}', + startTranscription: 'Transkription starten', + pendingTranscription: 'Transkription ausstehend', + transcribedOnly: 'Nur transkribiert', rawLabel: 'Rohtext', rawEmpty: '(leer)', selectHint: 'Wähle links einen Eintrag aus, um die Details anzuzeigen.', @@ -1084,6 +1098,9 @@ export const de: typeof zhCN = { 'Nur im Umschaltmodus. Nach erkannter Sprache wird die Aufnahme automatisch beendet und gesendet, sobald die gewählte Dauer ohne Sprache verstrichen ist. Standardmäßig aus. Erneuter Tastendruck und Esc funktionieren weiterhin.', silenceAutoStopSecondsLabel: 'Dauer der Stille', silenceAutoStopSecondsValue: '{{value}}s', + escRecordingRecoveryLabel: 'Mit Esc abgebrochene Aufnahmen behalten', + escRecordingRecoveryDesc: + 'Wenn aktiviert, speichert Esc während der Aufnahme das bisherige Audio und bietet kurz „Aufnahme fortsetzen“ an. Wenn deaktiviert, wird die Aufnahme verworfen. Standardmäßig aus.', migrationNoticeTitle: 'Standard-Aufnahmemodus ist jetzt „Umschalten“', migrationNoticeDesc: 'Dieses Update ändert die Standardeinstellung. Falls du lieber die Taste gedrückt hältst, kannst du hier zurückwechseln.', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index cd366b35..d4a88b58 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -83,6 +83,11 @@ export const en: typeof zhCN = { error: 'Something went wrong', inserted: 'Inserted {{count}}', translating: 'Translating', + recovery: { + continue: 'Continue recording', + resuming: 'Resuming…', + failed: 'Could not resume. Continue from History.', + }, selectionPolish: { polishing: 'Polishing…', replaced: 'Replaced', @@ -505,6 +510,14 @@ export const en: typeof zhCN = { retranscribe: 'Retranscribe', retranscribing: 'Transcribing…', retranscribeFailed: 'Retranscribe failed: {{err}}', + cancelledRecording: 'Recording cancelled · audio saved', + cancelledRecordingHint: 'This recording has not been transcribed. Continue recording, or transcribe it now with the current speech model.', + continueRecording: 'Continue recording', + continuingRecording: 'Resuming…', + continueRecordingFailed: 'Could not continue recording: {{err}}', + startTranscription: 'Transcribe now', + pendingTranscription: 'Pending transcription', + transcribedOnly: 'Transcribed', rawLabel: 'Raw', rawEmpty: '(empty)', selectHint: 'Select an entry on the left to see details.', @@ -1061,6 +1074,8 @@ export const en: typeof zhCN = { 'Toggle only. After speech is detected, recording stops and submits automatically once silence lasts the chosen duration. Off by default; a second hotkey press and Esc still work.', silenceAutoStopSecondsLabel: 'Silence duration', silenceAutoStopSecondsValue: '{{value}}s', + escRecordingRecoveryLabel: 'Keep recordings cancelled with Esc', + escRecordingRecoveryDesc: 'When enabled, pressing Esc while recording keeps the captured audio and briefly offers Continue. When disabled, Esc cancels and discards the recording. Off by default.', migrationNoticeTitle: 'Default recording mode is now Toggle', migrationNoticeDesc: 'This update changes the default; if you prefer push-to-talk, switch it back here.', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index f719eec4..fedacfc6 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -82,6 +82,11 @@ export const es: typeof zhCN = { error: 'Se ha producido un error', inserted: '{{count}} insertados', translating: 'Traduciendo', + recovery: { + continue: 'Continuar grabación', + resuming: 'Reanudando…', + failed: 'No se pudo continuar', + }, selectionPolish: { polishing: 'Mejorando el texto…', replaced: 'Reemplazado', @@ -512,6 +517,15 @@ export const es: typeof zhCN = { retranscribe: 'Volver a transcribir', retranscribing: 'Transcribiendo…', retranscribeFailed: 'No se pudo volver a transcribir: {{err}}', + cancelledRecording: 'Grabación cancelada · audio guardado', + cancelledRecordingHint: + 'Esta grabación aún no se ha transcrito. Continúa grabando o transcríbela ahora con el modelo de voz actual.', + continueRecording: 'Continuar grabación', + continuingRecording: 'Restaurando…', + continueRecordingFailed: 'No se pudo continuar la grabación: {{err}}', + startTranscription: 'Iniciar transcripción', + pendingTranscription: 'Transcripción pendiente', + transcribedOnly: 'Solo transcrito', rawLabel: 'Original', rawEmpty: '(vacío)', selectHint: 'Selecciona un registro de la izquierda para ver sus detalles.', @@ -1080,6 +1094,9 @@ export const es: typeof zhCN = { 'Solo en modo Alternar. Después de detectar voz, detiene y envía la grabación cuando el silencio dura el tiempo elegido. Desactivado de forma predeterminada; puedes seguir pulsando el atajo o Esc.', silenceAutoStopSecondsLabel: 'Duración del silencio', silenceAutoStopSecondsValue: '{{value}}s', + escRecordingRecoveryLabel: 'Conservar grabaciones canceladas con Esc', + escRecordingRecoveryDesc: + 'Si se activa, pulsar Esc durante la grabación guarda el audio capturado y ofrece brevemente Continuar grabación. Si se desactiva, la grabación se descarta. Desactivado de forma predeterminada.', migrationNoticeTitle: 'El modo de grabación predeterminado ahora es Alternar', migrationNoticeDesc: 'Esta actualización cambia el modo predeterminado. Si prefieres mantener pulsado para hablar, cámbialo aquí.', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 8e13d997..15df42c7 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -82,6 +82,11 @@ export const fr: typeof zhCN = { error: 'Une erreur s’est produite', inserted: '{{count}} insérés', translating: 'Traduction', + recovery: { + continue: 'Continuer l’enregistrement', + resuming: 'Reprise…', + failed: 'Impossible de continuer', + }, selectionPolish: { polishing: 'Amélioration du texte…', replaced: 'Remplacé', @@ -518,6 +523,15 @@ export const fr: typeof zhCN = { retranscribe: 'Retranscrire', retranscribing: 'Transcription…', retranscribeFailed: 'Échec de la nouvelle transcription : {{err}}', + cancelledRecording: 'Enregistrement annulé · audio conservé', + cancelledRecordingHint: + 'Cet enregistrement n’a pas encore été transcrit. Continuez l’enregistrement ou transcrivez-le maintenant avec le modèle vocal actuel.', + continueRecording: 'Continuer l’enregistrement', + continuingRecording: 'Restauration…', + continueRecordingFailed: 'Impossible de continuer l’enregistrement : {{err}}', + startTranscription: 'Démarrer la transcription', + pendingTranscription: 'Transcription en attente', + transcribedOnly: 'Transcrit uniquement', rawLabel: 'Brut', rawEmpty: '(vide)', selectHint: 'Sélectionnez une entrée à gauche pour afficher ses détails.', @@ -1092,6 +1106,9 @@ export const fr: typeof zhCN = { 'En mode Basculer uniquement. Après avoir détecté la voix, arrête et envoie l’enregistrement lorsque le silence dure le temps choisi. Désactivé par défaut ; le raccourci et Échap restent disponibles.', silenceAutoStopSecondsLabel: 'Durée du silence', silenceAutoStopSecondsValue: '{{value}}s', + escRecordingRecoveryLabel: 'Conserver les enregistrements annulés avec Échap', + escRecordingRecoveryDesc: + 'Lorsque cette option est activée, Échap conserve l’audio enregistré et propose brièvement de continuer. Lorsqu’elle est désactivée, l’enregistrement est supprimé. Désactivée par défaut.', migrationNoticeTitle: 'Le mode d’enregistrement par défaut est désormais Basculer', migrationNoticeDesc: 'Cette mise à jour change le mode par défaut. Si vous préférez maintenir la touche pour parler, rétablissez ce mode ici.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 2382eed9..4937c09e 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -74,6 +74,11 @@ export const ja: typeof zhCN = { error: 'エラーが発生しました', inserted: '{{count}} 文字を入力しました', translating: '翻訳中', + recovery: { + continue: '録音を続ける', + resuming: '再開中…', + failed: '再開できません。履歴から続けられます。', + }, selectionPolish: { polishing: '推敲中...', replaced: '置き換えました', @@ -493,6 +498,14 @@ export const ja: typeof zhCN = { retranscribe: '再認識', retranscribing: '認識中…', retranscribeFailed: '再認識に失敗:{{err}}', + cancelledRecording: '録音をキャンセル · 音声は保存済み', + cancelledRecordingHint: 'この録音はまだ文字起こしされていません。録音を続けるか、現在の音声モデルですぐに文字起こしできます。', + continueRecording: '録音を続ける', + continuingRecording: '再開中…', + continueRecordingFailed: '録音を再開できません:{{err}}', + startTranscription: '文字起こしを開始', + pendingTranscription: '文字起こし待ち', + transcribedOnly: '文字起こし済み', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左側から 1 件選択して詳細を表示。', @@ -1048,6 +1061,8 @@ export const ja: typeof zhCN = { 'トグルモードのみ有効。音声を検出した後、無音が選択した時間続いたら録音を自動停止して送信します。一度も話さない場合は10秒後にキャンセル。既定ではオフで、2回目のキー押下による停止と Esc によるキャンセルは引き続き有効です。', silenceAutoStopSecondsLabel: '無音の長さ', silenceAutoStopSecondsValue: '{{value}} 秒', + escRecordingRecoveryLabel: 'Esc でキャンセルした録音を保持', + escRecordingRecoveryDesc: '有効にすると、録音中に Esc を押したとき録音済みの音声を保持し、「録音を続ける」を短時間表示します。無効時は録音を破棄してそのままキャンセルします。既定ではオフです。', migrationNoticeTitle: 'デフォルトがトグル式に変更されました', migrationNoticeDesc: '以前にトリガー方式を変更していた場合は、ここで再度確認してください。今回のアップデートではショートカット方式のデフォルト値と読み込みロジックが変更されています。「押し続けて話す」が好みであれば再度切り替えてください。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 0488e61a..a6430137 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -72,6 +72,11 @@ export const ko: typeof zhCN = { error: '오류 발생', inserted: '{{count}}자 입력됨', translating: '번역 중', + recovery: { + continue: '녹음 계속', + resuming: '재개 중…', + failed: '재개하지 못했습니다. 기록에서 계속할 수 있습니다.', + }, selectionPolish: { polishing: '다듬는 중...', replaced: '교체됨', @@ -491,6 +496,14 @@ export const ko: typeof zhCN = { retranscribe: '다시 인식', retranscribing: '인식 중…', retranscribeFailed: '다시 인식 실패: {{err}}', + cancelledRecording: '녹음 취소됨 · 오디오 저장됨', + cancelledRecordingHint: '이 녹음은 아직 텍스트로 변환되지 않았습니다. 녹음을 계속하거나 현재 음성 모델로 바로 변환할 수 있습니다.', + continueRecording: '녹음 계속', + continuingRecording: '재개 중…', + continueRecordingFailed: '녹음을 계속하지 못했습니다: {{err}}', + startTranscription: '텍스트 변환 시작', + pendingTranscription: '변환 대기', + transcribedOnly: '변환됨', rawLabel: '원문', rawEmpty: '(비어 있음)', selectHint: '왼쪽에서 하나를 선택하여 자세히 보기.', @@ -1040,6 +1053,8 @@ export const ko: typeof zhCN = { '토글 모드에서만 동작합니다. 음성이 감지된 후 선택한 시간 동안 침묵이 이어지면 녹음을 자동으로 종료하고 제출합니다. 말을 전혀 하지 않으면 10초 후 취소됩니다. 기본적으로 꺼져 있으며, 두 번째 키 누름으로 중지하고 Esc로 취소하는 동작은 그대로 유지됩니다.', silenceAutoStopSecondsLabel: '침묵 시간', silenceAutoStopSecondsValue: '{{value}}초', + escRecordingRecoveryLabel: 'Esc로 취소한 녹음 보관', + escRecordingRecoveryDesc: '켜면 녹음 중 Esc를 눌렀을 때 이미 녹음한 오디오를 보관하고 잠시 "녹음 계속" 항목을 표시합니다. 끄면 녹음을 바로 취소하고 삭제합니다. 기본값은 꺼짐입니다.', migrationNoticeTitle: '기본값이 토글 방식으로 변경됨', migrationNoticeDesc: '이전에 트리거 방식을 변경했다면 여기서 다시 한 번 확인해 주세요. 이번 업데이트는 단축키 방식의 기본값과 읽기 로직을 조정했습니다. "눌러서 말하기"가 더 익숙하다면 다시 전환할 수 있습니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 01796900..89e26691 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -75,6 +75,11 @@ export const zhCN = { error: '出错了', inserted: '已插入 {{count}}', translating: '正在翻译', + recovery: { + continue: '继续录音', + resuming: '恢复中…', + failed: '恢复失败,可在历史中继续', + }, selectionPolish: { polishing: '正在润色...', replaced: '已替换', @@ -485,6 +490,14 @@ export const zhCN = { retranscribe: '重新转录', retranscribing: '转录中…', retranscribeFailed: '重新转录失败:{{err}}', + cancelledRecording: '录音已取消 · 音频已保留', + cancelledRecordingHint: '这段录音尚未转写。可以继续录音,或直接使用当前识别模型开始转写。', + continueRecording: '继续录音', + continuingRecording: '正在恢复…', + continueRecordingFailed: '继续录音失败:{{err}}', + startTranscription: '开始转写', + pendingTranscription: '待转写', + transcribedOnly: '已转写', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左侧选一条查看详情。', @@ -1016,6 +1029,8 @@ export const zhCN = { '仅切换模式生效。检测到语音后,连续静音达到所选时长即自动结束并提交;一直没说话则 10 秒后取消。默认关闭;第二次按键停止和 Esc 取消仍然有效。', silenceAutoStopSecondsLabel: '静音时长', silenceAutoStopSecondsValue: '{{value}} 秒', + escRecordingRecoveryLabel: '保留 Esc 取消的录音', + escRecordingRecoveryDesc: '开启后,录音中按 Esc 会保存已录内容,并短暂显示“继续录音”入口;关闭时直接取消且不保留本次录音。默认关闭。', migrationNoticeTitle: '默认已改为切换式说话', migrationNoticeDesc: '本次更新调整了默认值,如果习惯按住说话,请在此处切回。', microphoneLabel: '首选麦克风', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 8e984df8..8524fd24 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -77,6 +77,11 @@ export const zhTW: typeof zhCN = { error: '出錯了', inserted: '已插入 {{count}}', translating: '正在翻譯', + recovery: { + continue: '繼續錄音', + resuming: '恢復中…', + failed: '恢復失敗,可在歷史中繼續', + }, selectionPolish: { polishing: '正在潤色...', replaced: '已替換', @@ -487,6 +492,14 @@ export const zhTW: typeof zhCN = { retranscribe: '重新轉錄', retranscribing: '轉錄中…', retranscribeFailed: '重新轉錄失敗:{{err}}', + cancelledRecording: '錄音已取消 · 音訊已保留', + cancelledRecordingHint: '這段錄音尚未轉寫。可以繼續錄音,或直接使用目前的辨識模型開始轉寫。', + continueRecording: '繼續錄音', + continuingRecording: '正在恢復…', + continueRecordingFailed: '繼續錄音失敗:{{err}}', + startTranscription: '開始轉寫', + pendingTranscription: '待轉寫', + transcribedOnly: '已轉寫', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左側選一條查看詳情。', @@ -1016,6 +1029,8 @@ export const zhTW: typeof zhCN = { '僅切換模式生效。偵測到語音後,連續靜音達到所選時長即自動結束並提交;一直沒說話則 10 秒後取消。預設關閉;第二次按鍵停止和 Esc 取消仍然有效。', silenceAutoStopSecondsLabel: '靜音時長', silenceAutoStopSecondsValue: '{{value}} 秒', + escRecordingRecoveryLabel: '保留 Esc 取消的錄音', + escRecordingRecoveryDesc: '開啟後,錄音中按 Esc 會儲存已錄內容,並短暫顯示「繼續錄音」入口;關閉時直接取消且不保留本次錄音。預設關閉。', migrationNoticeTitle: '默認已改爲切換式說話', migrationNoticeDesc: '如果你之前改過快捷鍵觸發方式,請在這裏手動確認一次。本次更新調整了快捷鍵方式的默認值與讀取邏輯;如果你更習慣按住說話,可以重新切回“按住說話”。', diff --git a/openless-all/app/src/lib/ipc/dictation.ts b/openless-all/app/src/lib/ipc/dictation.ts index 1ed2bc89..0819fc63 100644 --- a/openless-all/app/src/lib/ipc/dictation.ts +++ b/openless-all/app/src/lib/ipc/dictation.ts @@ -12,6 +12,24 @@ export function cancelDictation(): Promise { return invokeOrMock('cancel_dictation', undefined, () => undefined); } +/** 沿用原 session id 与 WAV,继续一条被 Esc 打断的录音。 */ +export function resumeCancelledRecording(sessionId: string): Promise { + return invokeOrMock( + "resume_cancelled_recording", + { sessionId }, + () => undefined, + ) +} + +/** 主动收起 3 秒恢复提示;历史记录与 WAV 不受影响。 */ +export function dismissCancelledRecordingRecovery(sessionId?: string): Promise { + return invokeOrMock( + "dismiss_cancelled_recording_recovery", + { sessionId: sessionId ?? null }, + () => undefined, + ) +} + export function handleWindowHotkeyEvent( eventType: 'keydown' | 'keyup', key: string, diff --git a/openless-all/app/src/lib/ipc/index.ts b/openless-all/app/src/lib/ipc/index.ts index 5f984a57..6a4de834 100644 --- a/openless-all/app/src/lib/ipc/index.ts +++ b/openless-all/app/src/lib/ipc/index.ts @@ -93,6 +93,8 @@ export { startDictation, stopDictation, cancelDictation, + resumeCancelledRecording, + dismissCancelledRecordingRecovery, handleWindowHotkeyEvent, } from './dictation'; diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index b58e65a8..f8127a28 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -51,6 +51,7 @@ export let mockSettings: UserPreferences = { audioCueOnRecord: true, silenceAutoStopEnabled: false, silenceAutoStopSeconds: 3, + escRecordingRecoveryEnabled: false, microphoneDeviceName: '', activeAsrProvider: 'foundry-local-whisper', activeLlmProvider: 'ark', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index f7c6dc89..2d7bb3f8 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -349,6 +349,8 @@ export interface UserPreferences { silenceAutoStopEnabled: boolean; /** 语音后的连续静音阈值(秒)。可选 1 / 1.5 / 2 / 3 / 4 / 5,默认 3。 */ silenceAutoStopSeconds: number; + /** 录音中按 Esc 时保留已录音频,并提供继续录音入口。默认关闭。 */ + escRecordingRecoveryEnabled: boolean; /** 录音输入设备名称。空字符串 = 使用系统默认麦克风。 */ microphoneDeviceName: string; activeAsrProvider: string; @@ -697,6 +699,8 @@ export interface CapsulePayload { * 语音/QA 胶囊行为,兼容旧后端 payload。 */ selectionPolish?: boolean; + /** Esc 取消后可继续录音的历史/WAV session id;只在 3 秒恢复提示期间存在。 */ + recoverySessionId?: string | null; } export interface CredentialsStatus { diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index c9fc8368..bc4c028d 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -14,6 +14,7 @@ import { listStylePacks, readAudioRecording, repolish, + resumeCancelledRecording, retranscribeRecording, isTauri, } from '../lib/ipc'; @@ -83,6 +84,7 @@ export function History() { const [justCopiedRaw, setJustCopiedRaw] = useState(false); // 「重新转录」进行中:禁用按钮 + 显示「转录中…」,避免重复点击发起多次 ASR。 const [retranscribing, setRetranscribing] = useState(false); + const [resumingRecording, setResumingRecording] = useState(false); // 录音文件 lazily-detected missing 状态:retention / 条数 cap 清理后磁盘上 wav // 可能已被删,但 history 条目 hasAudioRecording 仍写 true。任一组件 // (播放 / 导出)首次 IPC 拿到 'recording not found' 时把 id 加进来, @@ -305,7 +307,7 @@ export function History() { // 返回整条记录;前端据此局部刷新。失败保留 + 自动重试已让这些条目的录音留得住,这里给 // 持久失败(重试也没救回来)一个手动重转入口。 const onRetranscribe = async () => { - if (!item || !item.hasAudioRecording) return; + if (!item || !item.hasAudioRecording || resumingRecording) return; setRetranscribing(true); setActionError(null); try { @@ -325,6 +327,29 @@ export function History() { } }; + const onResumeRecording = async () => { + if (!item || item.errorCode !== 'recordingCancelled' || retranscribing) return; + const resumedId = item.id; + setResumingRecording(true); + setActionError(null); + try { + await resumeCancelledRecording(resumedId); + setItems((prev) => prev.filter((session) => session.id !== resumedId)); + setSelectedId(null); + if (mobile) setMobileDetailOpen(false); + } catch (error) { + console.error('[history] resume recording failed', error); + const msg = errorMessage(error); + if (msg.includes('recording not found') || msg.includes('not found')) { + markAudioMissing(resumedId); + } else { + setActionError(t('history.continueRecordingFailed', { err: msg })); + } + } finally { + setResumingRecording(false); + } + }; + return (
- {s.finalText.split('\n')[0]} + {s.errorCode === 'recordingCancelled' + ? t('history.cancelledRecording') + : s.finalText.split('\n')[0]}
{/* tone 仍按 baseMode 走:颜色保留原来的粗分类信息,文字换成实际风格包名。 */}
@@ -594,11 +621,27 @@ export function History() {
+ {item.errorCode === 'recordingCancelled' && + item.hasAudioRecording && + !audioMissingIds.has(item.id) && ( + void onResumeRecording()} + > + {resumingRecording + ? t('history.continuingRecording') + : t('history.continueRecording')} + + )} {item.hasAudioRecording && !audioMissingIds.has(item.id) && ( void onExportAudio()} > {t('history.exportRecording')} @@ -608,22 +651,48 @@ export function History() { !audioMissingIds.has(item.id) && item.pipelineMode !== 'multimodal' && (item.errorCode === 'transcribeFailed' || - item.errorCode === 'emptyTranscript') && ( + item.errorCode === 'emptyTranscript' || + item.errorCode === 'recordingCancelled') && ( void onRetranscribe()} > - {retranscribing ? t('history.retranscribing') : t('history.retranscribe')} + {retranscribing + ? t('history.retranscribing') + : item.errorCode === 'recordingCancelled' + ? t('history.startTranscription') + : t('history.retranscribe')} )} - + {t('common.delete')}
+ {item.errorCode === 'recordingCancelled' && ( +
+ {t('history.cancelledRecordingHint')} +
+ )} {/* key 必须带组件前缀:下面的 RepolishPanel 是同一层的兄弟节点,两个都写 裸 `item.id` 会让同层出现重复 key,React 只警告不报错,但 reconcile 匹配 不上旧 fiber —— 每切换一次历史条目就在 DOM 里残留一个「播放录音」按钮, @@ -743,7 +812,11 @@ export function History() { ? t('history.copiedFallback', { shortcut: os === 'mac' ? '⌘V' : 'Ctrl+V', }) - : t('history.insertFailed')} + : item.errorCode === 'recordingCancelled' + ? t('history.pendingTranscription') + : item.errorCode == null && item.hasAudioRecording && item.rawTranscript + ? t('history.transcribedOnly') + : t('history.insertFailed')} {/* minWidth: 0 —— grid 子项默认 min-width: auto,任何不换行的内容(这里是风格包名 diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index 373d8e28..fa6aab2d 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -152,6 +152,7 @@ export function RecordingInputSection() { const isAndroid = platformCaps?.platform === 'android'; const showDesktopHotkey = platformCaps?.supportsDesktopHotkey === true; const showDesktopInsert = showDesktopHotkey && os !== 'linux'; + const showEscRecordingRecovery = showDesktopHotkey && (os === 'mac' || os === 'win'); const showDesktopStartup = showDesktopHotkey; const effectivePasteShortcut = os === 'win' && prefs.pasteShortcut === 'shiftInsert' ? 'ctrlV' : prefs.pasteShortcut; @@ -402,6 +403,17 @@ export function RecordingInputSection() { )} + {showEscRecordingRecovery && ( + + savePrefs({ ...prefs, escRecordingRecoveryEnabled: next })} + /> + + )}