#![cfg_attr(target_os = "linux", allow(dead_code, unused_imports, unused_variables))] //! Dictation coordinator. //! //! Mirrors the Swift `DictationCoordinator` state machine. Single owner of //! session state. Receives hotkey edges, drives recorder + ASR + polish + //! insertion, persists history, emits `capsule:state` events to the capsule //! window. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::sync::Arc; use std::time::Instant; use chrono::Utc; use ferrous_opencc::{config::BuiltinConfig, OpenCC}; use parking_lot::Mutex; use tauri::{async_runtime, AppHandle, Emitter, Manager}; use uuid::Uuid; #[cfg(target_os = "windows")] use crate::asr::local::{ foundry, sherpa, FoundryLocalRuntime, FoundryLocalWhisperAsr, SherpaOnnxAsr, SherpaOnnxRuntime, }; use crate::asr::{ BailianCredentials, BailianRealtimeASR, DictionaryHotword, RawTranscript, VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR, }; use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent, ComboHotkeyMonitor}; use crate::coordinator_state::{ begin_cancel_session_state, begin_recording_abort_before_restore, begin_session_state, finish_cancel_session_state, finish_starting_session_state, new_session_id, publish_abort_idle_after_restore, start_processing_if_listening, startup_race_status, BeginOutcome, SessionId, SessionPhase, SessionState, StartupRaceStatus, }; use crate::hotkey::{HotkeyEvent, HotkeyMonitor}; use crate::insertion::TextInserter; use crate::persistence::{ sync_style_pack_preferences, CorrectionRuleStore, CredentialAccount, CredentialsVault, DictionaryStore, HistoryStore, PreferencesStore, StylePackStore, }; use crate::llm_gemini::{GeminiConfig, GeminiProvider}; use crate::polish::{ ActiveLLMProvider, CodexOAuthConfig, CodexOAuthLLMProvider, OpenAICompatibleConfig, OpenAICompatibleLLMProvider, CODEX_DEFAULT_MODEL, CODEX_OAUTH_PROVIDER_ID, }; use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor}; use crate::recorder::{Recorder, RecorderError}; use crate::selection::capture_selection; #[cfg(target_os = "windows")] use crate::types::PasteShortcut; use crate::types::{ CapsulePayload, CapsuleState, ChineseScriptPreference, DictationSession, HotkeyCapability, HotkeyStatus, HotkeyStatusState, InsertStatus, OutputLanguagePreference, PolishMode, }; #[cfg(target_os = "windows")] use crate::windows_ime_ipc::ImeSubmitTarget; #[cfg(target_os = "windows")] use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionController}; mod dictation; mod qa; mod resources; #[cfg(test)] use dictation::dictation_error_code; use dictation::{ begin_session, cancel_session, end_session, handle_pressed, handle_pressed_edge, handle_released, handle_released_edge, request_stop_during_starting, }; use qa::{close_qa_panel, handle_qa_hotkey_pressed, QaPhase, QaSessionState}; #[cfg(test)] use resources::discard_startup_resources_for_session; use resources::{ acquire_recording_mute, cancel_active_asr, release_recording_mute, selected_microphone_device_name, stop_microphone_preview_monitor, stop_qa_recorder, SessionResource, SharedRecordingMuteState, }; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum CapsuleShowStrategy { NoActivate, FallbackShow, } fn capsule_show_strategy_for_platform() -> CapsuleShowStrategy { // ⚠️ 如果改下面的 cfg 列表,**必须**同步更新单元测试 // `capsule_show_strategy_matches_platform_activation_contract` 的两组 cfg — // 否则 Linux CI 直接红(PR #451 即是这种漏改)。 #[cfg(any(target_os = "macos", target_os = "windows"))] { CapsuleShowStrategy::NoActivate } #[cfg(not(any(target_os = "macos", target_os = "windows")))] { CapsuleShowStrategy::FallbackShow } } static CAPSULE_NO_ACTIVATE_FALLBACK_WARNED: AtomicBool = AtomicBool::new(false); static CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED: AtomicBool = AtomicBool::new(false); static CAPSULE_FIRST_SHOW_LOGGED: AtomicBool = AtomicBool::new(false); /// 给 #470 诊断日志用的 capsule 状态短名。显式枚举每个变体到 &'static str, /// 不走 `Debug` —— 哪天 CapsuleState 加了 `String` 字段,`:?` 会把 ASR / polish /// 内容意外灌进日志(pr_agent 提的 forward-looking 隐患);这里只输出状态名。 fn capsule_state_log_name(state: CapsuleState) -> &'static str { match state { CapsuleState::Idle => "idle", CapsuleState::Recording => "recording", CapsuleState::Transcribing => "transcribing", CapsuleState::Polishing => "polishing", CapsuleState::Done => "done", CapsuleState::Cancelled => "cancelled", CapsuleState::Error => "error", } } fn show_capsule_window_for_recording( app: &AppHandle, window: &tauri::WebviewWindow, ) { let mut needs_fallback = true; if capsule_show_strategy_for_platform() == CapsuleShowStrategy::NoActivate { needs_fallback = !show_capsule_window_no_activate(app, window); if needs_fallback && !CAPSULE_NO_ACTIVATE_FALLBACK_WARNED.swap(true, Ordering::SeqCst) { // 产品取舍:no-activate 是 macOS/AeroSpace 的主路径;但如果 ns_window // 暂不可用,仍优先保住录音反馈,不让用户以为听写没启动。fallback 可能 // 重新触发 workspace 跳转,只在 no-activate 失败时作为降级路径。 log::warn!("[capsule] no-activate show failed; falling back to window.show()"); } } if needs_fallback { if let Err(e) = window.show() { log::warn!("[capsule] show fallback failed: {e}"); } } } #[derive(Clone)] enum ActiveAsr { Volcengine(Arc), Whisper(Arc), Bailian(Arc), #[cfg(target_os = "windows")] FoundryLocalWhisper(Arc), /// Windows sherpa-onnx 本地 ASR(offline batch + 实验 online streaming)。 #[cfg(target_os = "windows")] SherpaOnnxLocal(Arc), /// 本地 Qwen3-ASR;只在 macOS + 模型已下载时可达。 #[cfg(target_os = "macos")] Local(Arc), } fn asr_transcribe_uses_global_timeout(asr: &ActiveAsr) -> bool { match asr { #[cfg(target_os = "windows")] ActiveAsr::FoundryLocalWhisper(_) => false, // sherpa-onnx 首次加载 / 下载 / 推理的耗时类似 Foundry,不走 // COORDINATOR_GLOBAL_TIMEOUT;各 provider 自己里面控制細粒度超时。 #[cfg(target_os = "windows")] ActiveAsr::SherpaOnnxLocal(_) => false, _ => true, } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ActiveAsrProviderKind { Bailian, WhisperCompatible, Volcengine, } fn active_asr_provider_kind(id: &str) -> ActiveAsrProviderKind { if is_bailian_provider(id) { ActiveAsrProviderKind::Bailian } else if is_whisper_compatible_provider(id) { ActiveAsrProviderKind::WhisperCompatible } else { ActiveAsrProviderKind::Volcengine } } fn batch_asr_chunk_limit_ms(provider_id: &str) -> Option { match provider_id { "zhipu" => Some(30_000), _ => None, } } pub struct Coordinator { inner: Arc, } struct Inner { app: Mutex>, history: HistoryStore, prefs: PreferencesStore, style_packs: StylePackStore, vocab: DictionaryStore, correction_rules: CorrectionRuleStore, inserter: TextInserter, #[cfg(target_os = "windows")] windows_ime: WindowsImeSessionController, #[cfg(target_os = "windows")] prepared_windows_ime_session: Arc>>, state: Mutex, asr: Mutex>>, /// 本地 Qwen3-ASR 引擎缓存。跨会话复用,避免每次重加载 1.2GB+ 模型。 /// 释放时机由 prefs.local_asr_keep_loaded_secs 决定。 local_asr_cache: Arc, #[cfg(target_os = "windows")] foundry_local_runtime: Arc, /// Windows sherpa-onnx 本地 ASR runtime。与 Foundry 同处一个 /// 位置、同一 lifecycle 语义;上层通过 `ActiveAsr::SherpaOnnxLocal` 后只调 /// runtime,不会跨模块调。 #[cfg(target_os = "windows")] sherpa_onnx_runtime: Arc, recorder: Mutex>>, /// 当前 dictation / QA session 的 wav 归档是否真的被写到磁盘上。 /// 由 Recorder::start 返回值 (archive_active) 写入;history.append 路径读取, /// 决定 DictationSession.has_audio_recording 字段。比单纯读 prefs.record_audio_for_debug /// 更准确:用户开了开关但路径无法创建(权限 / 磁盘满)也算 false。 audio_archive_active: AtomicBool, recording_mute: Mutex, hotkey: Mutex>, hotkey_status: Mutex, hotkey_trigger_held: AtomicBool, /// 防抖时间戳:handle_pressed_edge 入口检查与本字段的距离,< 250ms 的边沿直接 /// 丢弃(误触双击 / 微动开关回弹 / 用户连点过快造成的空转写报错)。 /// 与 `hotkey_trigger_held` 互补 —— held 防 press-without-release,本字段防 /// press-release-press 三连过快。 last_hotkey_dispatch_at: Mutex>, /// end_session 成功收尾后将 phase 设为 Idle 时记录的时间戳 + POST_SESSION_COOLDOWN_MS。 /// handle_pressed 在 (Toggle, Idle) 分支检查此字段:未过期则忽略该次按键, /// 防止胶囊离场动画期间误激活新听写(issue #545)。 session_cooldown_until: Mutex>, shortcut_recording_active: AtomicBool, /// 自定义组合键监听器(global-hotkey crate)。当 `prefs.hotkey.trigger == Custom` 时 /// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。 combo_hotkey: Mutex>, translation_hotkey: Mutex>, switch_style_hotkey: Mutex>, open_app_hotkey: Mutex>, /// 翻译模式触发标志。每次 begin_session 重置为 false;hotkey 监听器在 /// Listening / Starting 阶段看到 Shift down 边沿时 set true。 /// end_session 在调 polish/translate 前读这个 flag + translation_target_language /// 决定走哪条管线。详见 issue #4。 translation_modifier_seen: AtomicBool, /// 划词语音问答(issue #118):与 dictation hotkey 平行的全局快捷键 /// 监听器(global-hotkey crate)。`None` 表示功能关闭或还没成功安装。 qa_hotkey: Mutex>, /// QA 单独的 session 状态,与 dictation 的 SessionPhase 不冲突。 qa_state: Mutex, /// 最近一次应用到 capsule 窗口的几何状态。避免录音 level tick 反复触发 /// resize / reposition。 capsule_layout: Mutex>, /// QA 用的 ASR 句柄。必须跟 active_asr_provider 保持一致,避免浮窗走不同入口。 qa_asr: Mutex>, /// QA 用的 Recorder 句柄。 qa_recorder: Mutex>, /// QA SSE 流取消标志。begin_qa_session 重置为 false;cancel_qa_session 设 true; /// polish::chat_completion_history_streaming 的 loop 每帧检查,true 时 break loop /// 避免取消后 LLM 仍 drain HTTP body 烧 token。详见 issue #161。 qa_stream_cancelled: Arc, /// Coordinator 退出信号。各 hotkey supervisor loop 在每轮重试 sleep 之前会检查 /// 此 flag;为 true 时 loop 立刻 return。生产场景里 process exit 一并 reap 所有 /// supervisor 线程,但 integration test 和未来 RunEvent::Exit 钩子需要这条 /// 显式退出路径。审计 3.1.2。 shutdown: AtomicBool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ActionHotkeyKind { SwitchStyle, OpenApp, } #[cfg(target_os = "windows")] #[derive(Debug)] struct PreparedWindowsImeSessionSlot { session_id: SessionId, prepared: PreparedWindowsImeSession, } impl Coordinator { pub fn new() -> Self { #[cfg(target_os = "windows")] { Self::new_with_local_runtimes( Arc::new(FoundryLocalRuntime::new()), Arc::new(SherpaOnnxRuntime::new()), ) } #[cfg(not(target_os = "windows"))] { let history = HistoryStore::new().unwrap_or_else(|e| { log::error!("[coord] HistoryStore init failed: {e}; falling back to empty"); HistoryStore::new().expect("history store init") }); let prefs = PreferencesStore::new().expect("preferences store init"); let style_packs = StylePackStore::new(&prefs).expect("style pack store init"); let vocab = DictionaryStore::new().expect("dictionary store init"); let correction_rules = CorrectionRuleStore::new().expect("correction rule store init"); Self { inner: Arc::new(Inner { app: Mutex::new(None), history, prefs, style_packs, vocab, correction_rules, inserter: TextInserter::new(), state: Mutex::new(SessionState::default()), asr: Mutex::new(None), recorder: Mutex::new(None), audio_archive_active: AtomicBool::new(false), recording_mute: Mutex::new(SharedRecordingMuteState::new()), hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), hotkey_trigger_held: AtomicBool::new(false), last_hotkey_dispatch_at: Mutex::new(None), session_cooldown_until: Mutex::new(None), shortcut_recording_active: AtomicBool::new(false), combo_hotkey: Mutex::new(None), translation_hotkey: Mutex::new(None), switch_style_hotkey: Mutex::new(None), open_app_hotkey: Mutex::new(None), translation_modifier_seen: AtomicBool::new(false), qa_hotkey: Mutex::new(None), qa_state: Mutex::new(QaSessionState::default()), capsule_layout: Mutex::new(None), qa_asr: Mutex::new(None), qa_recorder: Mutex::new(None), qa_stream_cancelled: Arc::new(AtomicBool::new(false)), local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()), shutdown: AtomicBool::new(false), }), } } } /// 保留旧构造函数:现有调用点(含单元测试)只传 Foundry runtime。 /// sherpa-onnx runtime 这里创建默认 offline batch 实例;入产后(lib.rs)请走 /// `new_with_local_runtimes`,确保 Tauri State 共享同一个 Arc。 #[cfg(target_os = "windows")] pub fn new_with_foundry_runtime(foundry_local_runtime: Arc) -> Self { Self::new_with_local_runtimes(foundry_local_runtime, Arc::new(SherpaOnnxRuntime::new())) } #[cfg(target_os = "windows")] pub fn new_with_local_runtimes( foundry_local_runtime: Arc, sherpa_onnx_runtime: Arc, ) -> Self { let history = HistoryStore::new().unwrap_or_else(|e| { log::error!("[coord] HistoryStore init failed: {e}; falling back to empty"); HistoryStore::new().expect("history store init") }); let prefs = PreferencesStore::new().expect("preferences store init"); let style_packs = StylePackStore::new(&prefs).expect("style pack store init"); let vocab = DictionaryStore::new().expect("dictionary store init"); let correction_rules = CorrectionRuleStore::new().expect("correction rule store init"); Self { inner: Arc::new(Inner { app: Mutex::new(None), history, prefs, style_packs, vocab, correction_rules, inserter: TextInserter::new(), windows_ime: WindowsImeSessionController::new(), prepared_windows_ime_session: Arc::new(Mutex::new(Vec::new())), state: Mutex::new(SessionState::default()), asr: Mutex::new(None), recorder: Mutex::new(None), audio_archive_active: AtomicBool::new(false), recording_mute: Mutex::new(SharedRecordingMuteState::new()), hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), hotkey_trigger_held: AtomicBool::new(false), last_hotkey_dispatch_at: Mutex::new(None), session_cooldown_until: Mutex::new(None), shortcut_recording_active: AtomicBool::new(false), combo_hotkey: Mutex::new(None), translation_hotkey: Mutex::new(None), switch_style_hotkey: Mutex::new(None), open_app_hotkey: Mutex::new(None), translation_modifier_seen: AtomicBool::new(false), qa_hotkey: Mutex::new(None), qa_state: Mutex::new(QaSessionState::default()), capsule_layout: Mutex::new(None), qa_asr: Mutex::new(None), qa_recorder: Mutex::new(None), qa_stream_cancelled: Arc::new(AtomicBool::new(false)), local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()), foundry_local_runtime, sherpa_onnx_runtime, shutdown: AtomicBool::new(false), }), } } /// 后台预加载本地 ASR 引擎;当用户在 UI 切到 local-qwen3 provider 时调一次。 /// 加载是阻塞且数秒,所以放 spawn_blocking 里,不影响 UI 响应。 /// 模型未下载或不在 macOS 上时静默跳过。 pub fn preload_local_asr_in_background(self: &Arc) { #[cfg(target_os = "macos")] { let inner = Arc::clone(&self.inner); tauri::async_runtime::spawn(async move { let prefs = inner.prefs.get(); let model_id = match crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) { Some(m) => m, None => return, }; if !crate::asr::local::models::is_downloaded(model_id) { log::info!( "[coord] local ASR preload skipped: model {} not downloaded", model_id.as_str() ); return; } let dir = match crate::asr::local::models::model_dir(model_id) { Ok(d) => d, Err(_) => return, }; let cache = Arc::clone(&inner.local_asr_cache); let mid = model_id.as_str().to_string(); let _ = tauri::async_runtime::spawn_blocking(move || { if let Err(e) = cache.get_or_load(&mid, &dir) { log::warn!("[coord] local ASR preload failed: {e:#}"); } }) .await; }); } #[cfg(not(target_os = "macos"))] { // no-op } } /// 释放当前缓存的本地 ASR 引擎(用户主动点 / 或 删除模型时调)。 pub fn release_local_asr_engine(&self) { self.inner.local_asr_cache.release_now(); } pub fn local_asr_loaded_model(&self) -> Option { self.inner.local_asr_cache.loaded_model_id() } pub fn bind_app(&self, handle: AppHandle) { *self.inner.app.lock() = Some(handle); } /// 让所有 hotkey supervisor loop(dictation / qa / combo / translation / /// switch_style / open_app)在下一轮 sleep / poll 后退出。生产场景下进程退出 /// 一并 reap 所有线程,但 integration test 和未来 RunEvent::Exit 钩子需要 /// 显式退出路径。审计 3.1.2。 #[allow(dead_code)] pub fn request_shutdown(&self) { self.inner.shutdown.store(true, Ordering::SeqCst); } pub fn start_hotkey_listener(&self) { // 起一个守护线程,反复尝试安装 hotkey hook。Accessibility 一被授予就立即生效, // 用户不需要手动重启 OpenLess。 let inner = Arc::clone(&self.inner); std::thread::Builder::new() .name("openless-hotkey-supervisor".into()) .spawn(move || hotkey_supervisor_loop(inner)) .ok(); } pub fn stop_hotkey_listener(&self) { self.inner.hotkey.lock().take(); } /// 启动 QA hotkey supervisor(issue #118)。和 `start_hotkey_listener` 平行: /// 守护线程反复尝试注册(用户可能改了组合键),失败则 3s 后重试。 pub fn start_qa_hotkey_listener(&self) { let inner = Arc::clone(&self.inner); std::thread::Builder::new() .name("openless-qa-hotkey-supervisor".into()) .spawn(move || qa_hotkey_supervisor_loop(inner)) .ok(); } pub fn stop_qa_hotkey_listener(&self) { // QaHotkeyMonitor::drop 在 macOS 底层是 Carbon RemoveEventHotKey,要求主线程。 // RunEvent::Exit 回调不保证在 AppKit 主线程跑,drop 漏到 tokio worker 上会 // 触发 macOS dispatch_assert_queue_fail SIGTRAP。包到 run_on_main_thread 让 // drop 在主线程发生;AppHandle 已 None 时直接 drop(最坏 crash 也是退出时刻)。 // 详见 issue #169。 let app = self.inner.app.lock().clone(); if let Some(app) = app { let inner = Arc::clone(&self.inner); let _ = app.run_on_main_thread(move || { inner.qa_hotkey.lock().take(); }); } else { self.inner.qa_hotkey.lock().take(); } } /// 启动自定义组合键监听器。当 `prefs.hotkey.trigger == Custom` 时, /// 代替 modifier-only 的 hotkey monitor。 pub fn start_combo_hotkey_listener(&self) { let inner = Arc::clone(&self.inner); std::thread::Builder::new() .name("openless-combo-hotkey-supervisor".into()) .spawn(move || combo_hotkey_supervisor_loop(inner)) .ok(); } pub fn stop_combo_hotkey_listener(&self) { take_combo_hotkey_on_main_thread(&self.inner); } pub fn start_translation_hotkey_listener(&self) { let inner = Arc::clone(&self.inner); std::thread::Builder::new() .name("openless-translation-hotkey-supervisor".into()) .spawn(move || translation_hotkey_supervisor_loop(inner)) .ok(); } pub fn stop_translation_hotkey_listener(&self) { take_translation_hotkey_on_main_thread(&self.inner); } pub fn start_switch_style_hotkey_listener(&self) { let inner = Arc::clone(&self.inner); std::thread::Builder::new() .name("openless-switch-style-hotkey-supervisor".into()) .spawn(move || action_hotkey_supervisor_loop(inner, ActionHotkeyKind::SwitchStyle)) .ok(); } pub fn stop_switch_style_hotkey_listener(&self) { take_action_hotkey_on_main_thread(&self.inner, ActionHotkeyKind::SwitchStyle); } pub fn start_open_app_hotkey_listener(&self) { let inner = Arc::clone(&self.inner); std::thread::Builder::new() .name("openless-open-app-hotkey-supervisor".into()) .spawn(move || action_hotkey_supervisor_loop(inner, ActionHotkeyKind::OpenApp)) .ok(); } pub fn stop_open_app_hotkey_listener(&self) { take_action_hotkey_on_main_thread(&self.inner, ActionHotkeyKind::OpenApp); } /// 用户在设置里改了自定义组合键时调用。 pub fn update_combo_hotkey_binding(&self) { let prefs = self.inner.prefs.get(); if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() { // 修饰键单键由 HotkeyMonitor 处理,组合键 monitor 要释放。 take_combo_hotkey_on_main_thread(&self.inner); log::info!("[coord] combo hotkey 已关闭(modifier-only)"); return; } let binding = prefs.dictation_hotkey.clone(); if is_unconfigured_shortcut(&binding) { // Custom 但没录到有效主键:清掉旧 monitor,避免旧快捷键继续生效。 take_combo_hotkey_on_main_thread(&self.inner); log::info!("[coord] combo hotkey 已关闭(无绑定)"); return; }; let app = self.inner.app.lock().clone(); let Some(app) = app else { log::warn!("[coord] update combo hotkey binding: AppHandle 未 bind,跳过"); return; }; let inner_clone = Arc::clone(&self.inner); let binding_for_main = binding.clone(); let _ = app.run_on_main_thread(move || { if let Some(monitor) = inner_clone.combo_hotkey.lock().as_ref() { if let Err(e) = monitor.update_binding(binding_for_main.clone()) { log::warn!("[coord] update combo hotkey binding 失败: {e}"); } return; } let (tx, rx) = mpsc::channel::(); match ComboHotkeyMonitor::start(binding_for_main, tx) { Ok(monitor) => { *inner_clone.combo_hotkey.lock() = Some(monitor); log::info!( "[coord] combo hotkey listener installed on main thread (via update)" ); let bridge_inner = Arc::clone(&inner_clone); std::thread::Builder::new() .name("openless-combo-hotkey-bridge".into()) .spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx)) .ok(); #[cfg(target_os = "linux")] sync_custom_dictation_to_plugin(&inner_clone); } Err(e) => { log::warn!("[coord] update combo hotkey binding 失败: {e}"); } } }); } /// 用户在设置里改了 QA 组合键时调用。先持久化(由 prefs.set 完成), /// 然后通知活着的 monitor 重新注册;monitor 不存在时 supervisor 会自然 /// 在下一次循环里读到新的 prefs。 pub fn update_qa_hotkey_binding(&self) { let prefs = self.inner.prefs.get(); let Some(binding) = prefs.qa_hotkey.clone() else { // 用户把功能关了 → 直接 drop monitor。drop 也得在主线程,否则 Carbon // unregister 会失败/UB。 let app = self.inner.app.lock().clone(); if let Some(app) = app { let inner_clone = Arc::clone(&self.inner); let _ = app.run_on_main_thread(move || { inner_clone.qa_hotkey.lock().take(); }); } else { self.inner.qa_hotkey.lock().take(); } log::info!("[coord] QA hotkey 已关闭"); self.update_modifier_shortcut_bindings(); return; }; if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() { let app = self.inner.app.lock().clone(); if let Some(app) = app { let inner_clone = Arc::clone(&self.inner); let _ = app.run_on_main_thread(move || { inner_clone.qa_hotkey.lock().take(); }); } else { self.inner.qa_hotkey.lock().take(); } self.update_modifier_shortcut_bindings(); log::info!("[coord] QA hotkey uses modifier-only listener"); return; } self.update_modifier_shortcut_bindings(); // global-hotkey crate 的 manager.register/unregister 必须主线程跑。 // 没在主线程会让 Carbon 句柄注册看似成功但事件不派发。 let app = self.inner.app.lock().clone(); let Some(app) = app else { log::warn!("[coord] update QA hotkey binding: AppHandle 未 bind,跳过"); return; }; let inner_clone = Arc::clone(&self.inner); let binding_for_main = binding.clone(); let _ = app.run_on_main_thread(move || { // 路径 1:当前已有 monitor → 在主线程换绑定。 if let Some(monitor) = inner_clone.qa_hotkey.lock().as_ref() { if let Err(e) = monitor.update_binding(binding_for_main.clone()) { log::warn!("[coord] update QA hotkey binding 失败: {e}"); } return; } // 路径 2:之前还没装上 → 主线程上重装一次(supervisor 也会重试, // 但用户体感更快:set_qa_hotkey 命令一返回,hotkey 立即生效)。 let (tx, rx) = mpsc::channel::(); match QaHotkeyMonitor::start(binding_for_main, tx) { Ok(monitor) => { *inner_clone.qa_hotkey.lock() = Some(monitor); log::info!("[coord] QA hotkey listener installed on main thread (via update)"); let bridge_inner = Arc::clone(&inner_clone); std::thread::Builder::new() .name("openless-qa-hotkey-bridge".into()) .spawn(move || qa_hotkey_bridge_loop(bridge_inner, rx)) .ok(); } Err(e) => { log::warn!("[coord] update QA hotkey binding 失败: {e}"); } } }); } pub fn update_translation_hotkey_binding(&self) { if let Err(e) = self.try_update_translation_hotkey_binding() { log::warn!("[coord] update translation hotkey binding 失败: {e}"); } } pub fn try_update_translation_hotkey_binding(&self) -> Result<(), String> { let prefs = self.inner.prefs.get(); if is_builtin_translation_shift(&prefs.translation_hotkey) || crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey).is_some() { take_translation_hotkey_on_main_thread(&self.inner); self.update_modifier_shortcut_bindings(); log::info!("[coord] translation hotkey uses modifier-only listener"); return Ok(()); } self.update_modifier_shortcut_bindings(); let app = self.inner.app.lock().clone(); let Some(app) = app else { return Err("AppHandle 未 bind,无法注册翻译快捷键".into()); }; let inner_clone = Arc::clone(&self.inner); let binding_for_main = prefs.translation_hotkey.clone(); let (result_tx, result_rx) = mpsc::sync_channel::>(1); let _ = app.run_on_main_thread(move || { let result = update_translation_hotkey_on_main_thread(inner_clone, binding_for_main); let _ = result_tx.send(result.map_err(|e| e.to_string())); }); match result_rx.recv_timeout(std::time::Duration::from_secs(5)) { Ok(result) => result, Err(_) => Err("注册翻译快捷键超时".into()), } } pub fn update_switch_style_hotkey_binding(&self) { self.update_action_hotkey_binding(ActionHotkeyKind::SwitchStyle); } pub fn update_open_app_hotkey_binding(&self) { self.update_action_hotkey_binding(ActionHotkeyKind::OpenApp); } fn update_action_hotkey_binding(&self, kind: ActionHotkeyKind) { let binding = action_hotkey_binding(&self.inner, kind); if is_modifier_only_shortcut(&binding) { take_action_hotkey_on_main_thread(&self.inner, kind); log::warn!("[coord] action hotkey {kind:?} 使用了不支持的 modifier-only 绑定,已关闭"); return; } let app = self.inner.app.lock().clone(); let Some(app) = app else { log::warn!("[coord] update action hotkey binding: AppHandle 未 bind,跳过"); return; }; let inner_clone = Arc::clone(&self.inner); let _ = app.run_on_main_thread(move || { if let Some(monitor) = action_hotkey_slot(&inner_clone, kind).lock().as_ref() { if let Err(e) = monitor.update_binding(binding.clone()) { log::warn!("[coord] update action hotkey {kind:?} binding 失败: {e}"); } return; } let (tx, rx) = mpsc::channel::(); match ComboHotkeyMonitor::start(binding, tx) { Ok(monitor) => { *action_hotkey_slot(&inner_clone, kind).lock() = Some(monitor); let bridge_inner = Arc::clone(&inner_clone); std::thread::Builder::new() .name(action_hotkey_bridge_thread_name(kind).into()) .spawn(move || action_hotkey_bridge_loop(bridge_inner, rx, kind)) .ok(); } Err(e) => log::warn!("[coord] update action hotkey {kind:?} binding 失败: {e}"), } }); } /// 给前端 Settings 渲染当前 QA 快捷键 label(如 "Cmd+Shift+;")。 /// `qa_hotkey == None` 时返回空串,UI 据此显示「未启用」。 pub fn qa_hotkey_label(&self) -> String { self.inner .prefs .get() .qa_hotkey .as_ref() .map(|b| b.display_label()) .unwrap_or_default() } /// 用户点 ✕ / 按 Esc 关 QA 浮窗时调。等价于:取消任何进行中的录音 + /// 清空多轮对话历史 + 隐藏窗口。详见 issue #118 v2。 pub fn qa_window_dismiss(&self) { close_qa_panel(&self.inner); } /// 用户点 📌 切换 pinned 状态。pinned=true 时浮窗不自动隐藏。 pub fn qa_window_pin(&self, pinned: bool) { self.inner.qa_state.lock().pinned = pinned; log::info!("[coord] QA window pinned={pinned}"); } pub fn history(&self) -> &HistoryStore { &self.inner.history } pub fn prefs(&self) -> &PreferencesStore { &self.inner.prefs } pub fn sync_active_asr_provider_from_preferences(&self) -> Result<(), String> { let provider = self.inner.prefs.get().active_asr_provider; self.sync_active_asr_provider_to_vault(&provider) } pub fn sync_active_asr_provider_to_vault(&self, provider: &str) -> Result<(), String> { if CredentialsVault::get_active_asr() == provider { return Ok(()); } CredentialsVault::set_active_asr_provider(provider).map_err(|e| e.to_string()) } pub fn style_packs(&self) -> &StylePackStore { &self.inner.style_packs } pub fn vocab(&self) -> &DictionaryStore { &self.inner.vocab } pub fn correction_rules(&self) -> &CorrectionRuleStore { &self.inner.correction_rules } pub fn update_hotkey_binding(&self) { let prefs = self.inner.prefs.get(); let dictation_trigger = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey); let binding = crate::types::HotkeyBinding { trigger: dictation_trigger.unwrap_or(crate::types::HotkeyTrigger::Custom), mode: prefs.hotkey.mode, keys: None, }; if dictation_trigger.is_some() { take_combo_hotkey_on_main_thread(&self.inner); } else { self.update_combo_hotkey_binding(); } self.ensure_modifier_hotkey_monitor(binding); self.update_modifier_shortcut_bindings(); } fn ensure_modifier_hotkey_monitor(&self, binding: crate::types::HotkeyBinding) { if let Some(monitor) = self.inner.hotkey.lock().as_ref() { #[cfg(target_os = "linux")] let plugin_binding = binding.clone(); monitor.update_binding(binding); #[cfg(target_os = "linux")] if plugin_binding.trigger == crate::types::HotkeyTrigger::Custom { sync_custom_dictation_to_plugin(&self.inner); } else { crate::linux_fcitx::sync_binding_to_plugin(&plugin_binding); } return; } let (tx, rx) = mpsc::channel::(); #[cfg(target_os = "linux")] let (fcitx_tx, fcitx_binding) = (tx.clone(), binding.clone()); match HotkeyMonitor::start(binding, tx) { Ok(monitor) => { let adapter = monitor.kind(); *self.inner.hotkey.lock() = Some(monitor); *self.inner.hotkey_status.lock() = HotkeyStatus { adapter, state: HotkeyStatusState::Installed, message: Some(format!("{} 已安装", adapter.display_name())), last_error: None, }; let inner_clone = Arc::clone(&self.inner); std::thread::Builder::new() .name("openless-hotkey-bridge".into()) .spawn(move || hotkey_bridge_loop(inner_clone, rx)) .ok(); // Linux: 启动 fcitx5 插件信号监听作为热键源。 #[cfg(target_os = "linux")] { let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&self.inner); let custom_key = custom_dictation_key_string(&self.inner); crate::linux_fcitx::start_dictation_signal_listener( fcitx_tx, fcitx_binding.clone(), qa_trigger, translation_trigger, custom_key, ); if fcitx_binding.trigger == crate::types::HotkeyTrigger::Custom { sync_custom_dictation_to_plugin(&self.inner); } else { crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding); } } } Err(e) => { *self.inner.hotkey_status.lock() = HotkeyStatus { adapter: HotkeyMonitor::capability().adapter, state: HotkeyStatusState::Failed, message: Some(e.message.clone()), last_error: Some(e), }; } } } pub fn update_modifier_shortcut_bindings(&self) { if let Some(monitor) = self.inner.hotkey.lock().as_ref() { let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&self.inner); monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); } } pub fn hotkey_status(&self) -> HotkeyStatus { self.inner.hotkey_status.lock().clone() } pub fn hotkey_capability(&self) -> HotkeyCapability { HotkeyMonitor::capability() } pub async fn start_dictation(&self) -> Result<(), String> { begin_session(&self.inner).await } pub async fn stop_dictation(&self) -> Result<(), String> { if self.inner.state.lock().phase == SessionPhase::Starting { request_stop_during_starting(&self.inner, "manual stop"); return Ok(()); } end_session(&self.inner).await } pub fn cancel_dictation(&self) { cancel_session(&self.inner); } /// 返回当前听写阶段(read-only 快照),供 CLI 入口在 dispatch toggle 时决策。 /// 与原热键边沿走的 `handle_pressed` 分支完全相同的判定逻辑:Idle → start, /// Listening → stop。可用于桌面快捷键 → CLI 转发的备用触发路径。 pub fn dictation_phase_for_cli(&self) -> SessionPhase { self.inner.state.lock().phase } /// CLI 入口的 QA toggle:直接复用 modifier-only QA 热键边沿的处理函数。 /// 与 `handle_qa_hotkey_pressed` 同语义 — Idle → 开浮窗 / Recording → 收尾 / /// Processing → 忽略。桌面快捷键 → CLI 转发的备用进入点。 pub async fn cli_toggle_qa_panel(&self) { handle_qa_hotkey_pressed(&self.inner).await; } pub fn set_shortcut_recording_active(&self, active: bool) { self.inner .shortcut_recording_active .store(active, Ordering::SeqCst); if active { reset_shortcut_held_state(&self.inner); } log::info!("[coord] shortcut recording active={active}"); } pub async fn handle_window_hotkey_event( &self, event_type: String, key: String, code: String, repeat: bool, ) -> Result<(), String> { handle_window_hotkey_event(&self.inner, event_type, key, code, repeat).await } #[cfg(any(debug_assertions, test))] pub async fn inject_hotkey_click_for_dev(&self) -> Result<(), String> { log::info!("[coord] dev hotkey injection started"); handle_pressed(&self.inner).await; handle_released(&self.inner).await; cancel_session(&self.inner); Ok(()) } pub async fn repolish(&self, raw_text: String, mode: PolishMode) -> Result { let hotwords = enabled_phrases(&self.inner); let prefs = self.inner.prefs.get(); let pack = self .inner .style_packs .get_or_default_active(&prefs.active_style_pack_id) .map_err(|e| e.to_string())?; let style_system_prompt = pack.prompt.clone(); let working_languages = prefs.working_languages; 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 effective_mode = pack.base_mode; log::info!( "[style-pack] repolish dispatch active_pack={} kind={:?} effective_mode={:?} legacy_mode={:?} raw_chars={} prompt_chars={} hotwords={} thinking={}", pack.id, pack.kind, effective_mode, mode, raw_text.chars().count(), style_system_prompt.chars().count(), hotwords.len(), llm_thinking_enabled ); if effective_mode == PolishMode::Raw && !raw_style_pack_uses_llm(&pack) { log::info!( "[style-pack] repolish bypass llm active_pack={} reason=default_builtin_raw", pack.id ); return Ok(raw_text); } // repolish 是历史记录里手动重新润色,不再绑定原 session 的前台 app; // 当下用户调起的 app 才是相关上下文(如果可拿)。 let front_app = capture_frontmost_app(); // repolish 是用户主动对单条历史"重新润色",不应该被对话感知上下文影响—— // 用户改的就是这一条本身,不要把别的会话拿进来。所以始终走单轮路径。 polish_text( &raw_text, effective_mode, &hotwords, &style_system_prompt, &working_languages, chinese_script_preference, output_language_preference, llm_thinking_enabled, front_app.as_deref(), &[], ) .await .map_err(|e| e.to_string()) } pub fn preview_style_pack_runtime( &self, style_pack: &crate::types::StylePack, ) -> crate::types::StylePackRuntimeDiagnostics { let prefs = self.inner.prefs.get(); let hotwords = enabled_phrases(&self.inner); let single_turn = crate::polish::assemble_polish_system_prompt( &style_pack.prompt, &hotwords, &prefs.working_languages, prefs.chinese_script_preference, prefs.output_language_preference, None, false, ); let multi_turn = crate::polish::assemble_polish_system_prompt( &style_pack.prompt, &hotwords, &prefs.working_languages, prefs.chinese_script_preference, prefs.output_language_preference, None, true, ); crate::types::StylePackRuntimeDiagnostics { pack_id: style_pack.id.clone(), pack_name: style_pack.name.clone(), pack_prompt: style_pack.prompt.clone(), pack_prompt_chars: style_pack.prompt.chars().count(), context_premise: single_turn.context_premise.clone(), context_premise_chars: single_turn.context_premise.chars().count(), hotword_block: single_turn.hotword_block.clone(), hotword_block_chars: single_turn.hotword_block.chars().count(), history_instruction: multi_turn.history_instruction.clone(), history_instruction_chars: multi_turn.history_instruction.chars().count(), single_turn_prompt: single_turn.effective_system_prompt.clone(), single_turn_prompt_chars: single_turn.effective_system_prompt.chars().count(), multi_turn_prompt: multi_turn.effective_system_prompt.clone(), multi_turn_prompt_chars: multi_turn.effective_system_prompt.chars().count(), working_languages: prefs.working_languages, hotwords, context_window_minutes: prefs.polish_context_window_minutes, includes_context_premise: single_turn.includes_context_premise, includes_hotword_block: single_turn.includes_hotword_block, includes_history_instruction: multi_turn.includes_history_instruction, preview_omits_front_app: true, } } } fn raw_style_pack_uses_llm(pack: &crate::types::StylePack) -> bool { !(pack.kind == crate::types::StylePackKind::Builtin && pack.id == crate::types::BUILTIN_STYLE_PACK_RAW_ID && pack.prompt == crate::types::StyleSystemPrompts::default().raw) } fn raw_mode_uses_llm(style_system_prompt: &str) -> bool { style_system_prompt != crate::types::StyleSystemPrompts::default().raw } // ─────────────────────────── hotkey bridging ─────────────────────────── fn hotkey_supervisor_loop(inner: Arc) { let mut attempts: u32 = 0; let capability = HotkeyMonitor::capability(); loop { if inner.shutdown.load(Ordering::SeqCst) { return; } let prefs = inner.prefs.get(); if inner.hotkey.lock().is_some() { return; } // Linux: 启动前检查 fcitx5 插件是否可用 #[cfg(target_os = "linux")] if !crate::linux_fcitx::available() { *inner.hotkey_status.lock() = HotkeyStatus { adapter: capability.adapter, state: HotkeyStatusState::Failed, message: Some("fcitx5 插件不可用 — 请确保 fcitx5 已安装且在运行".into()), last_error: Some(crate::types::HotkeyInstallError { code: "fcitx5_unavailable".into(), message: "fcitx5 插件 DBus 接口无响应".into(), }), }; log::warn!("[hotkey-supervisor] fcitx5 plugin unavailable, retrying..."); attempts += 1; std::thread::sleep(std::time::Duration::from_secs(3)); continue; } *inner.hotkey_status.lock() = HotkeyStatus { adapter: capability.adapter, state: HotkeyStatusState::Starting, message: Some(format!("正在安装全局快捷键监听(第 {} 次)", attempts + 1)), last_error: None, }; let trigger = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey) .unwrap_or(crate::types::HotkeyTrigger::Custom); let binding = crate::types::HotkeyBinding { trigger, mode: prefs.hotkey.mode, keys: None, }; let (tx, rx) = mpsc::channel::(); #[cfg(target_os = "linux")] let (fcitx_tx, fcitx_binding) = (tx.clone(), binding.clone()); match HotkeyMonitor::start(binding, tx) { Ok(monitor) => { let adapter = monitor.kind(); *inner.hotkey.lock() = Some(monitor); if let Some(monitor) = inner.hotkey.lock().as_ref() { let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); } *inner.hotkey_status.lock() = HotkeyStatus { adapter, state: HotkeyStatusState::Installed, message: Some(format!("{} 已安装", adapter.display_name())), last_error: None, }; log::info!( "[coord] hotkey listener installed (after {} attempt(s))", attempts + 1 ); let inner_clone = Arc::clone(&inner); std::thread::Builder::new() .name("openless-hotkey-bridge".into()) .spawn(move || hotkey_bridge_loop(inner_clone, rx)) .ok(); // Linux: 启动 fcitx5 插件信号监听作为热键源。 #[cfg(target_os = "linux")] { let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); let custom_key = custom_dictation_key_string(&inner); crate::linux_fcitx::start_dictation_signal_listener( fcitx_tx, fcitx_binding.clone(), qa_trigger, translation_trigger, custom_key, ); if fcitx_binding.trigger == crate::types::HotkeyTrigger::Custom { sync_custom_dictation_to_plugin(&inner); } else { crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding); } } return; } Err(e) => { attempts += 1; let error_message = e.message.clone(); *inner.hotkey_status.lock() = HotkeyStatus { adapter: capability.adapter, state: HotkeyStatusState::Failed, message: Some(error_message.clone()), last_error: Some(e), }; if attempts <= 3 || attempts % 10 == 0 { log::warn!( "[coord] hotkey listener attempt #{attempts} failed: {}; retrying in 3s", error_message ); } std::thread::sleep(std::time::Duration::from_secs(3)); } } } } // ─────────────────────────── QA hotkey supervisor ─────────────────────────── fn qa_hotkey_supervisor_loop(inner: Arc) { let mut attempts: u32 = 0; loop { if inner.shutdown.load(Ordering::SeqCst) { return; } // 用户已经把 QA 关掉就睡着等 prefs 改动;改动通过 update_qa_hotkey_binding 唤醒。 let binding = match inner.prefs.get().qa_hotkey.clone() { Some(b) => b, None => { inner.qa_hotkey.lock().take(); std::thread::sleep(std::time::Duration::from_secs(5)); continue; } }; if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() { inner.qa_hotkey.lock().take(); if let Some(monitor) = inner.hotkey.lock().as_ref() { let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); } std::thread::sleep(std::time::Duration::from_secs(5)); continue; } if inner.qa_hotkey.lock().is_some() { // 已注册成功 → 不重复装;睡 5s 复查( binding 变化由 update 路径手动触发 )。 std::thread::sleep(std::time::Duration::from_secs(5)); continue; } // global-hotkey crate 在 macOS 走 Carbon RegisterEventHotKey,要求 manager // 在主线程构造,否则 register() 看起来 Ok 但事件根本不会派发——这是 issue #118 // PR #119 第一版漏掉的关键步骤,导致用户按了 hotkey 完全无反应。这里通过 // run_on_main_thread 把 QaHotkeyMonitor::start 跳到主线程跑,结果再回 channel。 let app = inner.app.lock().clone(); let app = match app { Some(a) => a, None => { // 启动期 AppHandle 还没 bind,再等。 std::thread::sleep(std::time::Duration::from_secs(1)); continue; } }; let (tx, rx) = mpsc::channel::(); let (init_tx, init_rx) = mpsc::sync_channel::>(1); let binding_for_main = binding.clone(); let _ = app.run_on_main_thread(move || { let result = QaHotkeyMonitor::start(binding_for_main, tx); let _ = init_tx.send(result); }); // run_on_main_thread 是 fire-and-forget;等主线程跑完结果回来。给 5s 上限避免 // 主线程繁忙时 supervisor 永久阻塞。 let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { Ok(r) => r, Err(_) => { attempts += 1; if attempts <= 3 || attempts % 10 == 0 { log::warn!( "[coord] QA hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试" ); } std::thread::sleep(std::time::Duration::from_secs(3)); continue; } }; match init_result { Ok(monitor) => { *inner.qa_hotkey.lock() = Some(monitor); log::info!( "[coord] QA hotkey listener installed on main thread (after {} attempt(s))", attempts + 1 ); let inner_clone = Arc::clone(&inner); std::thread::Builder::new() .name("openless-qa-hotkey-bridge".into()) .spawn(move || qa_hotkey_bridge_loop(inner_clone, rx)) .ok(); attempts = 0; } Err(e) => { attempts += 1; if attempts <= 3 || attempts % 10 == 0 { log::warn!("[coord] QA hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试"); } std::thread::sleep(std::time::Duration::from_secs(3)); } } } } fn qa_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { while let Ok(evt) = rx.recv() { if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } let inner_cloned = Arc::clone(&inner); match evt { QaHotkeyEvent::Pressed => { async_runtime::spawn(async move { handle_qa_hotkey_pressed(&inner_cloned).await }); } } } } // ─────────────────────────── combo hotkey supervisor ─────────────────────────── fn combo_hotkey_supervisor_loop(inner: Arc) { let mut attempts: u32 = 0; loop { if inner.shutdown.load(Ordering::SeqCst) { return; } // 读当前 prefs let prefs = inner.prefs.get(); if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() { // 不是 Custom → 睡着等 prefs 改动 take_combo_hotkey_on_main_thread(&inner); std::thread::sleep(std::time::Duration::from_secs(5)); continue; } let binding = prefs.dictation_hotkey.clone(); if is_unconfigured_shortcut(&binding) { take_combo_hotkey_on_main_thread(&inner); std::thread::sleep(std::time::Duration::from_secs(5)); continue; } if inner.combo_hotkey.lock().is_some() { std::thread::sleep(std::time::Duration::from_secs(5)); continue; } let app = inner.app.lock().clone(); let app = match app { Some(a) => a, None => { std::thread::sleep(std::time::Duration::from_secs(1)); continue; } }; let (tx, rx) = mpsc::channel::(); let (init_tx, init_rx) = mpsc::sync_channel::>(1); let binding_for_main = binding.clone(); let _ = app.run_on_main_thread(move || { let result = ComboHotkeyMonitor::start(binding_for_main, tx); let _ = init_tx.send(result); }); let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { Ok(r) => r, Err(_) => { attempts += 1; if attempts <= 3 || attempts % 10 == 0 { log::warn!( "[coord] combo hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试" ); } std::thread::sleep(std::time::Duration::from_secs(3)); continue; } }; match init_result { Ok(monitor) => { *inner.combo_hotkey.lock() = Some(monitor); log::info!( "[coord] combo hotkey listener installed on main thread (after {} attempt(s))", attempts + 1 ); let inner_clone = Arc::clone(&inner); std::thread::Builder::new() .name("openless-combo-hotkey-bridge".into()) .spawn(move || combo_hotkey_bridge_loop(inner_clone, rx)) .ok(); attempts = 0; } Err(e) => { attempts += 1; if attempts <= 3 || attempts % 10 == 0 { log::warn!("[coord] combo hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试"); } std::thread::sleep(std::time::Duration::from_secs(3)); } } } } fn combo_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { while let Ok(evt) = rx.recv() { if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } let inner_cloned = Arc::clone(&inner); match evt { // P0 #468/#475: 同 hotkey_bridge_loop —— Pressed/Released 必须串行 await, // 否则 latch 竞态导致 combo 快捷键二次按键失效。 ComboHotkeyEvent::Pressed => { async_runtime::block_on(async { handle_pressed_edge(&inner_cloned).await; }); } ComboHotkeyEvent::Released => { async_runtime::block_on(async { handle_released_edge(&inner_cloned).await; }); } } } } fn translation_hotkey_supervisor_loop(inner: Arc) { let mut attempts: u32 = 0; loop { if inner.shutdown.load(Ordering::SeqCst) { return; } let binding = inner.prefs.get().translation_hotkey; if is_builtin_translation_shift(&binding) || crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() { take_translation_hotkey_on_main_thread(&inner); if let Some(monitor) = inner.hotkey.lock().as_ref() { let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); } std::thread::sleep(std::time::Duration::from_secs(5)); continue; } if inner.translation_hotkey.lock().is_some() { std::thread::sleep(std::time::Duration::from_secs(5)); continue; } let app = match inner.app.lock().clone() { Some(a) => a, None => { std::thread::sleep(std::time::Duration::from_secs(1)); continue; } }; let (tx, rx) = mpsc::channel::(); let (init_tx, init_rx) = mpsc::sync_channel::>(1); let binding_for_main = binding.clone(); let _ = app.run_on_main_thread(move || { let result = ComboHotkeyMonitor::start(binding_for_main, tx); let _ = init_tx.send(result); }); let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { Ok(r) => r, Err(_) => { attempts += 1; std::thread::sleep(std::time::Duration::from_secs(3)); continue; } }; match init_result { Ok(monitor) => { *inner.translation_hotkey.lock() = Some(monitor); let inner_clone = Arc::clone(&inner); std::thread::Builder::new() .name("openless-translation-hotkey-bridge".into()) .spawn(move || translation_hotkey_bridge_loop(inner_clone, rx)) .ok(); attempts = 0; } Err(e) => { attempts += 1; if attempts <= 3 || attempts % 10 == 0 { log::warn!( "[coord] translation hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试" ); } std::thread::sleep(std::time::Duration::from_secs(3)); } } } } fn update_translation_hotkey_on_main_thread( inner: Arc, binding: crate::types::ShortcutBinding, ) -> Result<(), ComboHotkeyError> { if let Some(monitor) = inner.translation_hotkey.lock().as_ref() { return monitor.update_binding(binding); } let (tx, rx) = mpsc::channel::(); let monitor = ComboHotkeyMonitor::start(binding, tx)?; *inner.translation_hotkey.lock() = Some(monitor); let bridge_inner = Arc::clone(&inner); std::thread::Builder::new() .name("openless-translation-hotkey-bridge".into()) .spawn(move || translation_hotkey_bridge_loop(bridge_inner, rx)) .map_err(|e| ComboHotkeyError::RegisterFailed(format!("spawn bridge thread: {e}")))?; Ok(()) } fn translation_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { while let Ok(evt) = rx.recv() { if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } if matches!(evt, ComboHotkeyEvent::Pressed) { mark_translation_modifier_seen(&inner); } } } fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotkeyKind) { let mut attempts: u32 = 0; loop { if inner.shutdown.load(Ordering::SeqCst) { return; } let binding = action_hotkey_binding(&inner, kind); if is_modifier_only_shortcut(&binding) { take_action_hotkey_on_main_thread(&inner, kind); std::thread::sleep(std::time::Duration::from_secs(5)); continue; } if action_hotkey_slot(&inner, kind).lock().is_some() { std::thread::sleep(std::time::Duration::from_secs(5)); continue; } let app = match inner.app.lock().clone() { Some(a) => a, None => { std::thread::sleep(std::time::Duration::from_secs(1)); continue; } }; let (tx, rx) = mpsc::channel::(); let (init_tx, init_rx) = mpsc::sync_channel::>(1); let binding_for_main = binding.clone(); let _ = app.run_on_main_thread(move || { let result = ComboHotkeyMonitor::start(binding_for_main, tx); let _ = init_tx.send(result); }); let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { Ok(r) => r, Err(_) => { attempts += 1; if attempts <= 3 || attempts % 10 == 0 { log::warn!( "[coord] action hotkey {kind:?} 第 {attempts} 次注册超时;3s 后重试" ); } std::thread::sleep(std::time::Duration::from_secs(3)); continue; } }; match init_result { Ok(monitor) => { *action_hotkey_slot(&inner, kind).lock() = Some(monitor); log::info!( "[coord] action hotkey {kind:?} listener installed after {} attempt(s)", attempts + 1 ); let inner_clone = Arc::clone(&inner); std::thread::Builder::new() .name(action_hotkey_bridge_thread_name(kind).into()) .spawn(move || action_hotkey_bridge_loop(inner_clone, rx, kind)) .ok(); attempts = 0; } Err(e) => { attempts += 1; if attempts <= 3 || attempts % 10 == 0 { log::warn!( "[coord] action hotkey {kind:?} 第 {attempts} 次注册失败: {e}; 3s 后重试" ); } std::thread::sleep(std::time::Duration::from_secs(3)); } } } } fn action_hotkey_bridge_loop( inner: Arc, rx: mpsc::Receiver, kind: ActionHotkeyKind, ) { while let Ok(evt) = rx.recv() { if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } if matches!(evt, ComboHotkeyEvent::Pressed) { handle_action_hotkey_pressed(&inner, kind); } } } fn handle_action_hotkey_pressed(inner: &Arc, kind: ActionHotkeyKind) { match kind { ActionHotkeyKind::SwitchStyle => switch_to_previous_style(inner), ActionHotkeyKind::OpenApp => { if let Some(app) = inner.app.lock().clone() { let app_for_main = app.clone(); let _ = app.run_on_main_thread(move || { crate::show_main_window(&app_for_main); }); } } } } fn switch_to_previous_style(inner: &Arc) { let mut prefs = inner.prefs.get(); let packs = match inner.style_packs.list() { Ok(packs) => packs, Err(error) => { log::warn!("[coord] switch style hotkey failed to load style packs: {error}"); return; } }; let enabled: Vec = packs.into_iter().filter(|pack| pack.enabled).collect(); if enabled.len() <= 1 { log::info!("[coord] switch style hotkey ignored: enabled style count <= 1"); return; } let current_index = enabled .iter() .position(|pack| pack.id == prefs.active_style_pack_id) .unwrap_or(0); let next_index = if current_index == 0 { enabled.len() - 1 } else { current_index - 1 }; prefs.active_style_pack_id = enabled[next_index].id.clone(); sync_style_pack_preferences(&mut prefs, &enabled); if let Err(e) = inner.prefs.set(prefs.clone()) { log::warn!("[coord] switch style hotkey 保存失败: {e}"); } else { log::info!( "[coord] switch style hotkey changed active style pack to {}", prefs.active_style_pack_id ); if let Some(app) = inner.app.lock().clone() { let _ = app.emit("prefs:changed", &prefs); let _ = app.emit_to("main", "prefs:changed", &prefs); let app_for_main = app.clone(); let _ = app.run_on_main_thread(move || { if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) { log::warn!("[tray] refresh style menu after switch style hotkey failed: {err}"); } }); } } } fn take_combo_hotkey_on_main_thread(inner: &Arc) { let app = inner.app.lock().clone(); if let Some(app) = app { let inner = Arc::clone(inner); let _ = app.run_on_main_thread(move || { inner.combo_hotkey.lock().take(); }); } else { inner.combo_hotkey.lock().take(); } } fn take_translation_hotkey_on_main_thread(inner: &Arc) { let app = inner.app.lock().clone(); if let Some(app) = app { let inner = Arc::clone(inner); let _ = app.run_on_main_thread(move || { inner.translation_hotkey.lock().take(); }); } else { inner.translation_hotkey.lock().take(); } } fn take_action_hotkey_on_main_thread(inner: &Arc, kind: ActionHotkeyKind) { let app = inner.app.lock().clone(); if let Some(app) = app { let inner = Arc::clone(inner); let _ = app.run_on_main_thread(move || { action_hotkey_slot(&inner, kind).lock().take(); }); } else { action_hotkey_slot(inner, kind).lock().take(); } } fn action_hotkey_slot( inner: &Arc, kind: ActionHotkeyKind, ) -> &Mutex> { match kind { ActionHotkeyKind::SwitchStyle => &inner.switch_style_hotkey, ActionHotkeyKind::OpenApp => &inner.open_app_hotkey, } } fn action_hotkey_binding( inner: &Arc, kind: ActionHotkeyKind, ) -> crate::types::ShortcutBinding { let prefs = inner.prefs.get(); match kind { ActionHotkeyKind::SwitchStyle => prefs.switch_style_hotkey, ActionHotkeyKind::OpenApp => prefs.open_app_hotkey, } } fn is_modifier_only_shortcut(binding: &crate::types::ShortcutBinding) -> bool { binding.modifiers.is_empty() && (binding.primary.eq_ignore_ascii_case("shift") || crate::shortcut_binding::legacy_modifier_trigger(binding).is_some()) } fn is_unconfigured_shortcut(binding: &crate::types::ShortcutBinding) -> bool { binding.primary.trim().is_empty() } fn action_hotkey_bridge_thread_name(kind: ActionHotkeyKind) -> &'static str { match kind { ActionHotkeyKind::SwitchStyle => "openless-switch-style-hotkey-bridge", ActionHotkeyKind::OpenApp => "openless-open-app-hotkey-bridge", } } fn is_builtin_translation_shift(binding: &crate::types::ShortcutBinding) -> bool { binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") } /// Linux: 从 prefs 读取自定义组合键,同步到 fcitx5 插件。 #[cfg(target_os = "linux")] fn custom_dictation_key_string(inner: &Arc) -> Option { let prefs = inner.prefs.get(); let key_string = crate::linux_fcitx::binding_to_fcitx_key_string(&prefs.dictation_hotkey); if key_string.is_empty() { None } else { Some(key_string) } } #[cfg(target_os = "linux")] fn sync_custom_dictation_to_plugin(inner: &Arc) { let prefs = inner.prefs.get(); let dictation = &prefs.dictation_hotkey; let key_string = crate::linux_fcitx::binding_to_fcitx_key_string(dictation); if key_string.is_empty() { return; } match crate::linux_fcitx::set_custom_dictation_trigger(&key_string) { Ok(()) => log::info!( "[fcitx] Synced custom dictation trigger '{}' to plugin", key_string ), Err(e) => log::warn!("[fcitx] Failed to sync custom dictation trigger: {e}"), } } fn modifier_shortcut_triggers( inner: &Arc, ) -> ( Option, Option, ) { let prefs = inner.prefs.get(); let qa_trigger = prefs .qa_hotkey .as_ref() .and_then(crate::shortcut_binding::legacy_modifier_trigger); let translation_trigger = if is_builtin_translation_shift(&prefs.translation_hotkey) { None } else { crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey) }; (qa_trigger, translation_trigger) } fn mark_translation_modifier_seen(inner: &Arc) { let phase = inner.state.lock().phase; if matches!(phase, SessionPhase::Starting | SessionPhase::Listening) { inner .translation_modifier_seen .store(true, Ordering::SeqCst); log::info!("[coord] translation modifier seen during {phase:?}"); } } fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { while let Ok(evt) = rx.recv() { if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } let inner_cloned = Arc::clone(&inner); match evt { // P0 #468/#475: Pressed/Released 必须串行处理,否则在 Windows 上 WH_KEYBOARD_LL // 边沿间隔微秒级 → 两个独立 spawn 的 task 被 work-stealing 调度器并行执行 → // `hotkey_trigger_held` latch 翻转顺序错乱 → 下次按键被静默吞掉 // (UI 关不掉 / 录音停不下来)。改为 bridge 线程内 block_on 顺序 await, // recv 的 FIFO 顺序就是 handler 执行顺序。 // 注意:handle_pressed_edge / handle_released_edge 内部走 .await(含网络 // 握手),会暂时阻塞本 bridge 线程;Hold 模式短按时 Released 会排队在 channel // 里直到 begin_session 完成,但 SessionPhase::Starting 已经有 // request_stop_during_starting 兜底,begin_session 完成进 Listening 后 // bridge 立刻 recv Released → end_session,行为正确,仅有短暂 stop 延迟。 HotkeyEvent::Pressed => { async_runtime::block_on(async { handle_pressed_edge(&inner_cloned).await; }); } HotkeyEvent::Released => { async_runtime::block_on(async { handle_released_edge(&inner_cloned).await; }); } HotkeyEvent::Cancelled => { cancel_session(&inner_cloned); } HotkeyEvent::TranslationModifierPressed => { let translation_hotkey = inner_cloned.prefs.get().translation_hotkey; if is_builtin_translation_shift(&translation_hotkey) || crate::shortcut_binding::legacy_modifier_trigger(&translation_hotkey) .is_some() { mark_translation_modifier_seen(&inner_cloned); } } HotkeyEvent::QaShortcutPressed => { async_runtime::spawn(async move { handle_qa_hotkey_pressed(&inner_cloned).await }); } } } } fn reset_shortcut_held_state(inner: &Arc) { inner.hotkey_trigger_held.store(false, Ordering::SeqCst); if let Some(monitor) = inner.hotkey.lock().as_ref() { monitor.reset_held_state(); } let prefs = inner.prefs.get(); if let Some(binding) = prefs.qa_hotkey.as_ref() { if crate::shortcut_binding::legacy_modifier_trigger(binding).is_none() { if let Some(monitor) = inner.qa_hotkey.lock().as_ref() { if let Err(e) = monitor.update_binding(binding.clone()) { log::warn!("[coord] reset QA hotkey latch failed: {e}"); } } } } if !is_builtin_translation_shift(&prefs.translation_hotkey) && crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey).is_none() { if let Some(monitor) = inner.translation_hotkey.lock().as_ref() { if let Err(e) = monitor.update_binding(prefs.translation_hotkey.clone()) { log::warn!("[coord] reset translation hotkey latch failed: {e}"); } } } if !is_modifier_only_shortcut(&prefs.switch_style_hotkey) { if let Some(monitor) = inner.switch_style_hotkey.lock().as_ref() { if let Err(e) = monitor.update_binding(prefs.switch_style_hotkey.clone()) { log::warn!("[coord] reset switch-style hotkey latch failed: {e}"); } } } if !is_modifier_only_shortcut(&prefs.open_app_hotkey) { if let Some(monitor) = inner.open_app_hotkey.lock().as_ref() { if let Err(e) = monitor.update_binding(prefs.open_app_hotkey.clone()) { log::warn!("[coord] reset open-app hotkey latch failed: {e}"); } } } } async fn handle_window_hotkey_event( inner: &Arc, event_type: String, key: String, code: String, repeat: bool, ) -> Result<(), String> { if inner.shortcut_recording_active.load(Ordering::SeqCst) { return Ok(()); } if event_type == "keydown" && key == "Escape" { // Esc 路由(issue #161):QA 浮窗可见时优先取消 QA(不动 dictation); // 否则走 dictation 取消通路。之前无条件 cancel_session 导致 QA 浮窗 // 按 Esc 杀的是 dictation 而 QA 流还在烧 token。 let qa_active = { let st = inner.qa_state.lock(); st.panel_visible || st.phase != QaPhase::Idle }; if qa_active { close_qa_panel(inner); } else { cancel_session(inner); } return Ok(()); } #[cfg(not(target_os = "windows"))] { let _ = (inner, event_type, key, code, repeat); Ok(()) } #[cfg(target_os = "windows")] { if !window_hotkey_fallback_enabled() { if event_type == "keydown" && !repeat { log::info!( "[window-hotkey] ignored because Windows lifecycle owner is the low-level hook" ); } return Ok(()); } let Some(trigger) = crate::shortcut_binding::legacy_modifier_trigger(&inner.prefs.get().dictation_hotkey) else { return Ok(()); }; if !window_key_matches_trigger(trigger, &key, &code) { return Ok(()); } match event_type.as_str() { "keydown" => { if repeat { return Ok(()); } log::info!( "[window-hotkey] pressed trigger={trigger:?} code={code} repeat={repeat}" ); handle_pressed_edge(inner).await; } "keyup" => { log::info!("[window-hotkey] released trigger={trigger:?} code={code}"); handle_released_edge(inner).await; } _ => {} } Ok(()) } } fn window_hotkey_fallback_enabled() -> bool { crate::types::HotkeyCapability::current().explicit_fallback_available } #[cfg(any(target_os = "windows", test))] fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, key: &str, code: &str) -> bool { use crate::types::HotkeyTrigger; match trigger { HotkeyTrigger::RightControl => key == "Control" && code == "ControlRight", HotkeyTrigger::LeftControl => key == "Control" && code == "ControlLeft", HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => { (key == "Alt" || key == "AltGraph") && code == "AltRight" } HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltLeft", HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight", HotkeyTrigger::Fn => key == "Control" && code == "ControlRight", // MediaPlayPause 走 WH_KEYBOARD_LL,不走 window hotkey fallback HotkeyTrigger::MediaPlayPause => false, // Custom 走 global-hotkey crate,不走 window hotkey fallback HotkeyTrigger::Custom => false, } } // ─────────────────────────── session lifecycle ─────────────────────────── /// QA 录音 runtime error 监听器。镜像 `spawn_recorder_error_monitor` 的语义但走 QA /// 收尾路径(`finish_qa_with_error` 替代 `abort_recording_with_error`)。 /// 用 qa_state.session_id 守卫 stale 事件。详见 issue #168。 fn spawn_qa_recorder_error_monitor(inner: &Arc, rx: mpsc::Receiver) { let captured_session_id = inner.qa_state.lock().session_id; let inner = Arc::clone(inner); std::thread::Builder::new() .name("openless-qa-recorder-error-monitor".into()) .spawn(move || { if let Ok(err) = rx.recv() { let current_session_id = inner.qa_state.lock().session_id; if captured_session_id != current_session_id { log::warn!( "[coord] QA recorder error from stale session {} dropped (current={}, err={})", captured_session_id, current_session_id, err ); return; } log::error!("[coord] QA recorder runtime error: {err}"); finish_qa_with_error(&inner, format!("录音设备异常: {err}")); } }) .ok(); } #[cfg(target_os = "windows")] fn store_prepared_windows_ime_session( slots: &mut Vec, session_id: SessionId, prepared: PreparedWindowsImeSession, ) { slots.retain(|slot| slot.session_id != session_id); slots.push(PreparedWindowsImeSessionSlot { session_id, prepared, }); } #[cfg(target_os = "windows")] fn take_matching_prepared_windows_ime_session( slots: &mut Vec, session_id: SessionId, ) -> Option { let index = slots .iter() .position(|slot| slot.session_id == session_id)?; Some(slots.remove(index).prepared) } #[cfg(target_os = "windows")] fn take_current_prepared_windows_ime_session_for_restore( slots: &mut Vec, session_id: SessionId, current_session_id: SessionId, ) -> Option { let prepared = take_matching_prepared_windows_ime_session(slots, session_id)?; if current_session_id == session_id { Some(prepared) } else { None } } #[cfg(target_os = "windows")] fn restore_prepared_windows_ime_session(inner: &Arc, session_id: SessionId) { let state = inner.state.lock(); let prepared = { let mut slot = inner.prepared_windows_ime_session.lock(); take_current_prepared_windows_ime_session_for_restore( &mut slot, session_id, state.session_id, ) }; if let Some(prepared) = prepared { inner.windows_ime.restore_session(prepared); } } #[cfg(not(target_os = "windows"))] fn restore_prepared_windows_ime_session(_inner: &Arc, _session_id: SessionId) {} #[cfg(target_os = "windows")] async fn insert_with_windows_ime_first( inner: &Arc, session_id: SessionId, polished: &str, restore_clipboard: bool, allow_non_tsf_insertion_fallback: bool, paste_shortcut: PasteShortcut, ime_target: Option, ) -> InsertStatus { let prepared = { let mut slot = inner.prepared_windows_ime_session.lock(); take_matching_prepared_windows_ime_session(&mut slot, session_id) }; let Some(prepared) = prepared else { log::warn!("[windows-ime] no prepared TSF session for this dictation"); if should_try_non_tsf_insertion_fallback( allow_non_tsf_insertion_fallback, InsertStatus::Failed, ) { return insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut); } log::warn!("[windows-ime] non-TSF insertion fallback is disabled; failing insert"); return InsertStatus::Failed; }; let request = crate::windows_ime_ipc::ImeSubmitRequest { session_id: Uuid::new_v4().to_string(), text: polished.to_string(), created_at: Utc::now().to_rfc3339(), target: ime_target, }; let ime_status = match inner.windows_ime.submit_prepared(&prepared, request).await { Ok(status) => status, Err(error) => { log::warn!("[windows-ime] TSF submit failed: {error}"); InsertStatus::Failed } }; inner.windows_ime.restore_session(prepared); if ime_status == InsertStatus::Inserted { ime_status } else if should_try_non_tsf_insertion_fallback(allow_non_tsf_insertion_fallback, ime_status) { insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut) } else { log::warn!("[windows-ime] TSF did not insert; non-TSF insertion fallback is disabled"); InsertStatus::Failed } } #[cfg(target_os = "windows")] fn should_try_non_tsf_insertion_fallback( allow_non_tsf_insertion_fallback: bool, ime_status: InsertStatus, ) -> bool { allow_non_tsf_insertion_fallback && ime_status != InsertStatus::Inserted } #[cfg(target_os = "windows")] fn insert_via_non_tsf_fallback( inner: &Arc, polished: &str, _restore_clipboard: bool, _paste_shortcut: PasteShortcut, ) -> InsertStatus { let status = finish_non_tsf_insertion_fallback( || inner.inserter.insert_via_unicode_keystrokes(polished), || inner.inserter.copy_fallback(polished), ); match status { InsertStatus::Inserted => { log::warn!( "[windows-ime] TSF unavailable; inserted via paced Unicode SendInput fallback" ); } InsertStatus::CopiedFallback => { log::warn!( "[windows-ime] TSF unavailable; Unicode SendInput failed, left text on clipboard" ); } InsertStatus::PasteSent | InsertStatus::Failed => { log::warn!( "[windows-ime] TSF unavailable; Unicode SendInput fallback failed and copy fallback failed" ); } } status } #[cfg(any(target_os = "windows", test))] fn finish_non_tsf_insertion_fallback( mut unicode_fallback: U, mut copy_only_fallback: C, ) -> InsertStatus where U: FnMut() -> InsertStatus, C: FnMut() -> InsertStatus, { match unicode_fallback() { InsertStatus::Inserted => InsertStatus::Inserted, InsertStatus::PasteSent | InsertStatus::CopiedFallback | InsertStatus::Failed => { match copy_only_fallback() { InsertStatus::CopiedFallback => InsertStatus::CopiedFallback, // TextInserter::copy_fallback is copy-only: success is CopiedFallback. // Treat any other status as failure so this helper never invents an insert. InsertStatus::Inserted | InsertStatus::PasteSent | InsertStatus::Failed => { InsertStatus::Failed } } } } } #[cfg(test)] mod non_tsf_fallback_tests { use super::finish_non_tsf_insertion_fallback; use crate::types::InsertStatus; #[test] fn unicode_fallback_runs_before_copy_fallback() { let mut copy_called = false; let status = finish_non_tsf_insertion_fallback( || InsertStatus::Inserted, || { copy_called = true; InsertStatus::CopiedFallback }, ); assert_eq!(status, InsertStatus::Inserted); assert!(!copy_called); } #[test] fn copy_fallback_runs_after_unicode_failure() { let mut copy_called = false; let status = finish_non_tsf_insertion_fallback( || InsertStatus::Failed, || { copy_called = true; InsertStatus::CopiedFallback }, ); assert_eq!(status, InsertStatus::CopiedFallback); assert!(copy_called); } #[test] fn double_failure_does_not_pretend_text_was_copied() { let mut copy_called = false; let status = finish_non_tsf_insertion_fallback( || InsertStatus::Failed, || { copy_called = true; InsertStatus::Failed }, ); assert_eq!(status, InsertStatus::Failed); assert!(copy_called); } } // ─────────────────────────── helpers ─────────────────────────── #[cfg(any(debug_assertions, test))] fn hotkey_injection_dry_run_enabled() -> bool { std::env::var_os("OPENLESS_HOTKEY_INJECTION_DRY_RUN").is_some() } #[cfg(any(debug_assertions, test))] fn debug_transcript_override_text() -> Option { let path = std::env::var_os("OPENLESS_DEBUG_TRANSCRIPT_FILE")?; let text = std::fs::read_to_string(path).ok()?; let trimmed = text.trim().to_string(); if trimmed.is_empty() { None } else { Some(trimmed) } } fn ensure_microphone_permission(_inner: &Arc) -> Result<(), String> { use crate::permissions::{self, PermissionStatus}; #[cfg(target_os = "windows")] { if permissions::windows_microphone_access_explicitly_denied() { return Err("需要麦克风权限,当前状态: Denied".to_string()); } return Ok(()); } let status = permissions::check_microphone(); if matches!( status, PermissionStatus::Granted | PermissionStatus::NotApplicable ) { return Ok(()); } // 听写路径不抢前台焦点:缺 mic 权限时直接请求系统授权,不再先 show_main_window。 // 用户在设置页手动点“请求权限”仍走 request_microphone_from_foreground,那是显式操作。 // 这里若系统不弹框,后续会通过 capsule error 引导用户主动去权限页处理。详见 #166。 let requested = permissions::request_microphone(); if matches!( requested, PermissionStatus::Granted | PermissionStatus::NotApplicable ) { Ok(()) } else { Err(format!("需要麦克风权限,当前状态: {requested:?}")) } } fn ensure_asr_credentials() -> Result<(), String> { let active_asr = CredentialsVault::get_active_asr(); // 本地 Qwen3-ASR 没有"凭据"概念,但需要:(a) macOS 平台 (b) 模型已下载。 if crate::asr::local::is_local_qwen3(&active_asr) { #[cfg(not(target_os = "macos"))] { return Err("本地 ASR 当前仅支持 macOS(Windows 见 issue #256)".to_string()); } #[cfg(target_os = "macos")] { return ensure_local_qwen3_model_ready(); } } if crate::asr::local::foundry::is_foundry_local_whisper(&active_asr) { #[cfg(not(target_os = "windows"))] { return Err("Foundry Local Whisper 当前仅支持 Windows".to_string()); } #[cfg(target_os = "windows")] { return Ok(()); } } if crate::asr::local::sherpa::is_sherpa_onnx_local(&active_asr) { #[cfg(not(target_os = "windows"))] { return Err("sherpa-onnx local ASR 当前仅支持 Windows".to_string()); } #[cfg(target_os = "windows")] { return Ok(()); } } if is_whisper_compatible_provider(&active_asr) || is_bailian_provider(&active_asr) { let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) .ok() .flatten() .unwrap_or_default(); if api_key.trim().is_empty() { return Err("请先在设置中填写 ASR 服务商 API Key".to_string()); } return Ok(()); } let creds = read_volc_credentials(); if creds.app_id.trim().is_empty() || creds.access_token.trim().is_empty() { Err("请先在设置中填写火山引擎 ASR App Key 和 Access Key".to_string()) } else { Ok(()) } } #[cfg(test)] fn is_keyless_local_asr_provider(id: &str) -> bool { if crate::asr::local::is_local_qwen3(id) { return true; } #[cfg(target_os = "windows")] { crate::asr::local::foundry::is_foundry_local_whisper(id) || crate::asr::local::sherpa::is_sherpa_onnx_local(id) } #[cfg(not(target_os = "windows"))] { let _ = id; false } } #[cfg(target_os = "macos")] fn ensure_local_qwen3_model_ready() -> Result<(), String> { let prefs = || -> Result { // 这里没法拿到 inner,直接读 preferences.json 即可(Coordinator 写盘后总是同步的)。 crate::persistence::PreferencesStore::new() .map_err(|e| e.to_string()) .map(|s| s.get()) }()?; let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) .ok_or_else(|| format!("未知的本地模型 id: {}", prefs.local_asr_active_model))?; if !crate::asr::local::models::is_downloaded(model_id) { return Err(format!( "本地模型 {} 未下载完整,请到 设置 → 模型设置 中下载", model_id.as_str() )); } Ok(()) } /// 一次 dictation 结束后,按 prefs.local_asr_keep_loaded_secs 决定何时释放 /// 内存里的 Qwen3-ASR 引擎。0 = 立即释放;其它值 = sleep N 秒后看 last_used。 /// 多次会话叠加多个 sleep 任务,每个独立 check:只要中间又被使用过就跳过释放。 fn schedule_local_asr_release(inner: &Arc) { let keep_secs = inner.prefs.get().local_asr_keep_loaded_secs; let cache = Arc::clone(&inner.local_asr_cache); if keep_secs == 0 { cache.release_now(); return; } let dur = std::time::Duration::from_secs(keep_secs as u64); tauri::async_runtime::spawn(async move { tokio::time::sleep(dur).await; cache.release_if_idle(dur); }); } #[cfg(target_os = "windows")] fn foundry_local_asr_release_keep_secs(inner: &Arc) -> u32 { inner.prefs.get().foundry_local_asr_keep_loaded_secs } #[cfg(target_os = "windows")] #[derive(Clone, Copy)] enum AsrReleaseSession { Dictation(SessionId), Qa(SessionId), } #[cfg(target_os = "windows")] fn asr_release_session_is_current(inner: &Arc, session: AsrReleaseSession) -> bool { match session { AsrReleaseSession::Dictation(session_id) => inner.state.lock().session_id == session_id, AsrReleaseSession::Qa(session_id) => inner.qa_state.lock().session_id == session_id, } } #[cfg(target_os = "windows")] fn schedule_foundry_local_asr_release(inner: &Arc, session: AsrReleaseSession) { let keep_secs = foundry_local_asr_release_keep_secs(inner); let runtime = Arc::clone(&inner.foundry_local_runtime); let inner = Arc::clone(inner); tauri::async_runtime::spawn(async move { if keep_secs > 0 { tokio::time::sleep(std::time::Duration::from_secs(keep_secs as u64)).await; } if !asr_release_session_is_current(&inner, session) { return; } if let Err(error) = runtime.release_now().await { log::warn!("[foundry-asr] scheduled release failed: {error:#}"); } }); } #[cfg(target_os = "windows")] fn sherpa_onnx_release_keep_secs(inner: &Arc) -> u32 { inner.prefs.get().sherpa_onnx_keep_loaded_secs } /// 与 `schedule_foundry_local_asr_release` 同形:session_id 老旧则不释放, /// 避免下一轮 session 立即重加载同一个 offline batch 模型。 #[cfg(target_os = "windows")] fn schedule_sherpa_onnx_release(inner: &Arc, session: AsrReleaseSession) { let keep_secs = sherpa_onnx_release_keep_secs(inner); let runtime = Arc::clone(&inner.sherpa_onnx_runtime); let inner = Arc::clone(inner); tauri::async_runtime::spawn(async move { if keep_secs > 0 { tokio::time::sleep(std::time::Duration::from_secs(keep_secs as u64)).await; } if !asr_release_session_is_current(&inner, session) { return; } if let Err(error) = runtime.release_now().await { log::warn!("[sherpa-asr] scheduled release failed: {error:#}"); } }); } #[cfg(target_os = "macos")] async fn build_local_qwen3( inner: &Arc, ) -> anyhow::Result> { let prefs = inner.prefs.get(); let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) .ok_or_else(|| anyhow::anyhow!("未知本地模型 id: {}", prefs.local_asr_active_model))?; let dir = crate::asr::local::models::model_dir(model_id)?; let app = inner .app .lock() .clone() .ok_or_else(|| anyhow::anyhow!("AppHandle 未绑定"))?; // 走缓存:如果已有同 id 的引擎在内存里就直接复用,避免每次会话都重加载 // 1.2GB+ 模型。第一次加载阻塞数秒,spawn_blocking 不卡 tokio runtime。 let cache = Arc::clone(&inner.local_asr_cache); let mid = model_id.as_str().to_string(); let engine = tauri::async_runtime::spawn_blocking(move || cache.get_or_load(&mid, &dir)) .await .map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??; Ok(Arc::new(crate::asr::local::LocalQwenAsr::new(app, engine))) } /// `whisper` 是 OpenAI 原生;`siliconflow` / `zhipu` / `groq` 都暴露 /// OpenAI 兼容的 `/audio/transcriptions`,统一走 `WhisperBatchASR`。 /// 新增 OpenAI 兼容 ASR 时只需在这里加一项。 /// /// 注:DashScope 的 Qwen3-ASR-Flash 不在此列——它用 MultiModalConversation /// (messages=[{content:[{audio:...}]}]) 协议,不是 Whisper multipart,需要 /// 单独 ASR 客户端,留给 V2。 fn is_whisper_compatible_provider(id: &str) -> bool { matches!(id, "whisper" | "siliconflow" | "zhipu" | "groq") } /// 该 provider 的 `/audio/transcriptions` 是否支持 `response_format=verbose_json` /// 并返回带 `no_speech_prob` / `avg_logprob` / `compression_ratio` 的 segments, /// 用于幻听过滤。 /// /// - `whisper`(OpenAI)/ `groq`:原生 Whisper,完整支持,过滤有效。 /// - `siliconflow`:模型是 SenseVoice / TeleSpeech,文档无 `response_format`, /// 发送 verbose_json 可能被拒,**保持关闭**走旧的 `json`。 /// - `zhipu`(GLM-ASR):虽接受 verbose_json,但不产出上述指标,过滤是空转; /// 为最小化行为变更,这里也**保持关闭**,仅对确证有收益的 whisper/groq 开启。 fn whisper_supports_verbose_json(provider_id: &str) -> bool { matches!(provider_id, "whisper" | "groq") } fn is_bailian_provider(id: &str) -> bool { id == crate::asr::bailian::PROVIDER_ID } fn apply_chinese_script_preference(text: &str, pref: ChineseScriptPreference) -> String { if text.is_empty() { return String::new(); } let config = match pref { ChineseScriptPreference::Simplified => Some(BuiltinConfig::T2s), ChineseScriptPreference::Traditional => Some(BuiltinConfig::S2t), ChineseScriptPreference::Auto => None, }; let Some(config) = config else { return text.to_string(); }; match OpenCC::from_config(config) { Ok(converter) => converter.convert(text), Err(err) => { log::warn!("[coord] OpenCC init failed, skip script conversion: {err}"); text.to_string() } } } enum QaAsrStart { Volcengine { asr: Arc, bridge: Arc, }, Bailian { asr: Arc, bridge: Arc, }, Ready { active: ActiveAsr, consumer: Arc, }, } impl QaAsrStart { fn active_asr(&self) -> ActiveAsr { match self { QaAsrStart::Volcengine { asr, .. } => ActiveAsr::Volcengine(Arc::clone(asr)), QaAsrStart::Bailian { asr, .. } => ActiveAsr::Bailian(Arc::clone(asr)), QaAsrStart::Ready { active, .. } => active.clone(), } } fn recorder_consumer(&self) -> Arc { match self { QaAsrStart::Volcengine { bridge, .. } => Arc::clone(bridge) as _, QaAsrStart::Bailian { bridge, .. } => Arc::clone(bridge) as _, QaAsrStart::Ready { consumer, .. } => Arc::clone(consumer), } } async fn open_streaming_session(&self) -> Result<(), String> { match self { QaAsrStart::Volcengine { asr, bridge } => { asr.open_session().await.map_err(|e| e.to_string())?; let target: Arc = Arc::clone(asr) as _; let flushed = bridge.attach(target); log::info!("[coord] QA ASR connected; flushed {flushed} deferred audio bytes"); Ok(()) } QaAsrStart::Bailian { asr, bridge } => { asr.open_session().await.map_err(|e| e.to_string())?; let target: Arc = Arc::clone(asr) as _; let flushed = bridge.attach(target); log::info!( "[coord] QA Bailian ASR connected; flushed {flushed} deferred audio bytes" ); Ok(()) } QaAsrStart::Ready { .. } => Ok(()), } } } async fn build_qa_asr_start(inner: &Arc, active_asr: &str) -> Result { #[cfg(target_os = "windows")] if foundry::is_foundry_local_whisper(active_asr) { let prefs = inner.prefs.get(); let model_alias = if foundry::model_alias_is_known(&prefs.foundry_local_asr_model) { prefs.foundry_local_asr_model.clone() } else { foundry::DEFAULT_MODEL_ALIAS.to_string() }; let language_hint = prefs.foundry_local_asr_language_hint.trim().to_string(); let language_hint = if language_hint.is_empty() { None } else { Some(language_hint) }; let local = Arc::new(FoundryLocalWhisperAsr::new( Arc::clone(&inner.foundry_local_runtime), model_alias, prefs.foundry_local_runtime_source.clone(), language_hint, )); let active = ActiveAsr::FoundryLocalWhisper(Arc::clone(&local)); let consumer: Arc = local; return Ok(QaAsrStart::Ready { active, consumer }); } #[cfg(target_os = "windows")] if sherpa::is_sherpa_onnx_local(active_asr) { let prefs = inner.prefs.get(); let model_alias = if sherpa::model_alias_is_known(&prefs.sherpa_onnx_model) { prefs.sherpa_onnx_model.clone() } else { sherpa::DEFAULT_MODEL_ALIAS.to_string() }; let language_hint = prefs.sherpa_onnx_language_hint.trim().to_string(); let language_hint = if language_hint.is_empty() { None } else { Some(language_hint) }; let token_handler = inner.app.lock().clone().map(|app| { Arc::new(move |piece: String| { if let Err(error) = app.emit("local-asr-token", piece) { log::warn!("[sherpa-asr] emit token failed: {error}"); } }) as crate::asr::local::sherpa_provider::SherpaTokenHandler }); let local = SherpaOnnxAsr::new_for_model( Arc::clone(&inner.sherpa_onnx_runtime), model_alias, language_hint, token_handler, ) .await .map_err(|e| format!("sherpa-onnx init failed: {e}"))?; let local = Arc::new(local); let active = ActiveAsr::SherpaOnnxLocal(Arc::clone(&local)); let consumer: Arc = local; return Ok(QaAsrStart::Ready { active, consumer }); } #[cfg(target_os = "macos")] if crate::asr::local::is_local_qwen3(active_asr) { let local = build_local_qwen3(inner) .await .map_err(|e| format!("local ASR init failed: {e}"))?; let active = ActiveAsr::Local(Arc::clone(&local)); let consumer: Arc = local; return Ok(QaAsrStart::Ready { active, consumer }); } match active_asr_provider_kind(active_asr) { ActiveAsrProviderKind::Bailian => Ok(QaAsrStart::Bailian { asr: Arc::new(BailianRealtimeASR::new(read_bailian_credentials())), bridge: Arc::new(DeferredAsrBridge::new()), }), ActiveAsrProviderKind::WhisperCompatible => { let (api_key, base_url, model) = read_whisper_credentials(); let whisper_prompt = crate::asr::whisper::build_prompt_from_phrases(&enabled_phrases(inner)); let whisper = Arc::new(WhisperBatchASR::new( api_key, base_url, model, whisper_prompt, batch_asr_chunk_limit_ms(active_asr), whisper_supports_verbose_json(active_asr), )); let active = ActiveAsr::Whisper(Arc::clone(&whisper)); let consumer: Arc = whisper; Ok(QaAsrStart::Ready { active, consumer }) } ActiveAsrProviderKind::Volcengine => Ok(QaAsrStart::Volcengine { asr: Arc::new(VolcengineStreamingASR::new( read_volc_credentials(), enabled_hotwords(inner), )), bridge: Arc::new(DeferredAsrBridge::new()), }), } } /// 润色文本;失败时返回原文 + 失败原因,调用方据此弹错误胶囊 + 写历史 error_code。 /// 之前固定返回 String,调用方拿不到失败信号 → 用户感知"为什么风格设置没生效"。issue #57。 /// 流式润色的三态结果。让上层(dictation pipeline)能区分「已经流出去了」、 /// 「降级到一次性」和「真失败了走 raw 兜底」三种 case。 pub enum StreamingPolishOutcome { /// 流式润色成功,`String` 是已经一边流一边交给 `on_delta` 的全部文本(用于写 /// history、做词条命中统计)。调用方不应再 `inserter.insert(&text)`,因为字符 /// 已经通过键盘事件落到光标处。 Streamed(String), /// 当前配置不支持流式:用户没开 streaming_insert / Gemini provider / Codex /// provider / Raw 模式 / 翻译模式 / 不是 macOS。调用方应回到现有的 /// `polish_or_passthrough` 一次性路径,跟历史行为完全一致。 UnsupportedFallback, /// 流式过程中失败(HTTP / 解析 / 空流等)。`String` 是失败原因,调用方应当 /// 走 raw 兜底(同 `polish_or_passthrough` 失败分支的语义)。 Failed(String), } /// 流式润色入口。在不支持流式的所有 case 都返回 `UnsupportedFallback`,让调用方 /// 透明降级。不修改任何持久化 / 焦点 / 光标状态。 /// /// `on_delta` 每收到一个 SSE chunk 就被调用一次(同步),调用方负责把 chunk 实际 /// 模拟键盘事件落到光标 —— 见 `coordinator/dictation.rs` 的流式分支。 /// `should_cancel` 用户取消时返回 true,立即 break SSE 读循环避免烧 quota。 pub async fn polish_or_passthrough_streaming( raw: &RawTranscript, mode: PolishMode, hotwords: &[String], style_system_prompt: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, llm_thinking_enabled: bool, front_app: Option<&str>, prior_turns: &[(String, String)], on_delta: F, should_cancel: C, ) -> StreamingPolishOutcome where F: Fn(&str) + Send + Sync, C: Fn() -> bool + Send + Sync, { if mode == PolishMode::Raw && !raw_mode_uses_llm(style_system_prompt) { log::info!("[coord] streaming polish skipped: mode=Raw, fall back to one-shot"); return StreamingPolishOutcome::UnsupportedFallback; } let active_llm = CredentialsVault::get_active_llm(); if active_llm == "gemini" { log::info!( "[coord] streaming polish skipped: active LLM provider=gemini (v1 not implemented), fall back to one-shot" ); return StreamingPolishOutcome::UnsupportedFallback; } let provider = match build_active_llm_provider(llm_thinking_enabled) { Ok(p) => p, Err(e) => { log::error!("[coord] streaming polish: build provider failed: {e}"); return StreamingPolishOutcome::Failed(e.to_string()); } }; if !provider.supports_streaming_polish() { log::info!( "[coord] streaming polish skipped: provider does not support streaming (likely codex OAuth), fall back to one-shot" ); return StreamingPolishOutcome::UnsupportedFallback; } log::info!( "[coord] streaming polish START: provider=openai-compatible mode={:?} raw_chars={} prior_turns={}", mode, raw.text.chars().count(), prior_turns.len() ); match provider .polish_streaming( &raw.text, mode, hotwords, style_system_prompt, working_languages, chinese_script_preference, output_language_preference, front_app, prior_turns, on_delta, should_cancel, ) .await { Ok(text) => { log::info!( "[coord] streaming polish OK: final_chars={}", text.chars().count() ); StreamingPolishOutcome::Streamed(text) } Err(e) => { let reason = e.to_string(); log::error!("[coord] streaming polish FAILED: {reason}"); StreamingPolishOutcome::Failed(reason) } } } async fn polish_or_passthrough( raw: &RawTranscript, mode: PolishMode, hotwords: &[String], style_system_prompt: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, llm_thinking_enabled: bool, front_app: Option<&str>, prior_turns: &[(String, String)], ) -> (String, Option) { if mode == PolishMode::Raw && !raw_mode_uses_llm(style_system_prompt) { return (raw.text.clone(), None); } match polish_text( &raw.text, mode, hotwords, style_system_prompt, working_languages, chinese_script_preference, output_language_preference, llm_thinking_enabled, front_app, prior_turns, ) .await { Ok(s) => (s, None), Err(e) => { let reason = e.to_string(); log::error!("[coord] polish failed, falling back to raw: {reason}"); (raw.text.clone(), Some(reason)) } } } async fn polish_text( raw: &str, mode: PolishMode, hotwords: &[String], style_system_prompt: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, llm_thinking_enabled: bool, front_app: Option<&str>, prior_turns: &[(String, String)], ) -> anyhow::Result { // 谷歌 Gemini 分支:所有 LLM provider 共用 ark.* 凭据槽,唯独 Gemini 走原生 // generateContent / 自带 thinkingConfig 控制;其余 provider 走 OpenAI // 兼容协议,并在该路径里按 provider/channel 下发对应的思考开关。 let active_llm = CredentialsVault::get_active_llm(); if active_llm == "gemini" { let (api_key, model, base_url) = read_gemini_credentials()?; let provider = GeminiProvider::new( GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled), ); return Ok(provider .polish( raw, mode, hotwords, style_system_prompt, working_languages, chinese_script_preference, output_language_preference, front_app, prior_turns, ) .await?); } let provider = build_active_llm_provider(llm_thinking_enabled)?; Ok(provider .polish( raw, mode, hotwords, style_system_prompt, working_languages, chinese_script_preference, output_language_preference, front_app, prior_turns, ) .await?) } /// 专用翻译(仅翻译、不润色、单轮)。现作为"润色+翻译"合成调用解析失败时的兜底—— /// 模型没按两段格式输出时,退回这里拿一段干净译文,而不是把畸形输出当译文插入。 async fn translate_text( raw: &str, target_language: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, llm_thinking_enabled: bool, front_app: Option<&str>, ) -> anyhow::Result { // 见 polish_text 顶部注释——同样的 Gemini / OpenAI-compatible 路由逻辑。 let active_llm = CredentialsVault::get_active_llm(); if active_llm == "gemini" { let (api_key, model, base_url) = read_gemini_credentials()?; let provider = GeminiProvider::new( GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled), ); return Ok(provider .translate_to( raw, target_language, working_languages, chinese_script_preference, output_language_preference, front_app, ) .await?); } let provider = build_active_llm_provider(llm_thinking_enabled)?; Ok(provider .translate_to( raw, target_language, working_languages, chinese_script_preference, output_language_preference, front_app, ) .await?) } /// "润色+翻译"单次调用的两段哨兵。模型按 `SRC\n源文\nTGT\n译文` 输出,解析器据此切分。 /// 这两个串必须与 build_polish_translate_system_prompt 写给模型的完全一致。 const POLISH_TRANSLATE_SRC_MARKER: &str = "[[OPENLESS_POLISHED_SOURCE]]"; const POLISH_TRANSLATE_TGT_MARKER: &str = "[[OPENLESS_TRANSLATION]]"; /// 合成"先润色源文、再翻译"的系统提示词:在原翻译 prompt 之上追加"额外输出润色后源文" /// 与严格两段格式(覆盖原 prompt 末尾的"只输出译文")。译文仍是要插入用户光标的主产物, /// 故完整保留原翻译规则;润色后的源文只作对话上下文用,轻量清理即可。 fn build_polish_translate_system_prompt(target_language: &str) -> String { let base = crate::polish::prompts::translate_system_prompt(target_language); format!( "{base}\n\n\ # 额外输出:润色后的源文(仅用于对话上下文,不展示给用户)\n\ 在译文之前,先把上面的原始转写**按它本来的语言**润色一遍:去掉口癖(嗯 / 那个 / um)、\ 补必要标点、纠正明显的识别错误,但**不翻译、不改写风格、不增删意思**。\n\n\ # 输出格式(覆盖上面\u{201C}只输出译文\u{201D}的说明,严格遵守)\n\ 严格按下面两段输出,两个标记必须原样出现、各占一行,标记之外不要有任何多余文字:\n\ {src}\n\ (这里放润色后的源文,保持原语言)\n\ {tgt}\n\ (这里放翻译成\u{300C}{lang}\u{300D}的译文)", base = base, src = POLISH_TRANSLATE_SRC_MARKER, tgt = POLISH_TRANSLATE_TGT_MARKER, lang = target_language, ) } /// 解析"润色+翻译"单次调用输出 → Some((润色后源文, 译文))。 /// 找到译文标记且译文非空 → Some((源文, 译文)):源文标记缺失 / 源文段为空时源文为 None, /// 译文取标记之后的干净正文。**没有译文标记、或译文段为空(模型截断 / 只吐了标记)→ None**, /// 表示没拿到可信译文,交由调用方退回专用翻译——避免把空串当"成功译文"插进光标而丢字。 fn split_polish_translate_output(raw: &str) -> Option<(Option, String)> { let tgt_idx = raw.find(POLISH_TRANSLATE_TGT_MARKER)?; let translation = raw[tgt_idx + POLISH_TRANSLATE_TGT_MARKER.len()..] .trim() .to_string(); if translation.is_empty() { return None; } let before_tgt = &raw[..tgt_idx]; let source = before_tgt .find(POLISH_TRANSLATE_SRC_MARKER) .map(|i| { before_tgt[i + POLISH_TRANSLATE_SRC_MARKER.len()..] .trim() .to_string() }) .filter(|s| !s.is_empty()); Some((source, translation)) } /// 翻译路径——单次 LLM 调用同时润色源文 + 翻译。和 polish 一样失败时返回原文 + 失败原因, /// 避免"不丢字"约定被违反(CLAUDE.md)。返回 (要插入的译文, 润色后源文供上下文用, 失败原因)。 #[allow(clippy::too_many_arguments)] async fn polish_and_translate_or_passthrough( raw: &RawTranscript, target_language: &str, mode: PolishMode, hotwords: &[String], working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, llm_thinking_enabled: bool, front_app: Option<&str>, prior_turns: &[(String, String)], ) -> (String, Option, Option) { let system_prompt = build_polish_translate_system_prompt(target_language); match polish_text( &raw.text, mode, hotwords, &system_prompt, working_languages, chinese_script_preference, output_language_preference, llm_thinking_enabled, front_app, prior_turns, ) .await { Ok(out) => match split_polish_translate_output(&out) { Some((source, translation)) => (translation, source, None), None => { // 模型没按两段格式输出:退回专用翻译拿一段干净译文,避免把畸形输出插进光标。 // 此时无可信源文,这条翻译历史不参与后续普通润色上下文。 log::warn!( "[coord] polish+translate output missing markers; falling back to plain translate" ); match translate_text( &raw.text, target_language, working_languages, chinese_script_preference, output_language_preference, llm_thinking_enabled, front_app, ) .await { Ok(translation) => (translation, None, None), Err(e) => { let reason = e.to_string(); log::error!("[coord] fallback translate failed, using raw: {reason}"); (raw.text.clone(), None, Some(reason)) } } } }, Err(e) => { let reason = e.to_string(); log::error!("[coord] polish+translate failed, falling back to raw: {reason}"); (raw.text.clone(), None, Some(reason)) } } } fn read_whisper_credentials() -> (String, String, String) { let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) .ok() .flatten() .unwrap_or_default(); let base_url = CredentialsVault::get(CredentialAccount::AsrEndpoint) .ok() .flatten() .unwrap_or_default(); let model = CredentialsVault::get(CredentialAccount::AsrModel) .ok() .flatten() .filter(|s| !s.is_empty()) .unwrap_or_else(|| "whisper-1".to_string()); (api_key, base_url, model) } fn read_bailian_credentials() -> BailianCredentials { let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) .ok() .flatten() .unwrap_or_default(); let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint) .ok() .flatten() .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| crate::asr::bailian::DEFAULT_ENDPOINT.to_string()); let model = CredentialsVault::get(CredentialAccount::AsrModel) .ok() .flatten() .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| crate::asr::bailian::DEFAULT_MODEL.to_string()); let vocabulary_id = CredentialsVault::get(CredentialAccount::AsrVocabularyId) .ok() .flatten() .filter(|s| !s.trim().is_empty()); BailianCredentials { api_key, endpoint, model, vocabulary_id, } } fn read_volc_credentials() -> VolcengineCredentials { let app_id = CredentialsVault::get(CredentialAccount::VolcengineAppKey) .ok() .flatten() .unwrap_or_default(); let access_token = CredentialsVault::get(CredentialAccount::VolcengineAccessKey) .ok() .flatten() .unwrap_or_default(); let resource_id = CredentialsVault::get(CredentialAccount::VolcengineResourceId) .ok() .flatten() .filter(|s| !s.is_empty()) .unwrap_or_else(|| VolcengineCredentials::default_resource_id().to_string()); VolcengineCredentials { app_id, access_token, resource_id, } } fn enabled_hotwords(inner: &Arc) -> Vec { inner .vocab .list() .unwrap_or_default() .into_iter() .map(|e| DictionaryHotword { phrase: e.phrase, enabled: e.enabled, }) .collect() } // ─────────────────────────── QA session lifecycle ─────────────────────────── /// 划词语音问答会话(issue #118)。 /// /// 与 dictation 完全分离: /// - 不进 SessionPhase(互不抢锁) /// - 不写 history.json(除非 prefs.qa_save_history=true 才旁路写一条 placeholder) /// - 用独立的 qa_recorder + qa_asr,复用现有 Volcengine ASR 通路 async fn begin_qa_session(inner: &Arc) -> Result<(), String> { { let mut state = inner.qa_state.lock(); if !state.panel_visible { // 防御:浮窗没开就被叫到这里说明路由错了,直接退出。 return Ok(()); } if state.phase != QaPhase::Idle { return Ok(()); } state.phase = QaPhase::Recording; state.cancelled = false; state.session_id = new_session_id(); state.front_app = capture_frontmost_app(); state.selection = None; } // 重置 SSE 取消标志:上一轮可能 set 过的 true 留着会让本轮流式立即 break。 inner.qa_stream_cancelled.store(false, Ordering::SeqCst); // 抓选区。每轮按 Option 都重新抓一次:用户多轮提问中可以重新选别处文字。 // // - macOS:浮窗走 orderFrontRegardless,不成为 key window,原 app 仍是 frontmost, // AX/Cmd+C fallback 都能拿到。 // - Windows:#466 修复后 show_qa_window_no_activate 主动抓焦点,QA 此刻已是前台, // simulate_copy 会跑在 QA 自己 webview 上 → 抓不到。focus-dance 上半场:把焦点临时 // 还给"用户原 app 的 HWND"。 // // 多轮场景的目标刷新:用户开 QA 后可能 Alt+Tab 切到别的 app 选新文字。如果还死认 // open_qa_panel 时记下的初始 HWND,会把焦点抢回错的 app(pr_agent stale-focus 关注点)。 // 策略:每轮先看当前前台是不是本进程的窗口(QA / capsule / main)—— 是 → 用户没切 // 走,沿用 saved;不是 → 用户切到了真正的外部 app,刷新 saved 为当前 HWND。 // 抓完选区后下半场再把焦点交还 QA,让 ESC/X 继续可用。 #[cfg(target_os = "windows")] { // 合并两次 lock:原来分 lock #1 写 + lock #2 读,两者之间 close_qa_panel 在别的 // 线程把 qa_focus_target 清成 None 会被覆盖回旧 HWND。Cloud 评审指出的 TOCTOU。 // 单次加锁里既写最新外部前台、再读出来交给后面的 restore_focus_target_if_possible // —— capture_external_focus_target() 内部只调 GetForegroundWindow / pid 查询, // 不会反向取 qa_state 锁,持锁期间调用安全。 let saved_target = { let mut state = inner.qa_state.lock(); if let Some(current_external) = capture_external_focus_target() { state.qa_focus_target = Some(current_external); } state.qa_focus_target }; let _ = restore_focus_target_if_possible(saved_target); } let selection = capture_selection(); #[cfg(target_os = "windows")] if let Some(app) = inner.app.lock().clone() { crate::refocus_qa_window(&app); } let selection_preview_text = selection.as_ref().map(|s| s.text.clone()); inner.qa_state.lock().selection = selection.clone(); if let Some(app) = inner.app.lock().clone() { let messages = inner.qa_state.lock().messages.clone(); let _ = app.emit_to( "qa", "qa:state", serde_json::json!({ "kind": "recording", "selection_preview": selection_preview_text, "messages": messages, }), ); } // 2. QA 与 dictation 使用同一个 active ASR 入口。不要回退火山,否则用户配置 // 百炼 / Whisper / 本地 ASR 后,浮窗仍会偷偷走另一套凭据。 let active_asr = CredentialsVault::get_active_asr(); if let Err(message) = ensure_asr_credentials() { log::warn!("[coord] QA: active ASR credentials missing: {message}"); finish_qa_with_error(inner, format!("缺少 ASR 凭据:{message}")); return Err(message); } if let Err(message) = ensure_microphone_permission(inner) { log::warn!("[coord] QA: microphone permission gate failed: {message}"); finish_qa_with_error(inner, message.clone()); return Err(message); } let qa_asr = match build_qa_asr_start(inner, &active_asr).await { Ok(qa_asr) => qa_asr, Err(message) => { log::error!("[coord] QA active ASR init failed: {message}"); finish_qa_with_error(inner, format!("ASR 初始化失败: {message}")); return Err(message); } }; let consumer = qa_asr.recorder_consumer(); *inner.qa_asr.lock() = Some(qa_asr.active_asr()); // QA recorder 不需要 RMS 节流到胶囊;前端 QA 浮窗有自己的电平视图, // 这里发一份事件给 "qa" label 用就够了。 let inner_for_level = Arc::clone(inner); let last_emit_at = Arc::new(Mutex::new(None::)); const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33; let level_handler: Arc = Arc::new(move |level| { let phase = inner_for_level.qa_state.lock().phase; if phase != QaPhase::Recording { return; } let now = Instant::now(); { let mut last = last_emit_at.lock(); if let Some(prev) = *last { if now.duration_since(prev).as_millis() < LEVEL_EMIT_MIN_INTERVAL_MS as u128 { return; } } *last = Some(now); } if let Some(app) = inner_for_level.app.lock().clone() { let _ = app.emit_to("qa", "qa:level", serde_json::json!({ "level": level })); } // 同步把电平推给底部胶囊,让 QA 录音也有跟主听写一致的可视反馈。 emit_capsule( &inner_for_level, CapsuleState::Recording, level, 0, None, None, ); }); let microphone_device_name = selected_microphone_device_name(inner); stop_microphone_preview_monitor(inner, "QA recorder"); acquire_recording_mute(inner, "qa").await; // QA 默认不留痕(qa_save_history 默认 false),录音文件归档也跟着不开。 // 调试 QA 麦克风请用主听写路径。 match Recorder::start(microphone_device_name, consumer, level_handler, None) { Ok((rec, runtime_errors, archive_active)) => { // QA 路径不写 dictation 的 history,但仍把 archive 状态归零,避免 dictation // 接力时读到上一个 QA session 的过期值。 inner .audio_archive_active .store(archive_active, std::sync::atomic::Ordering::Relaxed); *inner.qa_recorder.lock() = Some(rec); // QA 也跟主听写一样监听 cpal runtime error。设备中途消失 / panic 时 // 不能让 QA 永远卡在 Recording 没反馈。详见 issue #168。 spawn_qa_recorder_error_monitor(inner, runtime_errors); } Err(e) => { log::error!("[coord] QA recorder start failed: {e}"); if let Some(asr) = inner.qa_asr.lock().take() { cancel_active_asr(asr); } release_recording_mute(inner, "qa"); finish_qa_with_error(inner, format!("录音启动失败: {e}")); return Err(e.to_string()); } } if let Err(e) = qa_asr.open_streaming_session().await { log::error!("[coord] QA: open ASR session failed: {e}"); stop_qa_recorder(inner); if let Some(asr) = inner.qa_asr.lock().take() { cancel_active_asr(asr); } finish_qa_with_error(inner, format!("ASR 连接失败: {e}")); return Err(e); } // cancel race:在 await 期间用户可能 dismiss 了浮窗。 if inner.qa_state.lock().cancelled { log::info!("[coord] QA cancel raced during open_session — aborting begin"); if let Some(asr) = inner.qa_asr.lock().take() { cancel_active_asr(asr); } stop_qa_recorder(inner); inner.qa_state.lock().phase = QaPhase::Idle; return Ok(()); } // 显式弹胶囊到 Recording。level_handler 后续会持续推电平,胶囊里"录音中…" // 的视觉反馈跟主听写完全一致。 emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); Ok(()) } async fn end_qa_session(inner: &Arc) -> Result<(), String> { { let mut state = inner.qa_state.lock(); if state.phase != QaPhase::Recording { return Ok(()); } state.phase = QaPhase::Processing; } // 胶囊进入 Transcribing:用户视觉上看到"识别中"。 emit_capsule(inner, CapsuleState::Transcribing, 0.0, 0, None, None); if let Some(app) = inner.app.lock().clone() { let _ = app.emit_to("qa", "qa:state", serde_json::json!({ "kind": "loading" })); } stop_qa_recorder(inner); let asr = match inner.qa_asr.lock().take() { Some(a) => a, None => { inner.qa_state.lock().phase = QaPhase::Idle; return Ok(()); } }; #[cfg_attr(not(target_os = "windows"), allow(unused_variables))] let qa_session_id = inner.qa_state.lock().session_id; 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] QA: 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] QA: await final failed: {e}"); finish_qa_with_error(inner, format!("识别失败: {e}")); return Err(e.to_string()); } Err(_) => { log::error!( "[coord] QA: 全局超时 {} 秒 - 强制恢复", COORDINATOR_GLOBAL_TIMEOUT_SECS ); asr.cancel(); finish_qa_with_error(inner, "识别超时".to_string()); return Err("global timeout".to_string()); } } } ActiveAsr::Bailian(asr) => { debug_assert!(uses_global_timeout); if let Err(e) = asr.send_last_frame().await { log::error!("[coord] QA: 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] QA: Bailian await final failed: {e}"); finish_qa_with_error(inner, format!("识别失败: {e}")); return Err(e.to_string()); } Err(_) => { log::error!( "[coord] QA: Bailian 全局超时 {} 秒", COORDINATOR_GLOBAL_TIMEOUT_SECS ); asr.cancel(); finish_qa_with_error(inner, "识别超时".to_string()); return Err("bailian global timeout".to_string()); } } } ActiveAsr::Whisper(w) => { debug_assert!(uses_global_timeout); 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] QA: whisper transcribe failed: {e}"); finish_qa_with_error(inner, format!("识别失败: {e}")); return Err(e.to_string()); } Err(_) => { log::error!( "[coord] QA: whisper 全局超时 {} 秒", COORDINATOR_GLOBAL_TIMEOUT_SECS ); finish_qa_with_error(inner, "识别超时".to_string()); return Err("whisper 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::Qa(qa_session_id)); r } Err(e) => { schedule_foundry_local_asr_release(inner, AsrReleaseSession::Qa(qa_session_id)); if inner.qa_state.lock().cancelled { log::info!( "[coord] QA Foundry Local Whisper transcribe cancelled — discarding transcript" ); finish_qa_idle_silently(inner); return Ok(()); } log::error!("[coord] QA Foundry Local Whisper transcribe failed: {e:#}"); finish_qa_with_error(inner, format!("本地识别失败: {e}")); return Err(e.to_string()); } } } #[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::Qa(qa_session_id)); r } Err(e) => { schedule_sherpa_onnx_release(inner, AsrReleaseSession::Qa(qa_session_id)); if inner.qa_state.lock().cancelled { log::info!( "[coord] QA sherpa-onnx transcribe cancelled — discarding transcript" ); finish_qa_idle_silently(inner); return Ok(()); } log::error!("[coord] QA sherpa-onnx transcribe failed: {e:#}"); finish_qa_with_error(inner, format!("本地识别失败: {e}")); return Err(e.to_string()); } } } #[cfg(target_os = "macos")] ActiveAsr::Local(local) => { debug_assert!(uses_global_timeout); let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0; let timeout_duration = local_qwen_transcribe_timeout(audio_secs); log::info!( "[coord] QA 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] QA local Qwen3-ASR transcribe failed: {e:#}"); finish_qa_with_error(inner, format!("本地识别失败: {e}")); return Err(e.to_string()); } Err(_) => { log::error!( "[coord] QA local Qwen3-ASR transcribe timeout after {}s", timeout_duration.as_secs() ); finish_qa_with_error(inner, "本地识别超时".to_string()); return Err("local qwen transcribe timeout".to_string()); } } } }; // cancel race:用户在 transcribe 中按 Esc / dismiss → 静默退出。 if inner.qa_state.lock().cancelled { log::info!("[coord] QA cancel detected after ASR — discarding transcript"); finish_qa_idle_silently(inner); return Ok(()); } let question = raw.text.trim().to_string(); if question.is_empty() { // 静默录音:不调 LLM,不弹错误,直接关浮窗。 log::info!("[coord] QA: empty transcript → silent dismiss"); finish_qa_idle_silently(inner); return Ok(()); } // 拼这一轮的 user 消息:第一轮(messages 还空)把选区原文嵌进去; // 之后的轮次只送提问,让 LLM 顺着上下文回答。详见 issue #118 v2。 let user_content = { let st = inner.qa_state.lock(); let is_first_turn = st.messages.is_empty(); let sel_text = st .selection .as_ref() .map(|s| s.text.clone()) .unwrap_or_default(); if is_first_turn && !sel_text.trim().is_empty() { format!( "# 选区原文\n{}\n\n# 我的问题\n{}", sel_text.trim(), question ) } else { question.clone() } }; inner .qa_state .lock() .messages .push(crate::types::QaChatMessage { role: "user".to_string(), content: user_content, }); if let Some(app) = inner.app.lock().clone() { let messages = inner.qa_state.lock().messages.clone(); let _ = app.emit_to( "qa", "qa:state", serde_json::json!({ "kind": "thinking", "messages": messages, }), ); } // 胶囊:思考阶段(复用 dictation 的 Polishing 状态——视觉上是"润色中",QA 借用一下)。 emit_capsule(inner, CapsuleState::Polishing, 0.0, 0, None, None); let prefs = inner.prefs.get(); 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 (messages_for_llm, front_app) = { let st = inner.qa_state.lock(); (st.messages.clone(), st.front_app.clone()) }; // 流式回调:每个 SSE delta 立刻推一帧 qa:state{kind:"answer_delta"} 给前端, // 浮窗里气泡边收边长。最终的 messages 由 answer 事件统一下发(保证一致性)。 // // session_id 守卫(issue #161):闭包捕获本会话 id;用户取消 → 关浮窗 → 开新浮窗 // 开新一轮时,旧的 in-flight LLM 流仍可能 emit chunk,必须在 emit 前比对当前 // qa_state.session_id == 捕获 id,否则跳过——避免旧会话的字漏进新气泡。 let captured_session_id = inner.qa_state.lock().session_id; let inner_for_delta = Arc::clone(inner); let on_delta = move |chunk: &str| { let cur_id = inner_for_delta.qa_state.lock().session_id; if cur_id != captured_session_id { return; // 旧 session 漏来的 chunk,丢弃 } if let Some(app) = inner_for_delta.app.lock().clone() { let _ = app.emit_to( "qa", "qa:state", serde_json::json!({ "kind": "answer_delta", "chunk": chunk, }), ); } }; // SSE 流取消旗标:cancel_qa_session / close_qa_panel 会 set true, // polish 的 SSE loop 每帧检查 → break,释放 HTTP body。详见 issue #161。 let cancel_flag = Arc::clone(&inner.qa_stream_cancelled); let should_cancel = move || cancel_flag.load(Ordering::Relaxed); let answer = match answer_chat_dispatch( &messages_for_llm, &working_languages, chinese_script_preference, output_language_preference, llm_thinking_enabled, front_app.as_deref(), on_delta, should_cancel, ) .await { Ok(s) => s, Err(e) => { log::error!("[coord] QA: LLM answer failed: {e}"); // 把刚 push 的 user 消息回滚,避免 retry 重复 inner.qa_state.lock().messages.pop(); finish_qa_with_error(inner, format!("回答失败: {e}")); return Err(e.to_string()); } }; if inner.qa_state.lock().cancelled { log::info!("[coord] QA cancel detected before answer — discarding"); // 同样回滚未配对的 user 消息 inner.qa_state.lock().messages.pop(); finish_qa_idle_silently(inner); return Ok(()); } inner .qa_state .lock() .messages .push(crate::types::QaChatMessage { role: "assistant".to_string(), content: answer.clone(), }); if let Some(app) = inner.app.lock().clone() { let messages = inner.qa_state.lock().messages.clone(); let _ = app.emit_to( "qa", "qa:state", serde_json::json!({ "kind": "answer", "messages": messages, }), ); } // 胶囊直接收掉。QA 不走 insertion,没"已粘贴 N 字"语义;浮窗里答案就是用户的反馈。 // (之前用 Done 状态会被 capsule UI 错误地渲染上一次 dictation 残留的 message/insertedChars。) emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None); // 可选:写一条 history(QA 类型)。当前 DictationSession schema 不能直接表达 // "QuestionAnswer" 类型,因此简单做法:勾选 qa_save_history 时写一条 // mode=Raw、error_code=Some("qaSession") 的 placeholder,避免污染 schema 同时 // 让用户能在历史里翻到这次问答的字面值。详见 issue #118。 if prefs.qa_save_history { let session = DictationSession { id: Uuid::new_v4().to_string(), created_at: Utc::now().to_rfc3339(), raw_transcript: question.clone(), final_text: answer.clone(), mode: PolishMode::Raw, style_pack_id: None, translation_active: false, polish_source: None, app_bundle_id: None, app_name: front_app.clone(), insert_status: InsertStatus::CopiedFallback, error_code: Some("qaSession".to_string()), duration_ms: Some(raw.duration_ms), dictionary_entry_count: None, has_audio_recording: None, }; 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] QA history append failed: {e}"); } } inner.qa_state.lock().phase = QaPhase::Idle; Ok(()) } /// 把出错状态送到前端浮窗 + 胶囊错误闪一下 + 复位 phase。 /// 浮窗保持可见(v2:错误后用户可以再按 Option 重试);messages 一并送过去 /// 让前端继续渲染历史对话。 fn finish_qa_with_error(inner: &Arc, message: String) { stop_qa_recorder(inner); if let Some(app) = inner.app.lock().clone() { let messages = inner.qa_state.lock().messages.clone(); let _ = app.emit_to( "qa", "qa:state", serde_json::json!({ "kind": "error", "error": message, "messages": messages, }), ); } emit_capsule(inner, CapsuleState::Error, 0.0, 0, Some(message), None); schedule_capsule_idle(inner, 1500); let mut state = inner.qa_state.lock(); state.phase = QaPhase::Idle; state.cancelled = false; } /// 静默收尾:发 idle 事件给前端,phase 复位。**不关浮窗**(v2:浮窗只在用户 /// Esc/X 或再按 QA hotkey 时才关);多轮对话历史保留。胶囊也即刻收掉。 fn finish_qa_idle_silently(inner: &Arc) { if let Some(app) = inner.app.lock().clone() { let messages = inner.qa_state.lock().messages.clone(); let _ = app.emit_to( "qa", "qa:state", serde_json::json!({ "kind": "idle", "messages": messages, }), ); } emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None); let mut state = inner.qa_state.lock(); state.phase = QaPhase::Idle; state.cancelled = false; state.selection = None; } fn cancel_qa_session(inner: &Arc) { let phase = inner.qa_state.lock().phase; if phase == QaPhase::Idle { return; } inner.qa_state.lock().cancelled = true; // SSE 流取消旗标——polish::chat_completion_history_streaming 的 loop 每帧检查 // 这个 flag,true 时立即 break 不再 drain HTTP body,避免取消后 LLM 仍烧 token。 // 详见 issue #161。 inner.qa_stream_cancelled.store(true, Ordering::SeqCst); stop_qa_recorder(inner); if let Some(asr) = inner.qa_asr.lock().take() { cancel_active_asr(asr); } // Processing 阶段保持 phase 让 end_qa_session 自然走完 cancel 检查; // 否则直接复位。 if phase != QaPhase::Processing { inner.qa_state.lock().phase = QaPhase::Idle; } log::info!("[coord] QA session cancelled (was {phase:?})"); } async fn answer_chat_dispatch( messages: &[crate::types::QaChatMessage], working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, llm_thinking_enabled: bool, front_app: Option<&str>, on_delta: F, should_cancel: C, ) -> anyhow::Result where F: Fn(&str) + Send + Sync, C: Fn() -> bool + Send + Sync, { // 见 polish_text 顶部注释——同样的 Gemini / OpenAI-compatible 路由逻辑, // QA 流式回答走 Gemini 原生 :streamGenerateContent?alt=sse。 let active_llm = CredentialsVault::get_active_llm(); if active_llm == "gemini" { let (api_key, model, base_url) = read_gemini_credentials()?; let provider = GeminiProvider::new( GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled), ); return Ok(provider .answer_chat_streaming( messages, working_languages, chinese_script_preference, output_language_preference, front_app, on_delta, should_cancel, ) .await?); } let provider = build_active_llm_provider(llm_thinking_enabled)?; Ok(provider .answer_chat_streaming( messages, working_languages, chinese_script_preference, output_language_preference, front_app, on_delta, should_cancel, ) .await?) } /// 读 Gemini 凭据。所有 LLM provider 共用 ark.* 槽位(persistence 没做 per-provider /// 隔离),所以这里也是从 `ArkApiKey` / `ArkModelId` / `ArkEndpoint` 三个槽读, /// 但回退默认值改成谷歌的:base_url 默认 `https://generativelanguage.googleapis.com/v1beta`, /// 模型默认 `gemini-2.5-flash`。Settings.tsx::onLlmProviderChange 在用户切到 gemini /// 时会强制把 endpoint/model 覆盖为这两个默认值,所以 99% 情况下槽里读出来就是 /// 这两个;这里的 `unwrap_or_else` 是给极端情况兜底(如旧版本切换 bug 留下的脏数据)。 /// /// base_url 末尾去掉 `/`,让 `llm_gemini::generate_content_url` 拼接稳定。 /// 不去 `/chat/completions` 后缀——OpenAI 兼容路径才会有那个后缀,原生 Gemini 不会。 fn read_gemini_credentials() -> anyhow::Result<(String, String, String)> { let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default(); let model = CredentialsVault::get(CredentialAccount::ArkModelId)? .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "gemini-2.5-flash".to_string()); let base_url = CredentialsVault::get(CredentialAccount::ArkEndpoint)? .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string()); if api_key.trim().is_empty() { anyhow::bail!("API Key 为空"); } let base_url = base_url.trim_end_matches('/').to_string(); Ok((api_key, model, base_url)) } fn build_active_llm_provider(llm_thinking_enabled: bool) -> anyhow::Result { let active = CredentialsVault::get_active_llm(); let model = CredentialsVault::get(CredentialAccount::ArkModelId)?.filter(|s| !s.trim().is_empty()); if active == CODEX_OAUTH_PROVIDER_ID { let config = CodexOAuthConfig::new(model.unwrap_or_else(|| CODEX_DEFAULT_MODEL.to_string())) .with_thinking_enabled(llm_thinking_enabled); return Ok(ActiveLLMProvider::Codex(CodexOAuthLLMProvider::new(config))); } let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default(); let model = model.unwrap_or_else(|| "deepseek-v3-2".to_string()); let endpoint = resolve_ark_endpoint(&api_key)?; let base_url = endpoint .trim_end_matches("/chat/completions") .trim_end_matches('/') .to_string(); let config = OpenAICompatibleConfig::new(active, "OpenLess LLM", base_url, api_key, model) .with_thinking_enabled(llm_thinking_enabled); Ok(ActiveLLMProvider::OpenAI(OpenAICompatibleLLMProvider::new( config, ))) } fn resolve_ark_endpoint(api_key: &str) -> anyhow::Result { let endpoint = CredentialsVault::get(CredentialAccount::ArkEndpoint)?.filter(|s| !s.is_empty()); resolve_ark_endpoint_with_policy(api_key, endpoint) } fn resolve_ark_endpoint_with_policy( api_key: &str, endpoint: Option, ) -> anyhow::Result { if api_key.trim().is_empty() && endpoint.is_none() { anyhow::bail!("API Key 为空"); } Ok(endpoint .unwrap_or_else(|| "https://ark.cn-beijing.volces.com/api/v3/chat/completions".to_string())) } #[cfg(test)] mod tests { use super::dictation::abort_recording_with_error; use super::*; use crate::types::{HotkeyMode, HotkeyTrigger}; use once_cell::sync::Lazy; static ENV_LOCK: Lazy> = Lazy::new(|| tokio::sync::Mutex::new(())); fn session_id(n: u128) -> SessionId { Uuid::from_u128(n) } #[test] fn split_polish_translate_parses_both_sections() { let out = format!( "{POLISH_TRANSLATE_SRC_MARKER}\n你好,世界。\n{POLISH_TRANSLATE_TGT_MARKER}\nHello, world." ); let (source, translation) = split_polish_translate_output(&out).expect("both markers"); assert_eq!(source.as_deref(), Some("你好,世界。")); assert_eq!(translation, "Hello, world."); } #[test] fn split_polish_translate_no_translation_marker_returns_none_for_fallback() { // 完全没有译文标记 → None,调用方据此退回专用翻译拿干净译文。 assert_eq!(split_polish_translate_output(" Hello, world. "), None); } #[test] fn split_polish_translate_empty_translation_returns_none_for_fallback() { // 有译文标记但内容为空(截断 / 只吐标记)→ None,避免空串当成功译文插入光标。 let out = format!("{POLISH_TRANSLATE_SRC_MARKER}\n你好。\n{POLISH_TRANSLATE_TGT_MARKER}\n "); assert_eq!(split_polish_translate_output(&out), None); } #[test] fn split_polish_translate_only_translation_marker_keeps_clean_translation() { let out = format!("noise{POLISH_TRANSLATE_TGT_MARKER}\nHola"); let (source, translation) = split_polish_translate_output(&out).expect("tgt marker"); assert_eq!(source, None); assert_eq!(translation, "Hola"); } #[test] fn split_polish_translate_empty_source_section_is_none() { let out = format!("{POLISH_TRANSLATE_SRC_MARKER}\n \n{POLISH_TRANSLATE_TGT_MARKER}\nHi"); let (source, translation) = split_polish_translate_output(&out).expect("tgt marker"); assert_eq!(source, None); assert_eq!(translation, "Hi"); } #[test] fn build_polish_translate_prompt_contains_markers_and_target() { let p = build_polish_translate_system_prompt("日本語"); assert!(p.contains(POLISH_TRANSLATE_SRC_MARKER)); assert!(p.contains(POLISH_TRANSLATE_TGT_MARKER)); assert!(p.contains("日本語")); } #[tokio::test] async fn hotkey_injection_gate_logs_pressed_and_cancels() { let _ = env_logger::builder() .filter_level(log::LevelFilter::Info) .is_test(false) .try_init(); let _guard = ENV_LOCK.lock().await; std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); let coordinator = Coordinator::new(); coordinator.inject_hotkey_click_for_dev().await.unwrap(); assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); } #[tokio::test] async fn begin_session_dry_run_enters_listening_and_clears_stale_edges() { let _guard = ENV_LOCK.lock().await; std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); let coordinator = Coordinator::new(); let old_session_id = coordinator.inner.state.lock().session_id; { let mut state = coordinator.inner.state.lock(); state.pending_stop = true; state.cancelled = true; } coordinator.start_dictation().await.unwrap(); let state = coordinator.inner.state.lock(); assert_eq!(state.phase, SessionPhase::Listening); assert!(!state.pending_stop); assert!(!state.cancelled); assert_ne!(state.session_id, old_session_id); std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); } #[tokio::test] async fn begin_session_ignores_non_idle_phase() { let _guard = ENV_LOCK.lock().await; std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); let coordinator = Coordinator::new(); let old_session_id = { let mut state = coordinator.inner.state.lock(); state.phase = SessionPhase::Processing; state.session_id = session_id(99); state.session_id }; coordinator.start_dictation().await.unwrap(); let state = coordinator.inner.state.lock(); assert_eq!(state.phase, SessionPhase::Processing); assert_eq!(state.session_id, old_session_id); std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); } #[test] fn window_key_matcher_mirrors_windows_trigger_aliases() { let cases = [ (HotkeyTrigger::RightControl, "Control", "ControlRight"), (HotkeyTrigger::LeftControl, "Control", "ControlLeft"), (HotkeyTrigger::RightOption, "Alt", "AltRight"), (HotkeyTrigger::RightAlt, "AltGraph", "AltRight"), (HotkeyTrigger::RightCommand, "Meta", "MetaRight"), (HotkeyTrigger::LeftOption, "Alt", "AltLeft"), // Mirrors Windows trigger_to_vk_code aliases. (HotkeyTrigger::Fn, "Control", "ControlRight"), ]; for (trigger, key, code) in cases { assert!( window_key_matches_trigger(trigger, key, code), "{trigger:?} should match {key}/{code}" ); } assert!(!window_key_matches_trigger( HotkeyTrigger::RightControl, "Control", "ControlLeft" )); assert!(!window_key_matches_trigger( HotkeyTrigger::LeftOption, "Alt", "AltRight" )); assert!(!window_key_matches_trigger(HotkeyTrigger::Fn, "Fn", "Fn")); } #[test] fn windows_local_providers_are_keyless_and_not_whisper_compatible() { #[cfg(target_os = "windows")] assert!(is_keyless_local_asr_provider( crate::asr::local::foundry::PROVIDER_ID )); #[cfg(target_os = "windows")] assert!(is_keyless_local_asr_provider( crate::asr::local::sherpa::PROVIDER_ID )); #[cfg(not(target_os = "windows"))] assert!(!is_keyless_local_asr_provider( crate::asr::local::foundry::PROVIDER_ID )); #[cfg(not(target_os = "windows"))] assert!(!is_keyless_local_asr_provider( crate::asr::local::sherpa::PROVIDER_ID )); assert!(!is_whisper_compatible_provider( crate::asr::local::foundry::PROVIDER_ID )); assert!(!is_whisper_compatible_provider( crate::asr::local::sherpa::PROVIDER_ID )); } #[test] fn verbose_json_enabled_only_for_whisper_family() { // verbose_json + 幻听过滤只对返回完整 Whisper 指标的 provider 开启。 assert!(whisper_supports_verbose_json("whisper")); assert!(whisper_supports_verbose_json("groq")); // SiliconFlow(SenseVoice/TeleSpeech) / Zhipu(GLM-ASR) 保持旧的 json 行为。 assert!(!whisper_supports_verbose_json("siliconflow")); assert!(!whisper_supports_verbose_json("zhipu")); } #[test] fn qa_asr_provider_kind_tracks_active_provider() { assert_eq!( active_asr_provider_kind(crate::asr::bailian::PROVIDER_ID), ActiveAsrProviderKind::Bailian ); assert_eq!( active_asr_provider_kind("whisper"), ActiveAsrProviderKind::WhisperCompatible ); assert_eq!( active_asr_provider_kind("volcengine"), ActiveAsrProviderKind::Volcengine ); } #[cfg(target_os = "windows")] #[test] fn coordinator_shares_app_foundry_runtime() { let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new()); let coordinator = Coordinator::new_with_foundry_runtime(Arc::clone(&runtime)); assert!(Arc::ptr_eq( &runtime, &coordinator.inner.foundry_local_runtime )); } #[cfg(target_os = "windows")] #[test] fn foundry_transcribe_skips_global_timeout_for_first_run_provisioning() { let provider = Arc::new(crate::asr::local::FoundryLocalWhisperAsr::new( Arc::new(crate::asr::local::FoundryLocalRuntime::new()), crate::asr::local::foundry::DEFAULT_MODEL_ALIAS.to_string(), "auto".to_string(), None, )); let active_asr = ActiveAsr::FoundryLocalWhisper(provider); assert!(!asr_transcribe_uses_global_timeout(&active_asr)); } #[cfg(target_os = "windows")] #[test] fn foundry_audio_transcribe_timeout_is_separate_from_prepare() { let timeout = foundry_audio_transcribe_timeout_duration(); assert_eq!( timeout, std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) ); } #[test] fn local_qwen_timeout_floors_at_global_timeout_for_short_audio() { // 5s 录音:5 × 0.6 = 3, +10 = 13, max(15) = 15。短录音保留 15s 兜底。 assert_eq!( local_qwen_transcribe_timeout(5.0), std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) ); } #[test] fn local_qwen_timeout_scales_with_audio_duration() { // 60s 录音:60 × 0.6 = 36, +10 = 46s。覆盖 RTF ≈ 0.5 的边界。 assert_eq!( local_qwen_transcribe_timeout(60.0), std::time::Duration::from_secs(46) ); } #[test] fn local_qwen_timeout_ceils_partial_seconds() { // 10.1s 录音:10.1 × 0.6 = 6.06, ceil = 7, +10 = 17, max(15) = 17。 assert_eq!( local_qwen_transcribe_timeout(10.1), std::time::Duration::from_secs(17) ); } #[test] fn local_qwen_timeout_handles_zero_duration() { // 0 时长(空 buffer 边界):0 × 0.6 = 0, +10 = 10, max(15) = 15。 assert_eq!( local_qwen_transcribe_timeout(0.0), std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) ); } #[cfg(target_os = "windows")] #[test] fn foundry_release_uses_foundry_keep_loaded_preference() { let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new()); let coordinator = Coordinator::new_with_foundry_runtime(runtime); let mut prefs = coordinator.inner.prefs.get(); prefs.local_asr_keep_loaded_secs = 3; prefs.foundry_local_asr_keep_loaded_secs = 7; coordinator.inner.prefs.set(prefs).unwrap(); assert_eq!(foundry_local_asr_release_keep_secs(&coordinator.inner), 7); } #[cfg(target_os = "windows")] #[test] fn foundry_release_guard_rejects_stale_dictation_session() { let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new()); let coordinator = Coordinator::new_with_foundry_runtime(runtime); let old_session_id = coordinator.inner.state.lock().session_id; assert!(asr_release_session_is_current( &coordinator.inner, AsrReleaseSession::Dictation(old_session_id) )); coordinator.inner.state.lock().session_id = new_session_id(); assert!(!asr_release_session_is_current( &coordinator.inner, AsrReleaseSession::Dictation(old_session_id) )); } #[cfg(target_os = "windows")] #[test] fn local_asr_release_guard_rejects_stale_qa_session() { let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new()); let coordinator = Coordinator::new_with_foundry_runtime(runtime); let old_session_id = coordinator.inner.qa_state.lock().session_id; assert!(asr_release_session_is_current( &coordinator.inner, AsrReleaseSession::Qa(old_session_id) )); coordinator.inner.qa_state.lock().session_id = new_session_id(); assert!(!asr_release_session_is_current( &coordinator.inner, AsrReleaseSession::Qa(old_session_id) )); } #[test] fn resolve_ark_endpoint_rejects_blank_key_without_custom_endpoint() { assert_eq!( resolve_ark_endpoint_with_policy("", None) .unwrap_err() .to_string(), "API Key 为空" ); } #[test] fn resolve_ark_endpoint_allows_blank_key_with_custom_endpoint() { let endpoint = resolve_ark_endpoint_with_policy( "", Some("https://example.com/v1/chat/completions".to_string()), ) .unwrap(); assert_eq!(endpoint, "https://example.com/v1/chat/completions"); } #[test] fn deferred_asr_bridge_flushes_startup_audio_before_live_chunks() { #[derive(Default)] struct RecordingConsumer { bytes: Mutex>, } impl crate::asr::AudioConsumer for RecordingConsumer { fn consume_pcm_chunk(&self, pcm: &[u8]) { self.bytes.lock().extend_from_slice(pcm); } } let bridge = DeferredAsrBridge::new(); crate::recorder::AudioConsumer::consume_pcm_chunk(&bridge, &[1, 2]); crate::recorder::AudioConsumer::consume_pcm_chunk(&bridge, &[3, 4]); let target = Arc::new(RecordingConsumer::default()); let target_for_attach: Arc = target.clone(); assert_eq!(bridge.attach(target_for_attach), 4); crate::recorder::AudioConsumer::consume_pcm_chunk(&bridge, &[5, 6]); assert_eq!(&*target.bytes.lock(), &[1, 2, 3, 4, 5, 6]); } #[tokio::test] async fn manual_stop_during_starting_is_queued() { let coordinator = Coordinator::new(); { let mut state = coordinator.inner.state.lock(); state.phase = SessionPhase::Starting; state.pending_stop = false; } coordinator.stop_dictation().await.unwrap(); let state = coordinator.inner.state.lock(); assert_eq!(state.phase, SessionPhase::Starting); assert!(state.pending_stop); } #[tokio::test] async fn stop_dictation_from_listening_without_asr_returns_idle() { let coordinator = Coordinator::new(); { let mut state = coordinator.inner.state.lock(); state.phase = SessionPhase::Listening; state.session_id = session_id(123); } coordinator.stop_dictation().await.unwrap(); assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); } #[test] fn cancel_session_state_machine_is_table_driven() { let cases = [ (SessionPhase::Idle, SessionPhase::Idle, false), (SessionPhase::Starting, SessionPhase::Idle, true), (SessionPhase::Listening, SessionPhase::Idle, true), (SessionPhase::Processing, SessionPhase::Processing, true), (SessionPhase::Inserting, SessionPhase::Inserting, false), ]; for (initial, expected_phase, expected_cancelled) in cases { let coordinator = Coordinator::new(); { let mut state = coordinator.inner.state.lock(); state.phase = initial; state.cancelled = false; state.focus_target = Some(1); } coordinator.cancel_dictation(); let state = coordinator.inner.state.lock(); assert_eq!(state.phase, expected_phase, "initial={initial:?}"); assert_eq!(state.cancelled, expected_cancelled, "initial={initial:?}"); if matches!(initial, SessionPhase::Starting | SessionPhase::Listening) { assert!(state.focus_target.is_none(), "initial={initial:?}"); } } } #[test] fn recorder_runtime_error_aborts_active_session() { let coordinator = Coordinator::new(); { let mut state = coordinator.inner.state.lock(); state.phase = SessionPhase::Listening; state.cancelled = false; } abort_recording_with_error(&coordinator.inner, "录音中断: stream failed".to_string()); let state = coordinator.inner.state.lock(); assert_eq!(state.phase, SessionPhase::Idle); assert!(state.cancelled); assert!(coordinator.inner.recorder.lock().is_none()); assert!(coordinator.inner.asr.lock().is_none()); } #[test] fn abort_recording_keeps_session_non_idle_until_restore_can_run() { let mut state = SessionState::default(); state.phase = SessionPhase::Listening; state.cancelled = false; state.session_id = session_id(7); let abort = begin_recording_abort_before_restore(&mut state).unwrap(); assert_eq!(abort.session_id, session_id(7)); assert!(state.cancelled); assert_eq!(state.phase, SessionPhase::Listening); publish_abort_idle_after_restore(&mut state, abort.session_id); assert_eq!(state.phase, SessionPhase::Idle); } #[tokio::test] async fn pressed_edge_during_inserting_does_not_start_new_session() { let coordinator = Coordinator::new(); { let mut state = coordinator.inner.state.lock(); state.phase = SessionPhase::Inserting; state.session_id = session_id(41); } handle_pressed_edge(&coordinator.inner).await; let state = coordinator.inner.state.lock(); assert_eq!(state.phase, SessionPhase::Inserting); assert_eq!(state.session_id, session_id(41)); } #[tokio::test] async fn repeated_pressed_edge_during_hold_session_does_not_restart() { let coordinator = Coordinator::new(); coordinator .inner .prefs .set(crate::types::UserPreferences { hotkey: crate::types::HotkeyBinding { trigger: HotkeyTrigger::RightControl, mode: HotkeyMode::Hold, keys: None, }, ..Default::default() }) .unwrap(); coordinator.inner.state.lock().phase = SessionPhase::Listening; coordinator .inner .hotkey_trigger_held .store(true, Ordering::SeqCst); handle_pressed_edge(&coordinator.inner).await; assert_eq!( coordinator.inner.state.lock().phase, SessionPhase::Listening ); assert!(coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); } #[test] fn enabling_shortcut_recording_clears_dictation_hold_latch() { let coordinator = Coordinator::new(); coordinator .inner .hotkey_trigger_held .store(true, Ordering::SeqCst); coordinator.set_shortcut_recording_active(true); assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); } #[test] fn window_hotkey_fallback_is_disabled_when_no_explicit_fallback_is_advertised() { assert_eq!( window_hotkey_fallback_enabled(), crate::types::HotkeyCapability::current().explicit_fallback_available ); } #[test] fn capsule_show_strategy_matches_platform_activation_contract() { // 平台列表必须与 capsule_show_strategy_for_platform 的 cfg 完全一致: // 改实现里的 #[cfg] 时,一并改这两个 #[cfg],否则 Linux CI 直接红 // (fcitx5 PR #451 把 Linux 加进 NoActivate 但漏改本测试,CI 失败)。 #[cfg(any(target_os = "macos", target_os = "windows"))] assert_eq!( capsule_show_strategy_for_platform(), CapsuleShowStrategy::NoActivate ); #[cfg(not(any(target_os = "macos", target_os = "windows")))] assert_eq!( capsule_show_strategy_for_platform(), CapsuleShowStrategy::FallbackShow ); } #[test] #[cfg(target_os = "windows")] fn prepared_windows_ime_slot_is_taken_only_for_matching_session() { let mut slots = vec![PreparedWindowsImeSessionSlot { session_id: session_id(2), prepared: PreparedWindowsImeSession::unavailable(), }]; assert!(take_matching_prepared_windows_ime_session(&mut slots, session_id(1)).is_none()); assert_eq!( slots.iter().map(|slot| slot.session_id).collect::>(), vec![session_id(2)] ); assert!(take_matching_prepared_windows_ime_session(&mut slots, session_id(2)).is_some()); assert!(slots.is_empty()); } #[test] #[cfg(target_os = "windows")] fn prepared_windows_ime_sessions_keep_overlapping_snapshots() { let mut slots = Vec::new(); store_prepared_windows_ime_session( &mut slots, session_id(1), PreparedWindowsImeSession::unavailable(), ); store_prepared_windows_ime_session( &mut slots, session_id(2), PreparedWindowsImeSession::unavailable(), ); assert_eq!( slots.iter().map(|slot| slot.session_id).collect::>(), vec![session_id(1), session_id(2)] ); assert!(take_matching_prepared_windows_ime_session(&mut slots, session_id(1)).is_some()); assert_eq!( slots.iter().map(|slot| slot.session_id).collect::>(), vec![session_id(2)] ); } #[test] #[cfg(target_os = "windows")] fn stale_prepared_windows_ime_restore_discards_old_snapshot_without_restoring() { let mut slots = Vec::new(); store_prepared_windows_ime_session( &mut slots, session_id(1), PreparedWindowsImeSession::unavailable(), ); store_prepared_windows_ime_session( &mut slots, session_id(2), PreparedWindowsImeSession::unavailable(), ); assert!(take_current_prepared_windows_ime_session_for_restore( &mut slots, session_id(1), session_id(2) ) .is_none()); assert_eq!( slots.iter().map(|slot| slot.session_id).collect::>(), vec![session_id(2)] ); } #[test] #[cfg(target_os = "windows")] fn non_tsf_insertion_fallback_gate_blocks_only_when_disabled() { assert!(should_try_non_tsf_insertion_fallback( true, InsertStatus::CopiedFallback )); assert!(should_try_non_tsf_insertion_fallback( true, InsertStatus::Failed )); assert!(!should_try_non_tsf_insertion_fallback( true, InsertStatus::Inserted )); assert!(!should_try_non_tsf_insertion_fallback( false, InsertStatus::CopiedFallback )); assert!(!should_try_non_tsf_insertion_fallback( false, InsertStatus::Failed )); } #[test] fn focus_restore_failure_uses_specific_error_code_when_insert_fails() { assert_eq!( dictation_error_code(InsertStatus::Failed, false, false, false), Some("focusRestoreFailed") ); } #[test] #[cfg(target_os = "windows")] fn missing_windows_hwnd_is_not_present() { use windows::Win32::Foundation::HWND; assert!(!windows_hwnd_is_present(HWND::default())); } #[test] #[cfg(target_os = "windows")] fn tsf_required_failure_keeps_tsf_error_when_focus_was_ready() { assert_eq!( dictation_error_code(InsertStatus::Failed, false, true, false), Some("windowsImeTsfRequired") ); } #[test] fn startup_race_check_treats_newer_session_as_stale() { let mut state = SessionState::default(); state.phase = SessionPhase::Starting; state.cancelled = false; state.session_id = session_id(2); assert_eq!( startup_race_status(&state, session_id(1)), StartupRaceStatus::StaleContinuation ); } #[test] fn startup_race_check_is_table_driven_for_begin_session_edges() { let cases = [ ( SessionPhase::Starting, false, session_id(7), StartupRaceStatus::ActiveStarting, ), ( SessionPhase::Starting, true, session_id(7), StartupRaceStatus::CancelRaced, ), ( SessionPhase::Idle, false, session_id(7), StartupRaceStatus::CancelRaced, ), ( SessionPhase::Listening, false, session_id(7), StartupRaceStatus::CancelRaced, ), ( SessionPhase::Starting, false, session_id(8), StartupRaceStatus::StaleContinuation, ), ]; for (phase, cancelled, actual_session_id, expected) in cases { let mut state = SessionState::default(); state.phase = phase; state.cancelled = cancelled; state.session_id = actual_session_id; assert_eq!( startup_race_status(&state, session_id(7)), expected, "phase={phase:?} cancelled={cancelled} actual_session={actual_session_id}" ); } } #[test] fn begin_recording_abort_is_noop_after_prior_cancel_or_idle() { let cases = [ (SessionPhase::Idle, false), (SessionPhase::Processing, false), (SessionPhase::Listening, true), ]; for (phase, cancelled) in cases { let mut state = SessionState::default(); state.phase = phase; state.cancelled = cancelled; assert!(begin_recording_abort_before_restore(&mut state).is_none()); assert_eq!(state.phase, phase); assert_eq!(state.cancelled, cancelled); } } #[test] fn stale_startup_cleanup_keeps_newer_asr_resource() { let coordinator = Coordinator::new(); let newer_asr = Arc::new(WhisperBatchASR::new( "key".to_string(), "http://localhost".to_string(), "model".to_string(), None, None, false, )); *coordinator.inner.asr.lock() = Some(SessionResource::new( session_id(2), ActiveAsr::Whisper(Arc::clone(&newer_asr)), )); discard_startup_resources_for_session(&coordinator.inner, session_id(1)); assert_eq!( coordinator .inner .asr .lock() .as_ref() .map(|resource| resource.session_id), Some(session_id(2)) ); discard_startup_resources_for_session(&coordinator.inner, session_id(2)); assert!(coordinator.inner.asr.lock().is_none()); } } fn enabled_phrases(inner: &Arc) -> Vec { inner .vocab .list() .unwrap_or_default() .into_iter() .filter(|e| e.enabled) .map(|e| e.phrase) .collect() } /// 终止态(Done / Cancelled / Error)后延迟 N ms 把胶囊改回 Idle,让浮窗自动消失。 /// 用户点 ✕ / ✓ / 中途出错 / 按 Esc 都走这里,统一 2 秒。 const CAPSULE_AUTO_HIDE_DELAY_MS: u64 = 2000; /// Toggle 模式下,end_session 将 phase 设为 Idle 后在此时间内禁止新的 begin_session。 /// 避免用户三连按时第 3 次按下误激活新听写(此时胶囊仍在离场动画周期内)。 /// 值取 capsule EXIT_ANIM_MS (360ms) + 余量 ≈ 600ms。 const POST_SESSION_COOLDOWN_MS: u64 = 600; /// Coordinator 全局超时保护:防止 ASR await_final_result() 永远挂起。 /// 设置为 15 秒(比 ASR 的 12 秒 FINAL_RESULT_TIMEOUT 稍长), /// 只在 ASR 超时机制失效时作为最后的防线触发。 const COORDINATOR_GLOBAL_TIMEOUT_SECS: u64 = 15; #[cfg(target_os = "windows")] fn foundry_audio_transcribe_timeout_duration() -> std::time::Duration { std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) } /// 本地 Qwen3-ASR 的动态转写超时。固定 15 秒在长录音(≥ 30s)+ 慢机器 /// (RTF ≈ 0.3–0.5)上必然超时把整段内容丢掉。改用 max(15, ceil(audio_s /// × 0.6) + 10):基础保留 15s 兜住短录音;长录音按音频长度的 0.6 倍 + /// 10s 余量,覆盖 RTF ≤ 0.5 的机器。 fn local_qwen_transcribe_timeout(audio_secs: f64) -> std::time::Duration { let secs = ((audio_secs * 0.6).ceil() as u64) .saturating_add(10) .max(COORDINATOR_GLOBAL_TIMEOUT_SECS); std::time::Duration::from_secs(secs) } /// sherpa-onnx offline batch 暂与 Foundry 同档;后续按 Windows 真机 CPU/模型 /// 实测结果再调整。 #[cfg(target_os = "windows")] fn sherpa_audio_transcribe_timeout_duration() -> std::time::Duration { std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) } /// 检查 begin_session 的 await 间隙是否被 cancel_session 打断。 /// 必须在持有 state lock 的瞬间读,结果一拿就过期,所以用 helper 名字提醒只在 /// 「准备做下一步副作用前」用。 fn startup_race_status_for_starting( inner: &Arc, captured_session_id: SessionId, ) -> StartupRaceStatus { let state = inner.state.lock(); startup_race_status(&state, captured_session_id) } fn set_phase_idle_if_session_matches(inner: &Arc, session_id: SessionId) { let mut state = inner.state.lock(); if state.session_id == session_id { state.phase = SessionPhase::Idle; } } fn schedule_capsule_idle(inner: &Arc, delay_ms: u64) { let inner_clone = Arc::clone(inner); async_runtime::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; // 必须 dictation **和** QA 同时空闲才能隐藏胶囊。否则旧 dictation Done timer // 的尾巴会在新 QA 录音/思考中把胶囊意外收掉(issue #118 v2 复现)。 let dictation_idle = inner_clone.state.lock().phase == SessionPhase::Idle; let qa_idle = inner_clone.qa_state.lock().phase == QaPhase::Idle; if dictation_idle && qa_idle { emit_capsule(&inner_clone, CapsuleState::Idle, 0.0, 0, None, None); } }); } /// 与 capture_focus_target 类似,但前台窗口属于本进程(即用户停在 QA / capsule / main /// 等自家窗口)时返回 None,让 caller 区分"用户没切到别处" vs "用户切到了另一个真正的 /// 外部 app"。issue #466 多轮场景下用来刷新 qa_focus_target。 #[cfg(target_os = "windows")] fn capture_external_focus_target() -> Option { use windows::Win32::System::Threading::GetCurrentProcessId; use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowThreadProcessId}; unsafe { let hwnd = GetForegroundWindow(); if hwnd.0.is_null() { return None; } let mut pid: u32 = 0; GetWindowThreadProcessId(hwnd, Some(&mut pid)); if pid == GetCurrentProcessId() { return None; } Some(hwnd.0 as usize) } } #[cfg(not(target_os = "windows"))] fn capture_external_focus_target() -> Option { None } #[cfg(target_os = "windows")] fn capture_focus_target() -> Option { use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow; let foreground = unsafe { GetForegroundWindow() }; if foreground.0.is_null() { None } else { Some(foreground.0 as usize) } } #[cfg(not(target_os = "windows"))] fn capture_focus_target() -> Option { None } /// 捕获用户开始 dictation 时的前台 app 标签("localizedName (bundle.id)"),用作 LLM /// polish/translate 的上下文前提,让模型按 app 调风格。详见 issue #116。 /// /// macOS 走 NSWorkspace.frontmostApplication(公开 API,无需额外权限); /// Windows 复用前台 HWND 拿窗口标题;Linux/其他平台返回 None。 #[cfg(target_os = "macos")] fn capture_frontmost_app() -> Option { use objc2::msg_send; use objc2::runtime::{AnyClass, AnyObject}; unsafe { let cls = AnyClass::get("NSWorkspace")?; let workspace: *mut AnyObject = msg_send![cls, sharedWorkspace]; if workspace.is_null() { return None; } let app: *mut AnyObject = msg_send![workspace, frontmostApplication]; if app.is_null() { return None; } let name_obj: *mut AnyObject = msg_send![app, localizedName]; let bundle_obj: *mut AnyObject = msg_send![app, bundleIdentifier]; let name = nsstring_to_string(name_obj); let bundle = nsstring_to_string(bundle_obj); match (name, bundle) { (Some(n), Some(b)) => Some(format!("{n} ({b})")), (Some(n), None) => Some(n), (None, Some(b)) => Some(b), (None, None) => None, } } } #[cfg(target_os = "macos")] unsafe fn nsstring_to_string(ns_string: *mut objc2::runtime::AnyObject) -> Option { use objc2::msg_send; if ns_string.is_null() { return None; } let utf8: *const std::os::raw::c_char = unsafe { msg_send![ns_string, UTF8String] }; if utf8.is_null() { return None; } let cstr = unsafe { std::ffi::CStr::from_ptr(utf8) }; let s = cstr.to_string_lossy().into_owned(); if s.is_empty() { None } else { Some(s) } } #[cfg(target_os = "windows")] fn capture_frontmost_app() -> Option { use windows::Win32::UI::WindowsAndMessaging::{ GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW, }; unsafe { let hwnd = GetForegroundWindow(); if hwnd.0.is_null() { return None; } let len = GetWindowTextLengthW(hwnd); if len <= 0 { return None; } let mut buf = vec![0u16; (len + 1) as usize]; let copied = GetWindowTextW(hwnd, &mut buf); if copied <= 0 { return None; } let title = String::from_utf16_lossy(&buf[..copied as usize]); if title.is_empty() { None } else { Some(title) } } } #[cfg(not(any(target_os = "macos", target_os = "windows")))] fn capture_frontmost_app() -> Option { None } #[cfg(target_os = "windows")] fn restore_focus_target_if_possible(target: Option) -> bool { use std::ffi::c_void; use windows::Win32::Foundation::HWND; use windows::Win32::UI::WindowsAndMessaging::{ GetForegroundWindow, IsIconic, IsWindow, SetForegroundWindow, ShowWindow, SW_RESTORE, }; let Some(raw_target) = target else { log::warn!("[coord] no original Windows insertion target captured"); return false; }; let hwnd = HWND(raw_target as *mut c_void); if hwnd.0.is_null() { return false; } if !unsafe { IsWindow(hwnd).as_bool() } { log::warn!("[coord] original Windows insertion target is no longer a valid window"); return false; } let foreground = unsafe { GetForegroundWindow() }; if foreground == hwnd { return true; } if unsafe { IsIconic(hwnd).as_bool() } { let _ = unsafe { ShowWindow(hwnd, SW_RESTORE) }; } let _ = unsafe { SetForegroundWindow(hwnd) }; std::thread::sleep(std::time::Duration::from_millis(60)); let foreground = unsafe { GetForegroundWindow() }; if foreground != hwnd { log::warn!("[coord] failed to restore original Windows insertion target before paste"); return false; } true } #[cfg(not(target_os = "windows"))] fn restore_focus_target_if_possible(_target: Option) -> bool { true } #[cfg(target_os = "windows")] fn windows_hwnd_is_present(hwnd: windows::Win32::Foundation::HWND) -> bool { hwnd != windows::Win32::Foundation::HWND::default() } #[cfg(target_os = "windows")] fn capture_ime_submit_target() -> Option { use windows::Win32::UI::WindowsAndMessaging::{ GetForegroundWindow, GetGUIThreadInfo, GetWindowThreadProcessId, GUITHREADINFO, }; let foreground = unsafe { GetForegroundWindow() }; if !windows_hwnd_is_present(foreground) { return None; } let mut foreground_process_id = 0; let foreground_thread_id = unsafe { GetWindowThreadProcessId(foreground, Some(&mut foreground_process_id)) }; if foreground_thread_id == 0 { return None; } let mut gui_info = GUITHREADINFO { cbSize: std::mem::size_of::() as u32, ..Default::default() }; let target_window = if unsafe { GetGUIThreadInfo(foreground_thread_id, &mut gui_info).is_ok() } && windows_hwnd_is_present(gui_info.hwndFocus) { gui_info.hwndFocus } else { foreground }; let mut process_id = 0; let thread_id = unsafe { GetWindowThreadProcessId(target_window, Some(&mut process_id)) }; if process_id == 0 || thread_id == 0 { return None; } Some(ImeSubmitTarget { process_id, thread_id, }) } // Windows topmost overlay 的已知 OS 级限制(issue #457): // `SetWindowPos(HWND_TOPMOST)` 让 capsule 在普通桌面合成、最大化窗口、borderless // windowed fullscreen 上正常叠加;但**对独占全屏(exclusive fullscreen)DirectX / // OpenGL 应用无效** —— 那条路径绕过桌面合成器,标准 topmost 窗口不参与合成 → // 用户看不见 capsule。这是 OS 层面的限制,用户空间无法绕过(除非接入 DirectX // overlay,工程量与风险都不在 surgical 修复范围内)。 // // 用户侧 workaround:把游戏切到 borderless windowed fullscreen(Minecraft Java 默认 // 即是;F11 在不同版本表现不一致,按设置里的「全屏」选项决定)。 // // 相关 UIPI 限制:若游戏以管理员身份运行而 OpenLess 不是,`WH_KEYBOARD_LL` 收不到 // 游戏的按键 → hotkey 完全不触发。这里跟 SetWindowPos 路径无关,但同源不可绕过。 #[cfg(target_os = "windows")] fn show_capsule_window_no_activate( _app: &AppHandle, window: &tauri::WebviewWindow, ) -> bool { use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use windows::Win32::Foundation::HWND; use windows::Win32::UI::WindowsAndMessaging::{ SetWindowPos, ShowWindow, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_SHOWWINDOW, SW_SHOWNOACTIVATE, }; let Ok(handle) = window.window_handle() else { return false; }; let RawWindowHandle::Win32(raw) = handle.as_raw() else { return false; }; let hwnd = HWND(raw.hwnd.get() as *mut _); let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) }; let _ = unsafe { SetWindowPos( hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW, ) }; true } #[cfg(target_os = "macos")] fn show_capsule_window_no_activate( _app: &AppHandle, window: &tauri::WebviewWindow, ) -> bool { use objc2::msg_send; use objc2::runtime::AnyObject; let Ok(handle) = window.ns_window() else { return false; }; let ns_window = handle as *mut AnyObject; if ns_window.is_null() { return false; } // emit_capsule 已经把窗口操作 marshal 到 Tauri 主线程;这里不能再调用 // window.show()/set_focus()/NSApp.activate,否则 AeroSpace 会把 workspace 切回 // OpenLess 主窗口所在空间。先让胶囊加入所有 Spaces,再用 // orderFrontRegardless 做无激活展示。 if let Err(e) = window.set_visible_on_all_workspaces(true) { log::warn!("[capsule] set visible on all macOS Spaces failed: {e}"); } unsafe { const NS_WINDOW_COLLECTION_BEHAVIOR_CAN_JOIN_ALL_SPACES: usize = 1 << 0; const NS_WINDOW_COLLECTION_BEHAVIOR_FULL_SCREEN_AUXILIARY: usize = 1 << 8; let behavior: usize = msg_send![ns_window, collectionBehavior]; let behavior = behavior | NS_WINDOW_COLLECTION_BEHAVIOR_CAN_JOIN_ALL_SPACES | NS_WINDOW_COLLECTION_BEHAVIOR_FULL_SCREEN_AUXILIARY; let _: () = msg_send![ns_window, setCollectionBehavior: behavior]; let _: () = msg_send![ns_window, orderFrontRegardless]; } true } #[cfg(target_os = "linux")] fn show_capsule_window_no_activate( _app: &AppHandle, _window: &tauri::WebviewWindow, ) -> bool { true } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] fn show_capsule_window_no_activate( _app: &AppHandle, _window: &tauri::WebviewWindow, ) -> bool { false } #[cfg(target_os = "windows")] fn hide_capsule_window_if_present() { use std::iter::once; use windows::core::PCWSTR; use windows::Win32::Foundation::HWND; use windows::Win32::UI::WindowsAndMessaging::{ FindWindowW, SetWindowPos, ShowWindow, HWND_NOTOPMOST, SWP_HIDEWINDOW, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SW_HIDE, }; let title: Vec = "OpenLess Capsule".encode_utf16().chain(once(0)).collect(); let hwnd = match unsafe { FindWindowW(PCWSTR::null(), PCWSTR(title.as_ptr())) } { Ok(hwnd) => hwnd, Err(_) => return, }; if hwnd == HWND::default() || hwnd.0.is_null() { return; } let _ = unsafe { ShowWindow(hwnd, SW_HIDE) }; let _ = unsafe { SetWindowPos( hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_HIDEWINDOW, ) }; } #[cfg(not(target_os = "windows"))] fn hide_capsule_window_if_present() {} fn emit_capsule( inner: &Arc, state: CapsuleState, level: f32, elapsed_ms: u64, message: Option, inserted_chars: Option, ) { let app_opt = inner.app.lock().clone(); let Some(app) = app_opt else { return }; let translation = inner.translation_modifier_seen.load(Ordering::SeqCst); let payload = CapsulePayload { state, level, elapsed_ms, message, inserted_chars, translation, }; // visible / translation 是「这一帧 capsule:state event 的 payload」内容 —— // 必须在 call-site(即音频线程触发 emit_capsule 时)就算定,否则 main thread // 闭包里读到的将是「下一帧」的 state,跟实际下发给 JS 的 payload 不一致。 let visible = !matches!(state, CapsuleState::Idle); // Linux: 通过 fcitx5 插件在候选词列表下方显示听写状态,不干扰输入法预编辑。 // 只在文本变化时调用 DBus,避免录音中 ~30Hz 的音频电平回调重复调用。 #[cfg(target_os = "linux")] { use std::sync::Mutex; static LAST_AUX: Mutex> = Mutex::new(None); let aux = match state { CapsuleState::Idle => None, CapsuleState::Recording => Some("🎤 收音中..."), CapsuleState::Transcribing => Some("🔄 识别中..."), CapsuleState::Polishing => Some("✨ 润色中..."), CapsuleState::Done => Some("✅ 已插入"), CapsuleState::Cancelled => Some("— 已取消"), CapsuleState::Error => Some("❌ 出错"), }; let mut last = LAST_AUX.lock().unwrap(); if aux != last.as_deref() { *last = aux.map(String::from); // 代数计数器:每次状态变化 +1,retry 线程只在自己代数仍为最新时生效。 // 避免 Recording→Idle→Recording 快速切换时多个 retry 重复触发。 static RETRY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); // fetch_add 返回旧值,所以 latest_gen > gen+1 才表示"在我之后又发生了变更"。 let gen = RETRY_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst); match aux { Some(t) => { log::info!("[capsule] set_aux_down: {t} gen={gen}"); let text = t.to_string(); std::thread::spawn(move || { let current = LAST_AUX.lock().unwrap().clone(); if current.as_deref() != Some(&text) { log::info!( "[capsule] set_aux_down skipped: state changed to {current:?}" ); return; } if let Err(e) = crate::linux_fcitx::set_aux_down(&text) { log::warn!("[capsule] set_aux_down failed: {e}"); } }); // 终态(Done/Cancelled/Error)3 秒后自动清除,避免一直跟随焦点。 if matches!( state, CapsuleState::Done | CapsuleState::Cancelled | CapsuleState::Error ) { let text = t.to_string(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_secs(3)); let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst); if latest_gen > gen + 1 { return; } let current = LAST_AUX.lock().unwrap().clone(); if current.as_deref() != Some(&text) { return; } log::info!("[capsule] auto-clear terminal state: {text}"); let _ = crate::linux_fcitx::set_aux_down(""); *LAST_AUX.lock().unwrap() = None; }); } } None => { log::info!("[capsule] clear_aux_down gen={gen}"); std::thread::spawn(move || { let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst); if latest_gen > gen + 1 { log::info!( "[capsule] clear_aux_down skipped: gen {gen}, latest {latest_gen}" ); return; } let current = LAST_AUX.lock().unwrap().clone(); if current.is_some() { log::info!( "[capsule] clear_aux_down skipped: state changed to {current:?}" ); return; } if let Err(e) = crate::linux_fcitx::clear_aux_down() { log::warn!("[capsule] clear_aux_down failed: {e}"); } }); } } } } // emit_capsule 会被 cpal process_callback(音频回调线程)调用 ~30 Hz —— 在该 // 线程上调用 NSWindow / HWND API 会撞 macOS dispatch_assert_queue_fail SIGTRAP // 或者 Win32 SendMessage 死锁。把 window.show/hide + 位置调整 marshal 到主线程; // app.emit_to 走 Tauri 内部事件总线,本身线程安全,保留同步调用。详见 audit 3.2.2。 // // show_capsule(用户偏好)在主线程执行时再读 —— 用户可以在录音过程中改设置, // 闭包入队到真正跑之间窗口上限是一两帧(~16-33ms),用最新值消除 stale-pref // 闪烁。pr_agent 关注点 — 见 audit follow-up。 let inner_for_main = Arc::clone(inner); let app_for_main = app.clone(); let _ = app.run_on_main_thread(move || { let Some(window) = app_for_main.get_webview_window("capsule") else { return; }; let show_capsule = inner_for_main.prefs.get().show_capsule; // Linux: 不操作胶囊窗口(不 show/hide,不 reposition)。 // 文字通过 fcitx5 插件直接 commit,用户始终在目标 app 中。 #[cfg(target_os = "linux")] { return; } #[cfg(not(target_os = "linux"))] { // 三平台统一:Done / Cancelled / Error 状态保留 ~1.5s toast // (schedule_capsule_idle 之后会回 Idle 隐藏)。 // Windows 上 linger 的真实问题(截图选中 / 死区 / 拖拽卡顿)由 #140 加的 // `hide_capsule_window_if_present()` Win32 hard-hide 在 visible=false 分支 // 处理,不依赖把 Done/Cancelled/Error 打成 invisible。详见 PR #140 评论。 maybe_position_capsule_bottom_center(&inner_for_main, &window, translation); if show_capsule && visible { // 用户报"看不到胶囊"时第一时间能在 log 里确认:胶囊路径有跑、show_capsule // 开关是 true、当前进入 visible 帧 —— 排除 prefs 没存住 / emit_capsule 没触 // 发 / state 一直 Idle 这几类常见 root cause。issue #470。 if !CAPSULE_FIRST_SHOW_LOGGED.swap(true, Ordering::SeqCst) { log::info!( "[capsule] first show this session: show_capsule=true visible=true state={}", capsule_state_log_name(state) ); } show_capsule_window_for_recording(&app_for_main, &window); // macOS/Windows 优先走 no-activate show,避免录音胶囊抢走当前工作 app 焦点。 // 若 fallback 到 show(),OpenLess 已是前台 app 时再把 key window 还给 main。 #[cfg(target_os = "macos")] crate::restore_main_window_key_if_active(&app_for_main); } else { // show_capsule 开关被用户关掉但本次确实想显示(visible=true)的情况: // 一次性 info log,让用户报"胶囊没显示"时能在日志里一眼看到根因 —— 维护者 // 不必再让用户"去打开设置确认"。issue #470。 if !show_capsule && visible && !CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED.swap(true, Ordering::SeqCst) { log::info!( "[capsule] suppressed by user toggle: show_capsule=false visible=true state={}", capsule_state_log_name(state) ); } hide_capsule_window_if_present(); let _ = window.hide(); } } }); let _ = app.emit_to("capsule", "capsule:state", payload); } #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct CapsuleLayoutState { translation_active: bool, monitor_x: i32, monitor_y: i32, monitor_width: u32, monitor_height: u32, scale_bits: u64, } /// 返回胶囊「应该摆放到的显示器」的标识信息。 /// /// 它看的显示器必须和 `position_capsule_bottom_center` 实际定位用的一致: /// Windows 看「正在输入的 App 所在显示器」,其它平台看胶囊自己的显示器。 /// 这是「是否需要重新定位」去重缓存(`maybe_position_capsule_bottom_center`) /// 的 key,如果这里看错了显示器,就会出现「输入焦点移到另一块屏、胶囊却没 /// 跟过去」的 bug。 fn capsule_layout_snapshot( window: &tauri::WebviewWindow, translation_active: bool, ) -> Option { // Windows:以「正在输入的 App 所在显示器」为基准。若用胶囊自己的 // current_monitor,输入焦点切到另一块屏时胶囊仍在原屏 → 误判「没变化」 // → 跳过重新定位。 #[cfg(target_os = "windows")] { if let Some(mon) = crate::foreground_window_monitor() { return Some(CapsuleLayoutState { translation_active, monitor_x: mon.left, monitor_y: mon.top, monitor_width: (mon.right - mon.left).max(0) as u32, monitor_height: (mon.bottom - mon.top).max(0) as u32, scale_bits: mon.scale.to_bits(), }); } // 仅当 Win32 取不到前台显示器时,落回下面的 current_monitor。 } let monitor = window.current_monitor().ok().flatten()?; Some(CapsuleLayoutState { translation_active, monitor_x: monitor.position().x, monitor_y: monitor.position().y, monitor_width: monitor.size().width, monitor_height: monitor.size().height, scale_bits: monitor.scale_factor().to_bits(), }) } fn maybe_position_capsule_bottom_center( inner: &Arc, window: &tauri::WebviewWindow, translation_active: bool, ) { let Some(next) = capsule_layout_snapshot(window, translation_active) else { return; }; { let last = inner.capsule_layout.lock(); if last.as_ref() == Some(&next) { return; } } if crate::position_capsule_bottom_center(window, translation_active).is_ok() { let mut last = inner.capsule_layout.lock(); *last = Some(next); } } // ─────────────────────────── audio bridge ─────────────────────────── struct DeferredAsrBridge { state: Mutex, } struct DeferredAsrState { target: Option>, pending_audio: Vec, attaching: bool, } impl DeferredAsrBridge { fn new() -> Self { Self { state: Mutex::new(DeferredAsrState { target: None, pending_audio: Vec::new(), attaching: false, }), } } fn attach(&self, target: Arc) -> usize { let mut flushed_bytes = 0; { let mut state = self.state.lock(); state.attaching = true; } loop { let pending = { let mut state = self.state.lock(); if state.pending_audio.is_empty() { state.target = Some(Arc::clone(&target)); state.attaching = false; return flushed_bytes; } std::mem::take(&mut state.pending_audio) }; flushed_bytes += pending.len(); target.consume_pcm_chunk(&pending); } } } impl crate::recorder::AudioConsumer for DeferredAsrBridge { fn consume_pcm_chunk(&self, pcm: &[u8]) { let target = { let mut state = self.state.lock(); if state.attaching { state.pending_audio.extend_from_slice(pcm); return; } if let Some(target) = state.target.as_ref() { Some(Arc::clone(target)) } else { state.pending_audio.extend_from_slice(pcm); None } }; if let Some(target) = target { target.consume_pcm_chunk(pcm); } } }