diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index ae0d1eb0..1579dc3f 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -64,6 +64,7 @@ use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionCon mod asr_setup; mod capsule; mod dictation; +mod dictation_end; mod dictation_session; mod dictation_streaming; mod dictation_voice_agent; @@ -79,6 +80,7 @@ mod voice_agent_hotkeys; // 引用继续通过父模块解析(拆分前的 `use super::*` 契约)。 pub(crate) use asr_setup::*; pub(crate) use capsule::*; +pub(crate) use dictation_end::*; pub(crate) use dictation_session::*; pub(crate) use dictation_streaming::*; pub(crate) use dictation_voice_agent::*; @@ -90,7 +92,7 @@ pub(crate) use voice_agent_hotkeys::*; #[cfg(test)] use dictation_session::dictation_error_code; -use dictation::{end_session, handle_pressed_edge, handle_released_edge}; +use dictation::{handle_pressed_edge, handle_released_edge}; use dictation_session::{begin_session, cancel_session, request_stop_during_starting}; #[cfg(any(debug_assertions, test))] use dictation::{handle_pressed, handle_released}; diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 6cb80c08..e8366861 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1,11 +1,9 @@ use std::sync::atomic::Ordering; use std::sync::Arc; -use crate::correction::apply_correction_rules; use crate::types::HotkeyMode; use super::qa::handle_qa_option_edge; -use super::resources::*; use super::*; /// 同一个 hotkey 边沿之间的最小间隔。低于此阈值的连按整体作为误触丢弃 —— @@ -126,784 +124,6 @@ pub(super) async fn handle_released(inner: &Arc) { } } -pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { - let current_session_id = { - let mut state = inner.state.lock(); - let Some(session_id) = start_processing_if_listening(&mut state) else { - return Ok(()); - }; - session_id - }; - - let elapsed = inner.state.lock().started_at.elapsed().as_millis() as u64; - emit_capsule(inner, CapsuleState::Transcribing, 0.0, elapsed, None, None); - - if let Some(rec) = take_recorder_for_session(inner, current_session_id) { - rec.stop(); - release_recording_mute(inner, "dictation"); - } - - let asr_opt = take_asr_for_session(inner, current_session_id); - let asr = match asr_opt { - Some(a) => a, - None => { - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - return Ok(()); - } - }; - - let uses_global_timeout = asr_transcribe_uses_global_timeout(&asr); - let raw = match asr { - ActiveAsr::Volcengine(asr) => { - debug_assert!(uses_global_timeout); - if let Err(e) = asr.send_last_frame().await { - log::error!("[coord] send last frame failed: {e}"); - } - // 添加全局超时保护:防止 await_final_result() 永远挂起 - let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); - match tokio::time::timeout(timeout_duration, asr.await_final_result()).await { - Ok(Ok(r)) => r, - Ok(Err(e)) => { - log::error!("[coord] await final failed: {e}"); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - elapsed, - Some(format!("识别失败: {e}")), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err(e.to_string()); - } - Err(_) => { - // 全局超时:最后的防线 - log::error!( - "[coord] 全局超时 {} 秒 - 强制恢复", - COORDINATOR_GLOBAL_TIMEOUT_SECS - ); - // 清理 ASR session,避免资源泄漏 - asr.cancel(); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - elapsed, - Some("识别超时".to_string()), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err("global timeout".to_string()); - } - } - } - ActiveAsr::Whisper(w) => { - debug_assert!(uses_global_timeout); - // Whisper 也添加类似的超时保护 - let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); - match tokio::time::timeout(timeout_duration, w.transcribe()).await { - Ok(Ok(r)) => r, - Ok(Err(e)) => { - log::error!("[coord] whisper transcribe failed: {e}"); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - elapsed, - Some(format!("识别失败: {e}")), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err(e.to_string()); - } - Err(_) => { - log::error!( - "[coord] whisper 全局超时 {} 秒", - COORDINATOR_GLOBAL_TIMEOUT_SECS - ); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - elapsed, - Some("识别超时".to_string()), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err("whisper global timeout".to_string()); - } - } - } - ActiveAsr::Mimo(m) => { - debug_assert!(uses_global_timeout); - let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); - match tokio::time::timeout(timeout_duration, m.transcribe()).await { - Ok(Ok(r)) => r, - Ok(Err(e)) => { - log::error!("[coord] MiMo ASR transcribe failed: {e}"); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - elapsed, - Some(format!("识别失败: {e}")), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err(e.to_string()); - } - Err(_) => { - log::error!( - "[coord] MiMo ASR 全局超时 {} 秒", - COORDINATOR_GLOBAL_TIMEOUT_SECS - ); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - elapsed, - Some("识别超时".to_string()), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err("mimo global timeout".to_string()); - } - } - } - ActiveAsr::Bailian(asr) => { - debug_assert!(uses_global_timeout); - if let Err(e) = asr.send_last_frame().await { - log::error!("[coord] Bailian send last frame failed: {e}"); - } - let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); - match tokio::time::timeout(timeout_duration, asr.await_final_result()).await { - Ok(Ok(r)) => r, - Ok(Err(e)) => { - log::error!("[coord] Bailian await final failed: {e}"); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - elapsed, - Some(format!("识别失败: {e}")), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err(e.to_string()); - } - Err(_) => { - log::error!( - "[coord] Bailian 全局超时 {} 秒", - COORDINATOR_GLOBAL_TIMEOUT_SECS - ); - asr.cancel(); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - elapsed, - Some("识别超时".to_string()), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err("bailian global timeout".to_string()); - } - } - } - #[cfg(target_os = "windows")] - ActiveAsr::FoundryLocalWhisper(local) => { - debug_assert!(!uses_global_timeout); - match local - .transcribe(foundry_audio_transcribe_timeout_duration()) - .await - { - Ok(r) => { - schedule_foundry_local_asr_release( - inner, - AsrReleaseSession::Dictation(current_session_id), - ); - r - } - Err(e) => { - if inner.state.lock().cancelled { - log::info!( - "[coord] Foundry Local Whisper transcribe cancelled — discarding transcript" - ); - schedule_foundry_local_asr_release( - inner, - AsrReleaseSession::Dictation(current_session_id), - ); - restore_prepared_windows_ime_session(inner, current_session_id); - set_phase_idle_if_session_matches(inner, current_session_id); - return Ok(()); - } - log::error!("[coord] Foundry Local Whisper transcribe failed: {e:#}"); - schedule_foundry_local_asr_release( - inner, - AsrReleaseSession::Dictation(current_session_id), - ); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - elapsed, - Some(format!("本地识别失败: {e}")), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err(e.to_string()); - } - } - } - // Windows sherpa-onnx offline batch:停止录音后整段转写,再复用现有 - // polish / insert / history 收尾路径。 - #[cfg(target_os = "windows")] - ActiveAsr::SherpaOnnxLocal(local) => { - debug_assert!(!uses_global_timeout); - match local - .transcribe(sherpa_audio_transcribe_timeout_duration()) - .await - { - Ok(r) => { - schedule_sherpa_onnx_release( - inner, - AsrReleaseSession::Dictation(current_session_id), - ); - r - } - Err(e) => { - if inner.state.lock().cancelled { - log::info!( - "[coord] sherpa-onnx transcribe cancelled — discarding transcript" - ); - schedule_sherpa_onnx_release( - inner, - AsrReleaseSession::Dictation(current_session_id), - ); - restore_prepared_windows_ime_session(inner, current_session_id); - set_phase_idle_if_session_matches(inner, current_session_id); - return Ok(()); - } - log::error!("[coord] sherpa-onnx transcribe failed: {e:#}"); - schedule_sherpa_onnx_release( - inner, - AsrReleaseSession::Dictation(current_session_id), - ); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - elapsed, - Some(format!("本地识别失败: {e}")), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err(e.to_string()); - } - } - } - #[cfg(target_os = "macos")] - ActiveAsr::Local(local) => { - debug_assert!(uses_global_timeout); - // 缓存命中时 transcribe 不含 load 时间;冷启动 load 已在 build_local_qwen3 - // 提前完成。但 transcribe 本身受音频长度影响:用户实测 RTF ≈ 0.3,慢机 - // 可达 0.5;15s 固定超时在 ≥ 30s 录音上会把整段结果丢掉。改用动态 - // 超时 max(15, ceil(audio_s × 0.6) + 10),公式与单测见 - // `local_qwen_transcribe_timeout`。 - let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0; - let timeout_duration = local_qwen_transcribe_timeout(audio_secs); - log::info!( - "[coord] local Qwen3-ASR transcribe: audio={:.2}s timeout={}s", - audio_secs, - timeout_duration.as_secs() - ); - let result = tokio::time::timeout(timeout_duration, local.transcribe()).await; - inner.local_asr_cache.touch(); - schedule_local_asr_release(inner); - match result { - Ok(Ok(r)) => r, - Ok(Err(e)) => { - log::error!("[coord] local Qwen3-ASR transcribe failed: {e:#}"); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - elapsed, - Some(format!("本地识别失败: {e}")), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err(e.to_string()); - } - Err(_) => { - log::error!( - "[coord] local Qwen3-ASR 动态超时 {}s(音频 {:.2}s)", - timeout_duration.as_secs(), - audio_secs - ); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - elapsed, - Some("识别超时".to_string()), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err("local global timeout".to_string()); - } - } - } - }; - - // ASR 完成后 cancel 检查:用户在 transcribe 进行中按 Esc 时,这里就会命中。 - // 优先级高于 empty 检查 — 用户取消 → 静默丢弃,不写失败历史也不弹错误胶囊。 - if inner.state.lock().cancelled { - log::info!("[coord] cancel detected after ASR — discarding transcript"); - restore_prepared_windows_ime_session(inner, current_session_id); - // PR #387 的「cancel 后清 focus_target」契约要在 Processing 路径上也成立。 - // cancel_session 在 Processing 阶段故意跳过 finish_cancel_session_state(让 - // 这里收尾),但此前的 end_session 没把 focus_target 清掉。logic-review - // 2026-05-10 P3 (🚩) 把这条补完。 - { - let mut state = inner.state.lock(); - state.phase = SessionPhase::Idle; - state.focus_target = None; - } - return Ok(()); - } - - // ASR 返回空转写护栏(来自 PR #66):写一条 emptyTranscript 失败历史 + 错误胶囊, - // 与 main 上其它 error 路径保持一致(带 schedule_capsule_idle 让胶囊自动消失)。 - let mut raw = raw; - - #[cfg(any(debug_assertions, test))] - if raw.text.trim().is_empty() { - if let Some(debug_text) = debug_transcript_override_text() { - log::info!( - "[coord] using debug transcript override (chars={})", - debug_text.chars().count() - ); - raw.text = debug_text; - } - } - - if raw.text.trim().is_empty() { - let session = DictationSession { - id: Uuid::new_v4().to_string(), - created_at: Utc::now().to_rfc3339(), - raw_transcript: raw.text.clone(), - 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("emptyTranscript".to_string()), - duration_ms: Some(raw.duration_ms), - dictionary_entry_count: Some(enabled_phrases(inner).len() as u32), - // empty-transcript(ASR 没识别到任何文字)也保留 wav 标记——这是用户最想 - // 通过原始录音定位"是不是麦克风太小声 / ASR 模型问题"的场景。修 pr_agent - // "Missing Audio" 反馈。 - 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 failed: {e}"); - } - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - elapsed, - Some("没有识别到语音".to_string()), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err("ASR returned empty transcript".to_string()); - } - - let correction_rules = match inner.correction_rules.list() { - Ok(rules) => rules, - Err(e) => { - log::warn!("[coord] load correction rules failed: {e}; continue without correction"); - Vec::new() - } - }; - let front_app = inner.state.lock().front_app.clone(); - if !correction_rules.is_empty() { - let corrected = apply_correction_rules(&raw.text, &correction_rules); - if corrected != raw.text { - log::info!( - "[coord] correction rules adjusted raw transcript ({} → {} chars)", - raw.text.chars().count(), - corrected.chars().count() - ); - raw.text = corrected; - } - } - - // Cloud Agent 语音分流:长按升级的会话不走润色/插入,转写交给 Claude 跑任务、结果弹胶囊。 - if inner.state.lock().voice_agent { - return run_voice_agent_transcript(inner, current_session_id, raw.text.clone(), elapsed) - .await; - } - - emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None); - - let prefs = inner.prefs.get(); - let pack = match inner - .style_packs - .get_or_default_active(&prefs.active_style_pack_id) - { - Ok(pack) => pack, - Err(error) => { - log::warn!( - "[coord] active style pack unavailable, falling back to builtin light: {error}" - ); - crate::types::builtin_style_pack_for_mode(PolishMode::Light) - } - }; - let mode = pack.base_mode; - let hotword_strs = enabled_phrases(inner); - let working_languages = prefs.working_languages.clone(); - let chinese_script_preference = prefs.chinese_script_preference; - let output_language_preference = prefs.output_language_preference; - let llm_thinking_enabled = prefs.llm_thinking_enabled; - let style_system_prompt = pack.prompt.clone(); - let raw_uses_llm = mode == PolishMode::Raw && super::raw_style_pack_uses_llm(&pack); - let translation_target = prefs.translation_target_language.trim().to_string(); - let translation_active = - inner.translation_modifier_seen.load(Ordering::SeqCst) && !translation_target.is_empty(); - log::info!( - "[style-pack] runtime dispatch session_id={} active_pack={} kind={:?} mode={:?} raw_chars={} prompt_chars={} raw_uses_llm={} translation_active={} hotwords={} working_languages={:?}", - current_session_id, - pack.id, - pack.kind, - mode, - raw.text.chars().count(), - style_system_prompt.chars().count(), - raw_uses_llm, - translation_active, - hotword_strs.len(), - working_languages - ); - // 对话感知 polish:拉最近 N 分钟的会话作为 LLM 上下文。翻译现在也走"润色+翻译"单次 - // LLM 调用,所以翻译路径同样需要上下文;只有 Raw 且不走 LLM 才没意义。窗口=0 时为空 Vec。 - // 只复用同一 active style pack 的历史;翻译历史按当前是否翻译决定喂译文还是润色后源文 - // (见 eligible_polish_context_turns)。 - let polish_context_window_minutes = prefs.polish_context_window_minutes; - let prior_turns: Vec<(String, String)> = if (translation_active - || mode != PolishMode::Raw - || raw_uses_llm) - && polish_context_window_minutes > 0 - { - match inner - .history - .recent_within_minutes(polish_context_window_minutes) - { - Ok(sessions) => eligible_polish_context_turns(sessions, &pack.id, translation_active), - Err(e) => { - log::warn!("[coord] fetch polish context failed: {e}; fall back to single-turn"); - Vec::new() - } - } - } else { - Vec::new() - }; - // 流式插入 opt-in 路径:开关打开 + 非翻译 + 非 Raw 模式 → 进入流式分支。 - // 任何不满足都走原一次性 polish_or_passthrough 路径,行为跟历史完全一致。 - let streaming_eligible = streaming_insert_eligible( - prefs.streaming_insert, - translation_active, - mode, - raw_uses_llm, - ); - log::info!( - "[coord] polish dispatch: translation={translation_active} mode={mode:?} streaming_eligible={streaming_eligible}" - ); - - // Linux: emit_capsule(Polishing) 已通过 fcitx5 auxDown 显示 "✨ 润色中...", - // 无需在此重复调用。 - - // 翻译会话润色后的源语言文本(译文前的中间产物),仅翻译路径解析成功时有值, - // 写进 history 供后续普通润色轮复用(剔除译文、避免外语污染)。 - let mut polish_source: Option = None; - let (polished, polish_error, already_streamed) = if translation_active { - log::info!( - "[coord] translation mode → target=\u{300C}{}\u{300D} working={:?} front_app={:?}", - translation_target, - working_languages, - front_app - ); - let (p, src, e) = polish_and_translate_or_passthrough( - &raw, - &translation_target, - mode, - &hotword_strs, - &working_languages, - chinese_script_preference, - output_language_preference, - llm_thinking_enabled, - front_app.as_deref(), - &prior_turns, - ) - .await; - polish_source = src; - (p, e, false) - } else if streaming_eligible { - run_streaming_polish( - inner, - &raw, - mode, - &hotword_strs, - &style_system_prompt, - &working_languages, - chinese_script_preference, - output_language_preference, - llm_thinking_enabled, - front_app.as_deref(), - &prior_turns, - ) - .await - } else { - let (p, e) = polish_or_passthrough( - &raw, - mode, - &hotword_strs, - &style_system_prompt, - &working_languages, - chinese_script_preference, - output_language_preference, - llm_thinking_enabled, - front_app.as_deref(), - &prior_turns, - ) - .await; - (p, e, false) - }; - - let polished = finalize_polished_text( - polished, - translation_active, - raw_uses_llm, - mode, - &polish_error, - chinese_script_preference, - &correction_rules, - already_streamed, - ); - // 原子化最后一次 cancel 检查 + 转 Inserting: - // 在同一 lock 内决定「丢弃」还是「进入 Inserting」。一旦设到 Inserting, - // cancel_session 就拒绝介入(Cmd+V 已发出,撤销不掉)。这是 audit HIGH #2 的修复, - // 之前 check 与 inserter.insert 之间有窗口期。 - // - // 流式路径例外:`already_streamed = true` 表示字符已经一边流一边落到光标了, - // 撤销不掉。即使 cancel 旗在中途被立起来,也只能尊重「已经发生」的事实,进入 - // Inserting 状态完成 history / vocab 等收尾工作。 - let proceed_to_insert = { - let mut state = inner.state.lock(); - if state.cancelled && !already_streamed { - state.phase = SessionPhase::Idle; - false - } else { - state.phase = SessionPhase::Inserting; - true - } - }; - if !proceed_to_insert { - log::info!( - "[coord] cancel detected before insert — discarding output (chars={})", - polished.chars().count() - ); - restore_prepared_windows_ime_session(inner, current_session_id); - return Ok(()); - } - - let focus_target = inner.state.lock().focus_target; - let focus_ready_for_paste = restore_focus_target_if_possible(focus_target); - let prefs = inner.prefs.get(); - let restore_clipboard = prefs.restore_clipboard_after_paste; - let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback; - let paste_shortcut = prefs.paste_shortcut; - // 流式路径下,字符已经通过 Unicode keystroke 落到光标处,跳过 inserter.insert。 - let status = if already_streamed { - log::info!( - "[coord] insertion skipped: {} chars already streamed via unicode_keystroke (polish_error={:?})", - polished.chars().count(), - polish_error - ); - InsertStatus::Inserted - } else if focus_ready_for_paste { - #[cfg(target_os = "windows")] - { - let ime_target = capture_ime_submit_target(); - insert_with_windows_ime_first( - inner, - current_session_id, - &polished, - restore_clipboard, - allow_non_tsf_insertion_fallback, - paste_shortcut, - ime_target, - ) - .await - } - #[cfg(not(target_os = "windows"))] - { - inner - .inserter - .insert(&polished, restore_clipboard, paste_shortcut) - } - } else { - #[cfg(target_os = "linux")] - { - // Linux: fcitx5 commitString 无需窗口焦点,始终尝试插入。 - inner - .inserter - .insert(&polished, restore_clipboard, paste_shortcut) - } - #[cfg(not(target_os = "linux"))] - { - log::warn!( - "[coord] original insertion target is not foreground; copied output without paste" - ); - if allow_non_tsf_insertion_fallback { - inner.inserter.copy_fallback(&polished) - } else { - InsertStatus::Failed - } - } - }; - restore_prepared_windows_ime_session(inner, current_session_id); - let inserted_chars = polished.chars().count() as u32; - - // 累计每条 enabled 词条在最终文本中的命中次数。 - // 用 polished(最终插入的文本)扫描,与用户实际看到的输出一致。 - let total_hits: u64 = match inner.vocab.record_hits(&polished) { - Ok(n) => n, - Err(e) => { - log::error!("[coord] record_hits failed: {e}"); - 0 - } - }; - // 词汇本页面在打开时通常需要立即看到 hits 增长,否则用户得手动切走再切回来才刷新。 - // 命中数 > 0 时通知前端:Vocab 页面订阅 vocab:updated 即时 listVocab() 重新加载。 - if total_hits > 0 { - if let Some(app) = inner.app.lock().clone() { - let _ = app.emit("vocab:updated", total_hits); - } - } - - // polish 失败时在 history 里标记 polishFailed,让用户能在历史详情看到为什么这次输出 - // 不是预期的 mode 风格。即使失败也不丢词 — final_text 仍是原文(保留"用户的话不丢"语义)。 - let error_code = dictation_error_code( - status, - polish_error.is_some(), - focus_ready_for_paste, - allow_non_tsf_insertion_fallback, - ) - .map(str::to_string); - let tsf_required_insert_failed = error_code.as_deref() == Some("windowsImeTsfRequired"); - - // 与 coordinator 内部 SessionId 对齐:方便 recorder 旁路写盘的 `.wav` - // 跟 history 这条 DictationSession.id 同名,前端凭 id 就能找到对应录音文件。 - let history_session_id = current_session_id.to_string(); - let history_created_at = Utc::now().to_rfc3339(); - let prefs_snapshot = inner.prefs.get(); - let session = DictationSession { - id: history_session_id.clone(), - created_at: history_created_at.clone(), - raw_transcript: raw.text.clone(), - final_text: polished.clone(), - mode, - style_pack_id: Some(pack.id.clone()), - translation_active, - polish_source, - app_bundle_id: None, - app_name: None, - insert_status: status, - error_code, - duration_ms: Some(raw.duration_ms), - // 历史详情页的"X 个热词"显示:用本次实际命中次数(每个匹配实例算一次), - // 比"启用词条总数"更能反映本段口述命中了多少。u64 → u32 截断对单段听写足够。 - dictionary_entry_count: Some(total_hits.min(u32::MAX as u64) as u32), - // 用 begin_session 时 Recorder::start 返回的实际写盘状态,而不是 prefs 开关—— - // 开关打开但路径创建失败时这里是 false,避免前端渲染播放按钮后端 404。 - has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)), - }; - 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 failed: {e}"); - } - let done_message = if tsf_required_insert_failed { - Some("TSF 未上屏,已禁止非 TSF 兜底".to_string()) - } else { - default_done_message(status, polish_error.is_some()) - }; - - emit_capsule( - inner, - CapsuleState::Done, - 0.0, - elapsed, - done_message, - Some(inserted_chars), - ); - - { - let mut state = inner.state.lock(); - state.phase = SessionPhase::Idle; - state.focus_target = None; - } - // Toggle 模式冷却:设冷却时间戳,POST_SESSION_COOLDOWN_MS 内禁止新的 activate。 - // 覆盖胶囊离场动画周期,避免三连按第 3 次误激活(issue #545)。 - { - let now = std::time::Instant::now(); - *inner.session_cooldown_until.lock() = - Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS)); - } - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - - Ok(()) -} - #[cfg(test)] mod tests { use super::batch_asr_chunk_limit_ms; diff --git a/openless-all/app/src-tauri/src/coordinator/dictation_end.rs b/openless-all/app/src-tauri/src/coordinator/dictation_end.rs new file mode 100644 index 00000000..453f5e9b --- /dev/null +++ b/openless-all/app/src-tauri/src/coordinator/dictation_end.rs @@ -0,0 +1,785 @@ +use std::sync::atomic::Ordering; +use std::sync::Arc; + +use crate::correction::apply_correction_rules; + +use super::resources::*; +use super::*; + +pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { + let current_session_id = { + let mut state = inner.state.lock(); + let Some(session_id) = start_processing_if_listening(&mut state) else { + return Ok(()); + }; + session_id + }; + + let elapsed = inner.state.lock().started_at.elapsed().as_millis() as u64; + emit_capsule(inner, CapsuleState::Transcribing, 0.0, elapsed, None, None); + + if let Some(rec) = take_recorder_for_session(inner, current_session_id) { + rec.stop(); + release_recording_mute(inner, "dictation"); + } + + let asr_opt = take_asr_for_session(inner, current_session_id); + let asr = match asr_opt { + Some(a) => a, + None => { + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + return Ok(()); + } + }; + + let uses_global_timeout = asr_transcribe_uses_global_timeout(&asr); + let raw = match asr { + ActiveAsr::Volcengine(asr) => { + debug_assert!(uses_global_timeout); + if let Err(e) = asr.send_last_frame().await { + log::error!("[coord] send last frame failed: {e}"); + } + // 添加全局超时保护:防止 await_final_result() 永远挂起 + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, asr.await_final_result()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] await final failed: {e}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(format!("识别失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + Err(_) => { + // 全局超时:最后的防线 + log::error!( + "[coord] 全局超时 {} 秒 - 强制恢复", + COORDINATOR_GLOBAL_TIMEOUT_SECS + ); + // 清理 ASR session,避免资源泄漏 + asr.cancel(); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some("识别超时".to_string()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("global timeout".to_string()); + } + } + } + ActiveAsr::Whisper(w) => { + debug_assert!(uses_global_timeout); + // Whisper 也添加类似的超时保护 + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, w.transcribe()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] whisper transcribe failed: {e}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(format!("识别失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + Err(_) => { + log::error!( + "[coord] whisper 全局超时 {} 秒", + COORDINATOR_GLOBAL_TIMEOUT_SECS + ); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some("识别超时".to_string()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("whisper global timeout".to_string()); + } + } + } + ActiveAsr::Mimo(m) => { + debug_assert!(uses_global_timeout); + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, m.transcribe()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] MiMo ASR transcribe failed: {e}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(format!("识别失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + Err(_) => { + log::error!( + "[coord] MiMo ASR 全局超时 {} 秒", + COORDINATOR_GLOBAL_TIMEOUT_SECS + ); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some("识别超时".to_string()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("mimo global timeout".to_string()); + } + } + } + ActiveAsr::Bailian(asr) => { + debug_assert!(uses_global_timeout); + if let Err(e) = asr.send_last_frame().await { + log::error!("[coord] Bailian send last frame failed: {e}"); + } + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, asr.await_final_result()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] Bailian await final failed: {e}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(format!("识别失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + Err(_) => { + log::error!( + "[coord] Bailian 全局超时 {} 秒", + COORDINATOR_GLOBAL_TIMEOUT_SECS + ); + asr.cancel(); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some("识别超时".to_string()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("bailian global timeout".to_string()); + } + } + } + #[cfg(target_os = "windows")] + ActiveAsr::FoundryLocalWhisper(local) => { + debug_assert!(!uses_global_timeout); + match local + .transcribe(foundry_audio_transcribe_timeout_duration()) + .await + { + Ok(r) => { + schedule_foundry_local_asr_release( + inner, + AsrReleaseSession::Dictation(current_session_id), + ); + r + } + Err(e) => { + if inner.state.lock().cancelled { + log::info!( + "[coord] Foundry Local Whisper transcribe cancelled — discarding transcript" + ); + schedule_foundry_local_asr_release( + inner, + AsrReleaseSession::Dictation(current_session_id), + ); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + log::error!("[coord] Foundry Local Whisper transcribe failed: {e:#}"); + schedule_foundry_local_asr_release( + inner, + AsrReleaseSession::Dictation(current_session_id), + ); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(format!("本地识别失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + } + } + // Windows sherpa-onnx offline batch:停止录音后整段转写,再复用现有 + // polish / insert / history 收尾路径。 + #[cfg(target_os = "windows")] + ActiveAsr::SherpaOnnxLocal(local) => { + debug_assert!(!uses_global_timeout); + match local + .transcribe(sherpa_audio_transcribe_timeout_duration()) + .await + { + Ok(r) => { + schedule_sherpa_onnx_release( + inner, + AsrReleaseSession::Dictation(current_session_id), + ); + r + } + Err(e) => { + if inner.state.lock().cancelled { + log::info!( + "[coord] sherpa-onnx transcribe cancelled — discarding transcript" + ); + schedule_sherpa_onnx_release( + inner, + AsrReleaseSession::Dictation(current_session_id), + ); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + log::error!("[coord] sherpa-onnx transcribe failed: {e:#}"); + schedule_sherpa_onnx_release( + inner, + AsrReleaseSession::Dictation(current_session_id), + ); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(format!("本地识别失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + } + } + #[cfg(target_os = "macos")] + ActiveAsr::Local(local) => { + debug_assert!(uses_global_timeout); + // 缓存命中时 transcribe 不含 load 时间;冷启动 load 已在 build_local_qwen3 + // 提前完成。但 transcribe 本身受音频长度影响:用户实测 RTF ≈ 0.3,慢机 + // 可达 0.5;15s 固定超时在 ≥ 30s 录音上会把整段结果丢掉。改用动态 + // 超时 max(15, ceil(audio_s × 0.6) + 10),公式与单测见 + // `local_qwen_transcribe_timeout`。 + let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0; + let timeout_duration = local_qwen_transcribe_timeout(audio_secs); + log::info!( + "[coord] local Qwen3-ASR transcribe: audio={:.2}s timeout={}s", + audio_secs, + timeout_duration.as_secs() + ); + let result = tokio::time::timeout(timeout_duration, local.transcribe()).await; + inner.local_asr_cache.touch(); + schedule_local_asr_release(inner); + match result { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] local Qwen3-ASR transcribe failed: {e:#}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(format!("本地识别失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + Err(_) => { + log::error!( + "[coord] local Qwen3-ASR 动态超时 {}s(音频 {:.2}s)", + timeout_duration.as_secs(), + audio_secs + ); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some("识别超时".to_string()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("local global timeout".to_string()); + } + } + } + }; + + // ASR 完成后 cancel 检查:用户在 transcribe 进行中按 Esc 时,这里就会命中。 + // 优先级高于 empty 检查 — 用户取消 → 静默丢弃,不写失败历史也不弹错误胶囊。 + if inner.state.lock().cancelled { + log::info!("[coord] cancel detected after ASR — discarding transcript"); + restore_prepared_windows_ime_session(inner, current_session_id); + // PR #387 的「cancel 后清 focus_target」契约要在 Processing 路径上也成立。 + // cancel_session 在 Processing 阶段故意跳过 finish_cancel_session_state(让 + // 这里收尾),但此前的 end_session 没把 focus_target 清掉。logic-review + // 2026-05-10 P3 (🚩) 把这条补完。 + { + let mut state = inner.state.lock(); + state.phase = SessionPhase::Idle; + state.focus_target = None; + } + return Ok(()); + } + + // ASR 返回空转写护栏(来自 PR #66):写一条 emptyTranscript 失败历史 + 错误胶囊, + // 与 main 上其它 error 路径保持一致(带 schedule_capsule_idle 让胶囊自动消失)。 + let mut raw = raw; + + #[cfg(any(debug_assertions, test))] + if raw.text.trim().is_empty() { + if let Some(debug_text) = debug_transcript_override_text() { + log::info!( + "[coord] using debug transcript override (chars={})", + debug_text.chars().count() + ); + raw.text = debug_text; + } + } + + if raw.text.trim().is_empty() { + let session = DictationSession { + id: Uuid::new_v4().to_string(), + created_at: Utc::now().to_rfc3339(), + raw_transcript: raw.text.clone(), + 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("emptyTranscript".to_string()), + duration_ms: Some(raw.duration_ms), + dictionary_entry_count: Some(enabled_phrases(inner).len() as u32), + // empty-transcript(ASR 没识别到任何文字)也保留 wav 标记——这是用户最想 + // 通过原始录音定位"是不是麦克风太小声 / ASR 模型问题"的场景。修 pr_agent + // "Missing Audio" 反馈。 + 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 failed: {e}"); + } + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some("没有识别到语音".to_string()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("ASR returned empty transcript".to_string()); + } + + let correction_rules = match inner.correction_rules.list() { + Ok(rules) => rules, + Err(e) => { + log::warn!("[coord] load correction rules failed: {e}; continue without correction"); + Vec::new() + } + }; + let front_app = inner.state.lock().front_app.clone(); + if !correction_rules.is_empty() { + let corrected = apply_correction_rules(&raw.text, &correction_rules); + if corrected != raw.text { + log::info!( + "[coord] correction rules adjusted raw transcript ({} → {} chars)", + raw.text.chars().count(), + corrected.chars().count() + ); + raw.text = corrected; + } + } + + // Cloud Agent 语音分流:长按升级的会话不走润色/插入,转写交给 Claude 跑任务、结果弹胶囊。 + if inner.state.lock().voice_agent { + return run_voice_agent_transcript(inner, current_session_id, raw.text.clone(), elapsed) + .await; + } + + emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None); + + let prefs = inner.prefs.get(); + let pack = match inner + .style_packs + .get_or_default_active(&prefs.active_style_pack_id) + { + Ok(pack) => pack, + Err(error) => { + log::warn!( + "[coord] active style pack unavailable, falling back to builtin light: {error}" + ); + crate::types::builtin_style_pack_for_mode(PolishMode::Light) + } + }; + let mode = pack.base_mode; + let hotword_strs = enabled_phrases(inner); + let working_languages = prefs.working_languages.clone(); + let chinese_script_preference = prefs.chinese_script_preference; + let output_language_preference = prefs.output_language_preference; + let llm_thinking_enabled = prefs.llm_thinking_enabled; + let style_system_prompt = pack.prompt.clone(); + let raw_uses_llm = mode == PolishMode::Raw && super::raw_style_pack_uses_llm(&pack); + let translation_target = prefs.translation_target_language.trim().to_string(); + let translation_active = + inner.translation_modifier_seen.load(Ordering::SeqCst) && !translation_target.is_empty(); + log::info!( + "[style-pack] runtime dispatch session_id={} active_pack={} kind={:?} mode={:?} raw_chars={} prompt_chars={} raw_uses_llm={} translation_active={} hotwords={} working_languages={:?}", + current_session_id, + pack.id, + pack.kind, + mode, + raw.text.chars().count(), + style_system_prompt.chars().count(), + raw_uses_llm, + translation_active, + hotword_strs.len(), + working_languages + ); + // 对话感知 polish:拉最近 N 分钟的会话作为 LLM 上下文。翻译现在也走"润色+翻译"单次 + // LLM 调用,所以翻译路径同样需要上下文;只有 Raw 且不走 LLM 才没意义。窗口=0 时为空 Vec。 + // 只复用同一 active style pack 的历史;翻译历史按当前是否翻译决定喂译文还是润色后源文 + // (见 eligible_polish_context_turns)。 + let polish_context_window_minutes = prefs.polish_context_window_minutes; + let prior_turns: Vec<(String, String)> = if (translation_active + || mode != PolishMode::Raw + || raw_uses_llm) + && polish_context_window_minutes > 0 + { + match inner + .history + .recent_within_minutes(polish_context_window_minutes) + { + Ok(sessions) => eligible_polish_context_turns(sessions, &pack.id, translation_active), + Err(e) => { + log::warn!("[coord] fetch polish context failed: {e}; fall back to single-turn"); + Vec::new() + } + } + } else { + Vec::new() + }; + // 流式插入 opt-in 路径:开关打开 + 非翻译 + 非 Raw 模式 → 进入流式分支。 + // 任何不满足都走原一次性 polish_or_passthrough 路径,行为跟历史完全一致。 + let streaming_eligible = streaming_insert_eligible( + prefs.streaming_insert, + translation_active, + mode, + raw_uses_llm, + ); + log::info!( + "[coord] polish dispatch: translation={translation_active} mode={mode:?} streaming_eligible={streaming_eligible}" + ); + + // Linux: emit_capsule(Polishing) 已通过 fcitx5 auxDown 显示 "✨ 润色中...", + // 无需在此重复调用。 + + // 翻译会话润色后的源语言文本(译文前的中间产物),仅翻译路径解析成功时有值, + // 写进 history 供后续普通润色轮复用(剔除译文、避免外语污染)。 + let mut polish_source: Option = None; + let (polished, polish_error, already_streamed) = if translation_active { + log::info!( + "[coord] translation mode → target=\u{300C}{}\u{300D} working={:?} front_app={:?}", + translation_target, + working_languages, + front_app + ); + let (p, src, e) = polish_and_translate_or_passthrough( + &raw, + &translation_target, + mode, + &hotword_strs, + &working_languages, + chinese_script_preference, + output_language_preference, + llm_thinking_enabled, + front_app.as_deref(), + &prior_turns, + ) + .await; + polish_source = src; + (p, e, false) + } else if streaming_eligible { + run_streaming_polish( + inner, + &raw, + mode, + &hotword_strs, + &style_system_prompt, + &working_languages, + chinese_script_preference, + output_language_preference, + llm_thinking_enabled, + front_app.as_deref(), + &prior_turns, + ) + .await + } else { + let (p, e) = polish_or_passthrough( + &raw, + mode, + &hotword_strs, + &style_system_prompt, + &working_languages, + chinese_script_preference, + output_language_preference, + llm_thinking_enabled, + front_app.as_deref(), + &prior_turns, + ) + .await; + (p, e, false) + }; + + let polished = finalize_polished_text( + polished, + translation_active, + raw_uses_llm, + mode, + &polish_error, + chinese_script_preference, + &correction_rules, + already_streamed, + ); + // 原子化最后一次 cancel 检查 + 转 Inserting: + // 在同一 lock 内决定「丢弃」还是「进入 Inserting」。一旦设到 Inserting, + // cancel_session 就拒绝介入(Cmd+V 已发出,撤销不掉)。这是 audit HIGH #2 的修复, + // 之前 check 与 inserter.insert 之间有窗口期。 + // + // 流式路径例外:`already_streamed = true` 表示字符已经一边流一边落到光标了, + // 撤销不掉。即使 cancel 旗在中途被立起来,也只能尊重「已经发生」的事实,进入 + // Inserting 状态完成 history / vocab 等收尾工作。 + let proceed_to_insert = { + let mut state = inner.state.lock(); + if state.cancelled && !already_streamed { + state.phase = SessionPhase::Idle; + false + } else { + state.phase = SessionPhase::Inserting; + true + } + }; + if !proceed_to_insert { + log::info!( + "[coord] cancel detected before insert — discarding output (chars={})", + polished.chars().count() + ); + restore_prepared_windows_ime_session(inner, current_session_id); + return Ok(()); + } + + let focus_target = inner.state.lock().focus_target; + let focus_ready_for_paste = restore_focus_target_if_possible(focus_target); + let prefs = inner.prefs.get(); + let restore_clipboard = prefs.restore_clipboard_after_paste; + let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback; + let paste_shortcut = prefs.paste_shortcut; + // 流式路径下,字符已经通过 Unicode keystroke 落到光标处,跳过 inserter.insert。 + let status = if already_streamed { + log::info!( + "[coord] insertion skipped: {} chars already streamed via unicode_keystroke (polish_error={:?})", + polished.chars().count(), + polish_error + ); + InsertStatus::Inserted + } else if focus_ready_for_paste { + #[cfg(target_os = "windows")] + { + let ime_target = capture_ime_submit_target(); + insert_with_windows_ime_first( + inner, + current_session_id, + &polished, + restore_clipboard, + allow_non_tsf_insertion_fallback, + paste_shortcut, + ime_target, + ) + .await + } + #[cfg(not(target_os = "windows"))] + { + inner + .inserter + .insert(&polished, restore_clipboard, paste_shortcut) + } + } else { + #[cfg(target_os = "linux")] + { + // Linux: fcitx5 commitString 无需窗口焦点,始终尝试插入。 + inner + .inserter + .insert(&polished, restore_clipboard, paste_shortcut) + } + #[cfg(not(target_os = "linux"))] + { + log::warn!( + "[coord] original insertion target is not foreground; copied output without paste" + ); + if allow_non_tsf_insertion_fallback { + inner.inserter.copy_fallback(&polished) + } else { + InsertStatus::Failed + } + } + }; + restore_prepared_windows_ime_session(inner, current_session_id); + let inserted_chars = polished.chars().count() as u32; + + // 累计每条 enabled 词条在最终文本中的命中次数。 + // 用 polished(最终插入的文本)扫描,与用户实际看到的输出一致。 + let total_hits: u64 = match inner.vocab.record_hits(&polished) { + Ok(n) => n, + Err(e) => { + log::error!("[coord] record_hits failed: {e}"); + 0 + } + }; + // 词汇本页面在打开时通常需要立即看到 hits 增长,否则用户得手动切走再切回来才刷新。 + // 命中数 > 0 时通知前端:Vocab 页面订阅 vocab:updated 即时 listVocab() 重新加载。 + if total_hits > 0 { + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit("vocab:updated", total_hits); + } + } + + // polish 失败时在 history 里标记 polishFailed,让用户能在历史详情看到为什么这次输出 + // 不是预期的 mode 风格。即使失败也不丢词 — final_text 仍是原文(保留"用户的话不丢"语义)。 + let error_code = dictation_error_code( + status, + polish_error.is_some(), + focus_ready_for_paste, + allow_non_tsf_insertion_fallback, + ) + .map(str::to_string); + let tsf_required_insert_failed = error_code.as_deref() == Some("windowsImeTsfRequired"); + + // 与 coordinator 内部 SessionId 对齐:方便 recorder 旁路写盘的 `.wav` + // 跟 history 这条 DictationSession.id 同名,前端凭 id 就能找到对应录音文件。 + let history_session_id = current_session_id.to_string(); + let history_created_at = Utc::now().to_rfc3339(); + let prefs_snapshot = inner.prefs.get(); + let session = DictationSession { + id: history_session_id.clone(), + created_at: history_created_at.clone(), + raw_transcript: raw.text.clone(), + final_text: polished.clone(), + mode, + style_pack_id: Some(pack.id.clone()), + translation_active, + polish_source, + app_bundle_id: None, + app_name: None, + insert_status: status, + error_code, + duration_ms: Some(raw.duration_ms), + // 历史详情页的"X 个热词"显示:用本次实际命中次数(每个匹配实例算一次), + // 比"启用词条总数"更能反映本段口述命中了多少。u64 → u32 截断对单段听写足够。 + dictionary_entry_count: Some(total_hits.min(u32::MAX as u64) as u32), + // 用 begin_session 时 Recorder::start 返回的实际写盘状态,而不是 prefs 开关—— + // 开关打开但路径创建失败时这里是 false,避免前端渲染播放按钮后端 404。 + has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)), + }; + 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 failed: {e}"); + } + let done_message = if tsf_required_insert_failed { + Some("TSF 未上屏,已禁止非 TSF 兜底".to_string()) + } else { + default_done_message(status, polish_error.is_some()) + }; + + emit_capsule( + inner, + CapsuleState::Done, + 0.0, + elapsed, + done_message, + Some(inserted_chars), + ); + + { + let mut state = inner.state.lock(); + state.phase = SessionPhase::Idle; + state.focus_target = None; + } + // Toggle 模式冷却:设冷却时间戳,POST_SESSION_COOLDOWN_MS 内禁止新的 activate。 + // 覆盖胶囊离场动画周期,避免三连按第 3 次误激活(issue #545)。 + { + let now = std::time::Instant::now(); + *inner.session_cooldown_until.lock() = + Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS)); + } + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + + Ok(()) +}