From 92e1c91097973be14da9683cc72b1d36c6bcb21a Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 17:35:18 +0800 Subject: [PATCH 01/37] feat: add configurable recording hotkeys --- openless-all/app/src-tauri/src/commands.rs | 1 + openless-all/app/src-tauri/src/coordinator.rs | 150 ++-- openless-all/app/src-tauri/src/hotkey.rs | 670 +++++++++++++++--- openless-all/app/src-tauri/src/types.rs | 171 ++++- openless-all/app/src/i18n/en.ts | 29 +- openless-all/app/src/i18n/zh-CN.ts | 31 +- openless-all/app/src/lib/hotkey.ts | 113 ++- openless-all/app/src/lib/ipc.ts | 4 +- openless-all/app/src/lib/types.ts | 7 +- openless-all/app/src/pages/History.tsx | 4 +- openless-all/app/src/pages/Overview.tsx | 4 +- openless-all/app/src/pages/QaPanel.tsx | 6 +- openless-all/app/src/pages/SelectionAsk.tsx | 4 +- openless-all/app/src/pages/Settings.tsx | 362 +++++++++- openless-all/app/src/pages/Translation.tsx | 4 +- 15 files changed, 1321 insertions(+), 239 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 508000d3..24d0a69c 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -632,6 +632,7 @@ mod tests { hotkey: HotkeyBinding { trigger: HotkeyTrigger::RightControl, mode: HotkeyMode::Toggle, + ..Default::default() }, qa_hotkey: Some(QaHotkeyBinding { primary: ";".to_string(), diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index a2867b61..0b860916 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -8,7 +8,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use chrono::Utc; use parking_lot::Mutex; @@ -30,8 +30,8 @@ use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor}; use crate::recorder::{Recorder, RecorderError}; use crate::selection::{capture_selection, SelectionContext}; use crate::types::{ - CapsulePayload, CapsuleState, DictationSession, HotkeyCapability, HotkeyMode, HotkeyStatus, - HotkeyStatusState, InsertStatus, PolishMode, + CapsulePayload, CapsuleState, DictationSession, HotkeyBinding, HotkeyCapability, HotkeyMode, + HotkeyStatus, HotkeyStatusState, InsertStatus, PolishMode, }; #[cfg(target_os = "windows")] use crate::windows_ime_ipc::ImeSubmitTarget; @@ -51,6 +51,8 @@ enum SessionPhase { Inserting, } +const HOTKEY_DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(450); + enum ActiveAsr { Volcengine(Arc), Whisper(Arc), @@ -127,6 +129,7 @@ struct Inner { hotkey: Mutex>, hotkey_status: Mutex, hotkey_trigger_held: AtomicBool, + hotkey_last_click_at: Mutex>, /// 翻译模式触发标志。每次 begin_session 重置为 false;hotkey 监听器在 /// Listening / Starting 阶段看到 Shift down 边沿时 set true。 /// end_session 在调 polish/translate 前读这个 flag + translation_target_language @@ -222,6 +225,7 @@ impl Coordinator { hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), hotkey_trigger_held: AtomicBool::new(false), + hotkey_last_click_at: Mutex::new(None), translation_modifier_seen: AtomicBool::new(false), qa_hotkey: Mutex::new(None), qa_state: Mutex::new(QaSessionState::default()), @@ -698,6 +702,9 @@ fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { async fn handle_pressed_edge(inner: &Arc) { let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst); if !was_held { + if !should_accept_pressed_edge(inner) { + return; + } // 路由:QA 浮窗可见时,rightOption 边沿走 QA;否则走主听写。详见 issue #118 v2。 let panel_visible = inner.qa_state.lock().panel_visible; if panel_visible { @@ -708,15 +715,30 @@ async fn handle_pressed_edge(inner: &Arc) { } } +fn should_accept_pressed_edge(inner: &Arc) -> bool { + if inner.prefs.get().hotkey.mode != HotkeyMode::DoubleClick { + *inner.hotkey_last_click_at.lock() = None; + return true; + } + + let now = Instant::now(); + let mut last_click_at = inner.hotkey_last_click_at.lock(); + let accepted = last_click_at + .map(|previous| now.duration_since(previous) <= HOTKEY_DOUBLE_CLICK_INTERVAL) + .unwrap_or(false); + *last_click_at = if accepted { None } else { Some(now) }; + accepted +} + async fn handle_pressed(inner: &Arc) { let mode = inner.prefs.get().hotkey.mode; let phase = inner.state.lock().phase; log::info!("[coord] hotkey pressed (mode={mode:?}, phase={phase:?})"); match (mode, phase) { - (HotkeyMode::Toggle, SessionPhase::Idle) => { + (HotkeyMode::Toggle | HotkeyMode::DoubleClick, SessionPhase::Idle) => { let _ = begin_session(inner).await; } - (HotkeyMode::Toggle, SessionPhase::Listening) => { + (HotkeyMode::Toggle | HotkeyMode::DoubleClick, SessionPhase::Listening) => { let _ = end_session(inner).await; } (HotkeyMode::Hold, SessionPhase::Idle) => { @@ -724,7 +746,7 @@ async fn handle_pressed(inner: &Arc) { } // Toggle 模式 Starting 阶段第二次按 → 用户想停。 // 不能直接 end_session(ASR session 还没建好),存边沿,握手完成后立即触发。 - (HotkeyMode::Toggle, SessionPhase::Starting) => { + (HotkeyMode::Toggle | HotkeyMode::DoubleClick, SessionPhase::Starting) => { request_stop_during_starting(inner, "toggle stop edge"); } _ => {} @@ -886,8 +908,8 @@ async fn handle_window_hotkey_event( return Ok(()); } - let trigger = inner.prefs.get().hotkey.trigger; - if !window_key_matches_trigger(trigger, &key, &code) { + let binding = inner.prefs.get().hotkey; + if !window_key_matches_binding(&binding, &key, &code) { return Ok(()); } @@ -896,13 +918,11 @@ async fn handle_window_hotkey_event( if repeat { return Ok(()); } - log::info!( - "[window-hotkey] pressed trigger={trigger:?} code={code} repeat={repeat}" - ); + log::info!("[window-hotkey] pressed code={code} repeat={repeat}"); handle_pressed_edge(inner).await; } "keyup" => { - log::info!("[window-hotkey] released trigger={trigger:?} code={code}"); + log::info!("[window-hotkey] released code={code}"); handle_released_edge(inner).await; } _ => {} @@ -916,18 +936,34 @@ fn window_hotkey_fallback_enabled() -> bool { } #[cfg(any(target_os = "windows", test))] -fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, key: &str, code: &str) -> bool { - use crate::types::HotkeyTrigger; +fn window_key_matches_binding(binding: &HotkeyBinding, key: &str, code: &str) -> bool { + let normalized = normalize_window_hotkey_code(key, code); + !normalized.is_empty() + && binding + .effective_codes() + .iter() + .any(|candidate| candidate == &normalized) +} - match trigger { - HotkeyTrigger::RightControl => key == "Control" && code == "ControlRight", - HotkeyTrigger::LeftControl => key == "Control" && code == "ControlLeft", - HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => { - (key == "Alt" || key == "AltGraph") && code == "AltRight" +#[cfg(any(target_os = "windows", test))] +fn normalize_window_hotkey_code(key: &str, code: &str) -> String { + if !code.is_empty() { + return code.to_string(); + } + match key { + "Control" => "ControlLeft".into(), + "Alt" | "AltGraph" => "AltLeft".into(), + "Shift" => "ShiftLeft".into(), + "Meta" => "MetaLeft".into(), + " " => "Space".into(), + "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight" => key.into(), + other if other.len() == 1 && other.as_bytes()[0].is_ascii_alphabetic() => { + format!("Key{}", other.to_ascii_uppercase()) + } + other if other.len() == 1 && other.as_bytes()[0].is_ascii_digit() => { + format!("Digit{other}") } - HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltRight", - HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight", - HotkeyTrigger::Fn => key == "Control" && code == "ControlRight", + other => other.into(), } } @@ -2548,7 +2584,7 @@ fn resolve_ark_endpoint_with_policy( #[cfg(test)] mod tests { use super::*; - use crate::types::HotkeyTrigger; + use crate::types::{HotkeyKey, HotkeyTrigger}; #[tokio::test] async fn hotkey_injection_gate_logs_pressed_and_cancels() { @@ -2566,35 +2602,21 @@ mod tests { } #[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"), - // Mirrors Windows trigger_to_vk_code aliases. - (HotkeyTrigger::LeftOption, "Alt", "AltRight"), - (HotkeyTrigger::Fn, "Control", "ControlRight"), - ]; - for (trigger, key, code) in cases { - assert!( - window_key_matches_trigger(trigger, key, code), - "{trigger:?} should match {key}/{code}" - ); - } + fn window_key_matcher_accepts_legacy_and_configured_codes() { + let legacy = HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + ..Default::default() + }; + assert!(window_key_matches_binding(&legacy, "Control", "ControlRight")); + assert!(!window_key_matches_binding(&legacy, "Control", "ControlLeft")); - assert!(!window_key_matches_trigger( - HotkeyTrigger::RightControl, - "Control", - "ControlLeft" - )); - assert!(!window_key_matches_trigger( - HotkeyTrigger::LeftOption, - "Alt", - "AltLeft" - )); - assert!(!window_key_matches_trigger(HotkeyTrigger::Fn, "Fn", "Fn")); + let caps_lock = HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + keys: Some(vec![HotkeyKey::new("CapsLock")]), + ..Default::default() + }; + assert!(window_key_matches_binding(&caps_lock, "CapsLock", "CapsLock")); + assert!(!window_key_matches_binding(&caps_lock, "Control", "ControlRight")); } #[test] @@ -2710,6 +2732,31 @@ mod tests { assert_eq!(state.session_id, 41); } + #[test] + fn double_click_mode_requires_second_press_within_window() { + let coordinator = Coordinator::new(); + coordinator + .inner + .prefs + .set(crate::types::UserPreferences { + hotkey: crate::types::HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::DoubleClick, + ..Default::default() + }, + ..Default::default() + }) + .unwrap(); + + assert!(!should_accept_pressed_edge(&coordinator.inner)); + assert!(should_accept_pressed_edge(&coordinator.inner)); + assert!(!should_accept_pressed_edge(&coordinator.inner)); + + *coordinator.inner.hotkey_last_click_at.lock() = + Some(Instant::now() - HOTKEY_DOUBLE_CLICK_INTERVAL - Duration::from_millis(1)); + assert!(!should_accept_pressed_edge(&coordinator.inner)); + } + #[tokio::test] async fn repeated_pressed_edge_during_hold_session_does_not_restart() { let coordinator = Coordinator::new(); @@ -2720,6 +2767,7 @@ mod tests { hotkey: crate::types::HotkeyBinding { trigger: HotkeyTrigger::RightControl, mode: HotkeyMode::Hold, + ..Default::default() }, ..Default::default() }) diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 242f3c5d..c7ff68bc 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -10,6 +10,7 @@ //! //! 仅产出"边沿"事件,toggle vs hold 由 Coordinator 解释。 +use std::collections::BTreeSet; use std::sync::atomic::AtomicBool; use std::sync::mpsc::{self, Sender}; use std::sync::Arc; @@ -37,6 +38,7 @@ pub trait HotkeyAdapter: Send + Sync { struct Shared { binding: RwLock, + pressed_codes: RwLock>, /// 触发键当前是否处于"按住"状态。OS 自动重复事件用此去重。 trigger_held: AtomicBool, /// Shift(翻译修饰键)当前是否按住。用于在 FLAGS_CHANGED 上识别 down 边沿 @@ -113,6 +115,7 @@ where { let shared = Arc::new(Shared { binding: RwLock::new(binding), + pressed_codes: RwLock::new(BTreeSet::new()), trigger_held: AtomicBool::new(false), translation_modifier_held: AtomicBool::new(false), }); @@ -133,11 +136,44 @@ where fn update_shared_binding(shared: &Shared, binding: HotkeyBinding) { *shared.binding.write() = binding; + shared.pressed_codes.write().clear(); shared .trigger_held .store(false, std::sync::atomic::Ordering::SeqCst); } +fn dispatch_hotkey_code(shared: &Shared, tx: &Sender, code: &str, pressed: bool) { + if code.is_empty() { + return; + } + let binding = shared.binding.read().clone(); + let active_after = { + let mut pressed_codes = shared.pressed_codes.write(); + if pressed { + pressed_codes.insert(code.to_string()); + } else { + pressed_codes.remove(code); + } + binding_matches_pressed_codes(&binding, &pressed_codes) + }; + let was_active = shared + .trigger_held + .swap(active_after, std::sync::atomic::Ordering::SeqCst); + if active_after && !was_active { + send_or_log(tx, HotkeyEvent::Pressed); + } else if !active_after && was_active { + send_or_log(tx, HotkeyEvent::Released); + } +} + +fn binding_matches_pressed_codes(binding: &HotkeyBinding, pressed_codes: &BTreeSet) -> bool { + let codes = binding.effective_codes(); + !codes.is_empty() + && codes + .iter() + .all(|code| pressed_codes.contains(code.as_str())) +} + // ─────────────────────────── macOS implementation ─────────────────────────── #[cfg(target_os = "macos")] @@ -148,10 +184,10 @@ mod platform { use std::sync::Arc; use super::{ - install_error, send_or_log, start_listener_thread, update_shared_binding, HotkeyAdapter, - HotkeyEvent, Shared, StartupTx, + dispatch_hotkey_code, install_error, send_or_log, start_listener_thread, + update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, StartupTx, }; - use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; + use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError}; pub fn start_adapter( binding: HotkeyBinding, @@ -218,12 +254,17 @@ mod platform { const TAP_OPTION_DEFAULT: CgEventTapOptions = 0; const KEY_DOWN: CgEventType = 10; + const KEY_UP: CgEventType = 11; const FLAGS_CHANGED: CgEventType = 12; + const OTHER_MOUSE_DOWN: CgEventType = 25; + const OTHER_MOUSE_UP: CgEventType = 26; const TAP_DISABLED_BY_TIMEOUT: CgEventType = 0xFFFF_FFFE; const TAP_DISABLED_BY_USER_INPUT: CgEventType = 0xFFFF_FFFF; + const MOUSE_EVENT_BUTTON_NUMBER: CgEventField = 3; const KEYBOARD_EVENT_KEYCODE: CgEventField = 9; + const FLAG_MASK_ALPHA_SHIFT: CgEventFlags = 0x0001_0000; const FLAG_MASK_SHIFT: CgEventFlags = 0x0002_0000; const FLAG_MASK_CONTROL: CgEventFlags = 0x0004_0000; const FLAG_MASK_ALTERNATE: CgEventFlags = 0x0008_0000; @@ -277,7 +318,11 @@ mod platform { unsafe impl Sync for CallbackContext {} fn run_listen_loop(shared: Arc, tx: Sender, status_tx: StartupTx<()>) { - let mask: CgEventMask = (1u64 << FLAGS_CHANGED) | (1u64 << KEY_DOWN); + let mask: CgEventMask = (1u64 << FLAGS_CHANGED) + | (1u64 << KEY_DOWN) + | (1u64 << KEY_UP) + | (1u64 << OTHER_MOUSE_DOWN) + | (1u64 << OTHER_MOUSE_UP); let context = Box::into_raw(Box::new(CallbackContext { shared, tx, @@ -337,6 +382,9 @@ mod platform { } FLAGS_CHANGED => handle_flags_changed(ctx, event), KEY_DOWN => handle_key_down(ctx, event), + KEY_UP => handle_key_up(ctx, event), + OTHER_MOUSE_DOWN => handle_mouse_button(ctx, event, true), + OTHER_MOUSE_UP => handle_mouse_button(ctx, event, false), _ => {} } event @@ -360,21 +408,17 @@ mod platform { } let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; - let trigger = ctx.shared.binding.read().trigger; - let expected_keycode = trigger_to_keycode(trigger); - if keycode != expected_keycode { - return; - } - let mask = trigger_to_flag_mask(trigger); - let is_active = (flags & mask) != 0; - let was_held = ctx.shared.trigger_held.load(Ordering::SeqCst); - - if is_active && !was_held { - ctx.shared.trigger_held.store(true, Ordering::SeqCst); - send_or_log(&ctx.tx, HotkeyEvent::Pressed); - } else if !is_active && was_held { - ctx.shared.trigger_held.store(false, Ordering::SeqCst); - send_or_log(&ctx.tx, HotkeyEvent::Released); + if let Some(code) = mac_keycode_to_hotkey_code(keycode) { + if let Some(mask) = mac_keycode_flag_mask(keycode) { + let family_active = (flags & mask) != 0; + let code_was_pressed = ctx.shared.pressed_codes.read().contains(code); + dispatch_hotkey_code( + &ctx.shared, + &ctx.tx, + code, + family_active && !code_was_pressed, + ); + } } } @@ -382,28 +426,154 @@ mod platform { let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; if keycode == ESC_KEYCODE { send_or_log(&ctx.tx, HotkeyEvent::Cancelled); + return; + } + if let Some(code) = mac_keycode_to_hotkey_code(keycode) { + if mac_keycode_flag_mask(keycode).is_none() { + dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, true); + } } } - fn trigger_to_keycode(trigger: HotkeyTrigger) -> i64 { - match trigger { - HotkeyTrigger::LeftControl => 59, - HotkeyTrigger::RightControl => 62, - HotkeyTrigger::LeftOption => 58, - HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => 61, - HotkeyTrigger::RightCommand => 54, - HotkeyTrigger::Fn => 63, + fn handle_key_up(ctx: &CallbackContext, event: CgEventRef) { + let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; + if let Some(code) = mac_keycode_to_hotkey_code(keycode) { + if mac_keycode_flag_mask(keycode).is_none() { + dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, false); + } } } - fn trigger_to_flag_mask(trigger: HotkeyTrigger) -> CgEventFlags { - match trigger { - HotkeyTrigger::LeftControl | HotkeyTrigger::RightControl => FLAG_MASK_CONTROL, - HotkeyTrigger::RightCommand => FLAG_MASK_COMMAND, - HotkeyTrigger::LeftOption | HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => { - FLAG_MASK_ALTERNATE - } - HotkeyTrigger::Fn => FLAG_MASK_SECONDARY_FN, + fn handle_mouse_button(ctx: &CallbackContext, event: CgEventRef, pressed: bool) { + let button = unsafe { CGEventGetIntegerValueField(event, MOUSE_EVENT_BUTTON_NUMBER) }; + let code = match button { + 3 => "Mouse4", + 4 => "Mouse5", + _ => return, + }; + dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, pressed); + } + + fn mac_keycode_flag_mask(keycode: i64) -> Option { + match keycode { + 54 | 55 => Some(FLAG_MASK_COMMAND), + 56 | 60 => Some(FLAG_MASK_SHIFT), + 57 => Some(FLAG_MASK_ALPHA_SHIFT), + 58 | 61 => Some(FLAG_MASK_ALTERNATE), + 59 | 62 => Some(FLAG_MASK_CONTROL), + 63 => Some(FLAG_MASK_SECONDARY_FN), + _ => None, + } + } + + fn mac_keycode_to_hotkey_code(keycode: i64) -> Option<&'static str> { + match keycode { + 0 => Some("KeyA"), + 1 => Some("KeyS"), + 2 => Some("KeyD"), + 3 => Some("KeyF"), + 4 => Some("KeyH"), + 5 => Some("KeyG"), + 6 => Some("KeyZ"), + 7 => Some("KeyX"), + 8 => Some("KeyC"), + 9 => Some("KeyV"), + 11 => Some("KeyB"), + 12 => Some("KeyQ"), + 13 => Some("KeyW"), + 14 => Some("KeyE"), + 15 => Some("KeyR"), + 16 => Some("KeyY"), + 17 => Some("KeyT"), + 18 => Some("Digit1"), + 19 => Some("Digit2"), + 20 => Some("Digit3"), + 21 => Some("Digit4"), + 22 => Some("Digit6"), + 23 => Some("Digit5"), + 24 => Some("Equal"), + 25 => Some("Digit9"), + 26 => Some("Digit7"), + 27 => Some("Minus"), + 28 => Some("Digit8"), + 29 => Some("Digit0"), + 30 => Some("BracketRight"), + 31 => Some("KeyO"), + 32 => Some("KeyU"), + 33 => Some("BracketLeft"), + 34 => Some("KeyI"), + 35 => Some("KeyP"), + 36 => Some("Enter"), + 37 => Some("KeyL"), + 38 => Some("KeyJ"), + 39 => Some("Quote"), + 40 => Some("KeyK"), + 41 => Some("Semicolon"), + 42 => Some("Backslash"), + 43 => Some("Comma"), + 44 => Some("Slash"), + 45 => Some("KeyN"), + 46 => Some("KeyM"), + 47 => Some("Period"), + 48 => Some("Tab"), + 49 => Some("Space"), + 50 => Some("Backquote"), + 51 => Some("Backspace"), + 54 => Some("MetaRight"), + 55 => Some("MetaLeft"), + 56 => Some("ShiftLeft"), + 57 => Some("CapsLock"), + 58 => Some("AltLeft"), + 59 => Some("ControlLeft"), + 60 => Some("ShiftRight"), + 61 => Some("AltRight"), + 62 => Some("ControlRight"), + 63 => Some("Fn"), + 64 => Some("F17"), + 65 => Some("NumpadDecimal"), + 67 => Some("NumpadMultiply"), + 69 => Some("NumpadAdd"), + 75 => Some("NumpadDivide"), + 76 => Some("NumpadEnter"), + 78 => Some("NumpadSubtract"), + 79 => Some("F18"), + 80 => Some("F19"), + 82 => Some("Numpad0"), + 83 => Some("Numpad1"), + 84 => Some("Numpad2"), + 85 => Some("Numpad3"), + 86 => Some("Numpad4"), + 87 => Some("Numpad5"), + 88 => Some("Numpad6"), + 89 => Some("Numpad7"), + 91 => Some("Numpad8"), + 92 => Some("Numpad9"), + 96 => Some("F5"), + 97 => Some("F6"), + 98 => Some("F7"), + 99 => Some("F3"), + 100 => Some("F8"), + 101 => Some("F9"), + 103 => Some("F11"), + 105 => Some("F13"), + 106 => Some("F16"), + 107 => Some("F14"), + 109 => Some("F10"), + 111 => Some("F12"), + 113 => Some("F15"), + 115 => Some("Home"), + 116 => Some("PageUp"), + 117 => Some("Delete"), + 118 => Some("F4"), + 119 => Some("End"), + 120 => Some("F2"), + 121 => Some("PageDown"), + 122 => Some("F1"), + 123 => Some("ArrowLeft"), + 124 => Some("ArrowRight"), + 125 => Some("ArrowDown"), + 126 => Some("ArrowUp"), + _ => None, } } } @@ -421,29 +591,72 @@ mod platform { use windows::Win32::System::Threading::GetCurrentThreadId; use windows::Win32::UI::WindowsAndMessaging::{ CallNextHookEx, DispatchMessageW, GetMessageW, PostThreadMessageW, SetWindowsHookExW, - TranslateMessage, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, MSG, - WH_KEYBOARD_LL, WM_QUIT, + TranslateMessage, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, + MSLLHOOKSTRUCT, MSG, WH_KEYBOARD_LL, WH_MOUSE_LL, WM_QUIT, }; use super::{ - install_error, send_or_log, start_listener_thread, update_shared_binding, HotkeyAdapter, - HotkeyEvent, Shared, StartupTx, + dispatch_hotkey_code, install_error, send_or_log, start_listener_thread, + update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, StartupTx, }; - use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; + use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError}; const WM_KEYDOWN: usize = 0x0100; const WM_KEYUP: usize = 0x0101; const WM_SYSKEYDOWN: usize = 0x0104; const WM_SYSKEYUP: usize = 0x0105; + const WM_XBUTTONDOWN: usize = 0x020B; + const WM_XBUTTONUP: usize = 0x020C; + const VK_BACK: u32 = 0x08; + const VK_TAB: u32 = 0x09; + const VK_RETURN: u32 = 0x0D; const VK_ESCAPE: u32 = 0x1B; const VK_SHIFT: u32 = 0x10; + const VK_CONTROL: u32 = 0x11; + const VK_MENU: u32 = 0x12; + const VK_PAUSE: u32 = 0x13; + const VK_CAPITAL: u32 = 0x14; + const VK_SPACE: u32 = 0x20; + const VK_PRIOR: u32 = 0x21; + const VK_NEXT: u32 = 0x22; + const VK_END: u32 = 0x23; + const VK_HOME: u32 = 0x24; + const VK_LEFT: u32 = 0x25; + const VK_UP: u32 = 0x26; + const VK_RIGHT: u32 = 0x27; + const VK_DOWN: u32 = 0x28; + const VK_SNAPSHOT: u32 = 0x2C; + const VK_INSERT: u32 = 0x2D; + const VK_DELETE: u32 = 0x2E; + const VK_APPS: u32 = 0x5D; + const VK_LWIN: u32 = 0x5B; + const VK_RWIN: u32 = 0x5C; + const VK_MULTIPLY: u32 = 0x6A; + const VK_ADD: u32 = 0x6B; + const VK_SUBTRACT: u32 = 0x6D; + const VK_DECIMAL: u32 = 0x6E; + const VK_DIVIDE: u32 = 0x6F; + const VK_SCROLL: u32 = 0x91; const VK_LSHIFT: u32 = 0xA0; const VK_RSHIFT: u32 = 0xA1; const VK_LCONTROL: u32 = 0xA2; const VK_RCONTROL: u32 = 0xA3; + const VK_LMENU: u32 = 0xA4; const VK_RMENU: u32 = 0xA5; - const VK_RWIN: u32 = 0x5C; + const VK_OEM_1: u32 = 0xBA; + const VK_OEM_PLUS: u32 = 0xBB; + const VK_OEM_COMMA: u32 = 0xBC; + const VK_OEM_MINUS: u32 = 0xBD; + const VK_OEM_PERIOD: u32 = 0xBE; + const VK_OEM_2: u32 = 0xBF; + const VK_OEM_3: u32 = 0xC0; + const VK_OEM_4: u32 = 0xDB; + const VK_OEM_5: u32 = 0xDC; + const VK_OEM_6: u32 = 0xDD; + const VK_OEM_7: u32 = 0xDE; + const XBUTTON1: u32 = 0x0001; + const XBUTTON2: u32 = 0x0002; const LLKHF_INJECTED: u32 = 0x0000_0010; const ACCEPT_INJECTED_ENV: &str = "OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS"; @@ -493,7 +706,8 @@ mod platform { struct CallbackContext { shared: Arc, tx: Sender, - hook: std::sync::Mutex>, + keyboard_hook: std::sync::Mutex>, + mouse_hook: std::sync::Mutex>, } unsafe impl Send for CallbackContext {} @@ -504,7 +718,8 @@ mod platform { let context = Box::into_raw(Box::new(CallbackContext { shared, tx, - hook: std::sync::Mutex::new(None), + keyboard_hook: std::sync::Mutex::new(None), + mouse_hook: std::sync::Mutex::new(None), })); HOOK_CONTEXT.store(context, AtomicOrdering::SeqCst); @@ -512,9 +727,8 @@ mod platform { let hook = SetWindowsHookExW(WH_KEYBOARD_LL, Some(low_level_keyboard_proc), None, 0); match hook { Ok(hook) => { - *(*context).hook.lock().unwrap() = Some(hook); + *(*context).keyboard_hook.lock().unwrap() = Some(hook); log::info!("[hotkey] Windows low-level keyboard hook 已启动"); - let _ = status_tx.send(Ok(thread_id)); } Err(err) => { HOOK_CONTEXT.store(std::ptr::null_mut(), AtomicOrdering::SeqCst); @@ -527,6 +741,19 @@ mod platform { } } + match SetWindowsHookExW(WH_MOUSE_LL, Some(low_level_mouse_proc), None, 0) { + Ok(hook) => { + *(*context).mouse_hook.lock().unwrap() = Some(hook); + log::info!("[hotkey] Windows low-level mouse hook installed"); + } + Err(err) => { + log::warn!( + "[hotkey] Windows low-level mouse hook install failed; Mouse4/Mouse5 hotkeys will be unavailable: {err}" + ); + } + } + let _ = status_tx.send(Ok(thread_id)); + let mut message = MSG::default(); loop { let result = GetMessageW(&mut message, None, 0, 0).0; @@ -542,7 +769,10 @@ mod platform { let _ = DispatchMessageW(&message); } - if let Some(hook) = (*context).hook.lock().unwrap().take() { + if let Some(hook) = (*context).keyboard_hook.lock().unwrap().take() { + let _ = UnhookWindowsHookEx(hook); + } + if let Some(hook) = (*context).mouse_hook.lock().unwrap().take() { let _ = UnhookWindowsHookEx(hook); } HOOK_CONTEXT.store(std::ptr::null_mut(), AtomicOrdering::SeqCst); @@ -567,6 +797,21 @@ mod platform { CallNextHookEx(None, code, wparam, lparam) } + unsafe extern "system" fn low_level_mouse_proc( + code: i32, + wparam: WPARAM, + lparam: LPARAM, + ) -> LRESULT { + if code == HC_ACTION as i32 && lparam.0 != 0 { + if let Some(ctx) = callback_context() { + let mouse = *(lparam.0 as *const MSLLHOOKSTRUCT); + dispatch_mouse_event(ctx, mouse.mouseData, wparam.0); + } + } + + CallNextHookEx(None, code, wparam, lparam) + } + unsafe fn callback_context<'a>() -> Option<&'a CallbackContext> { let ptr = HOOK_CONTEXT.load(AtomicOrdering::SeqCst); if ptr.is_null() { @@ -577,7 +822,10 @@ mod platform { } fn dispatch_keyboard_event(ctx: &CallbackContext, vk_code: u32, message: usize) { - if vk_code == VK_ESCAPE && (message == WM_KEYDOWN || message == WM_SYSKEYDOWN) { + let is_down = matches!(message, WM_KEYDOWN | WM_SYSKEYDOWN); + let is_up = matches!(message, WM_KEYUP | WM_SYSKEYUP); + + if vk_code == VK_ESCAPE && is_down { send_or_log(&ctx.tx, HotkeyEvent::Cancelled); return; } @@ -601,47 +849,145 @@ mod platform { } _ => {} } - return; } - let trigger = ctx.shared.binding.read().trigger; - if vk_code != trigger_to_vk_code(trigger) { - return; - } - - match message { - WM_KEYDOWN | WM_SYSKEYDOWN => { - let was_held = ctx.shared.trigger_held.swap(true, Ordering::SeqCst); - if !was_held { - log::info!("[hotkey] Windows trigger pressed vk={vk_code}"); - send_or_log(&ctx.tx, HotkeyEvent::Pressed); - } + if let Some(code) = vk_to_hotkey_code(vk_code) { + if is_down { + dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, true); + } else if is_up { + dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, false); } - WM_KEYUP | WM_SYSKEYUP => { - let was_held = ctx.shared.trigger_held.swap(false, Ordering::SeqCst); - if was_held { - log::info!("[hotkey] Windows trigger released vk={vk_code}"); - send_or_log(&ctx.tx, HotkeyEvent::Released); - } - } - _ => {} } } - fn trigger_to_vk_code(trigger: HotkeyTrigger) -> u32 { - // Windows only gives us a small set of modifier virtual keys that can be - // used as reliable modifier-only global triggers, so the cross-platform - // trigger list intentionally collapses a few aliases onto the same - // physical Windows key: - // - LeftOption reuses RightAlt / VK_RMENU - // - Fn reuses RightControl / VK_RCONTROL - match trigger { - HotkeyTrigger::RightControl => VK_RCONTROL, - HotkeyTrigger::LeftControl => VK_LCONTROL, - HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => VK_RMENU, - HotkeyTrigger::RightCommand => VK_RWIN, - HotkeyTrigger::LeftOption => VK_RMENU, - HotkeyTrigger::Fn => VK_RCONTROL, + fn dispatch_mouse_event(ctx: &CallbackContext, mouse_data: u32, message: usize) { + let code = match ((mouse_data >> 16) & 0xffff, message) { + (XBUTTON1, WM_XBUTTONDOWN | WM_XBUTTONUP) => "Mouse4", + (XBUTTON2, WM_XBUTTONDOWN | WM_XBUTTONUP) => "Mouse5", + _ => return, + }; + dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, message == WM_XBUTTONDOWN); + } + + fn vk_to_hotkey_code(vk_code: u32) -> Option<&'static str> { + match vk_code { + VK_SHIFT => Some("ShiftLeft"), + VK_LSHIFT => Some("ShiftLeft"), + VK_RSHIFT => Some("ShiftRight"), + VK_CONTROL => Some("ControlLeft"), + VK_LCONTROL => Some("ControlLeft"), + VK_RCONTROL => Some("ControlRight"), + VK_MENU => Some("AltLeft"), + VK_LMENU => Some("AltLeft"), + VK_RMENU => Some("AltRight"), + VK_LWIN => Some("MetaLeft"), + VK_RWIN => Some("MetaRight"), + VK_BACK => Some("Backspace"), + VK_TAB => Some("Tab"), + VK_RETURN => Some("Enter"), + VK_CAPITAL => Some("CapsLock"), + VK_PAUSE => Some("Pause"), + VK_SPACE => Some("Space"), + VK_PRIOR => Some("PageUp"), + VK_NEXT => Some("PageDown"), + VK_END => Some("End"), + VK_HOME => Some("Home"), + VK_LEFT => Some("ArrowLeft"), + VK_UP => Some("ArrowUp"), + VK_RIGHT => Some("ArrowRight"), + VK_DOWN => Some("ArrowDown"), + VK_SNAPSHOT => Some("PrintScreen"), + VK_INSERT => Some("Insert"), + VK_DELETE => Some("Delete"), + VK_APPS => Some("ContextMenu"), + VK_MULTIPLY => Some("NumpadMultiply"), + VK_ADD => Some("NumpadAdd"), + VK_SUBTRACT => Some("NumpadSubtract"), + VK_DECIMAL => Some("NumpadDecimal"), + VK_DIVIDE => Some("NumpadDivide"), + VK_SCROLL => Some("ScrollLock"), + VK_OEM_1 => Some("Semicolon"), + VK_OEM_PLUS => Some("Equal"), + VK_OEM_COMMA => Some("Comma"), + VK_OEM_MINUS => Some("Minus"), + VK_OEM_PERIOD => Some("Period"), + VK_OEM_2 => Some("Slash"), + VK_OEM_3 => Some("Backquote"), + VK_OEM_4 => Some("BracketLeft"), + VK_OEM_5 => Some("Backslash"), + VK_OEM_6 => Some("BracketRight"), + VK_OEM_7 => Some("Quote"), + 0x30 => Some("Digit0"), + 0x31 => Some("Digit1"), + 0x32 => Some("Digit2"), + 0x33 => Some("Digit3"), + 0x34 => Some("Digit4"), + 0x35 => Some("Digit5"), + 0x36 => Some("Digit6"), + 0x37 => Some("Digit7"), + 0x38 => Some("Digit8"), + 0x39 => Some("Digit9"), + 0x41 => Some("KeyA"), + 0x42 => Some("KeyB"), + 0x43 => Some("KeyC"), + 0x44 => Some("KeyD"), + 0x45 => Some("KeyE"), + 0x46 => Some("KeyF"), + 0x47 => Some("KeyG"), + 0x48 => Some("KeyH"), + 0x49 => Some("KeyI"), + 0x4A => Some("KeyJ"), + 0x4B => Some("KeyK"), + 0x4C => Some("KeyL"), + 0x4D => Some("KeyM"), + 0x4E => Some("KeyN"), + 0x4F => Some("KeyO"), + 0x50 => Some("KeyP"), + 0x51 => Some("KeyQ"), + 0x52 => Some("KeyR"), + 0x53 => Some("KeyS"), + 0x54 => Some("KeyT"), + 0x55 => Some("KeyU"), + 0x56 => Some("KeyV"), + 0x57 => Some("KeyW"), + 0x58 => Some("KeyX"), + 0x59 => Some("KeyY"), + 0x5A => Some("KeyZ"), + 0x60 => Some("Numpad0"), + 0x61 => Some("Numpad1"), + 0x62 => Some("Numpad2"), + 0x63 => Some("Numpad3"), + 0x64 => Some("Numpad4"), + 0x65 => Some("Numpad5"), + 0x66 => Some("Numpad6"), + 0x67 => Some("Numpad7"), + 0x68 => Some("Numpad8"), + 0x69 => Some("Numpad9"), + 0x70 => Some("F1"), + 0x71 => Some("F2"), + 0x72 => Some("F3"), + 0x73 => Some("F4"), + 0x74 => Some("F5"), + 0x75 => Some("F6"), + 0x76 => Some("F7"), + 0x77 => Some("F8"), + 0x78 => Some("F9"), + 0x79 => Some("F10"), + 0x7A => Some("F11"), + 0x7B => Some("F12"), + 0x7C => Some("F13"), + 0x7D => Some("F14"), + 0x7E => Some("F15"), + 0x7F => Some("F16"), + 0x80 => Some("F17"), + 0x81 => Some("F18"), + 0x82 => Some("F19"), + 0x83 => Some("F20"), + 0x84 => Some("F21"), + 0x85 => Some("F22"), + 0x86 => Some("F23"), + 0x87 => Some("F24"), + _ => None, } } @@ -659,13 +1005,13 @@ mod platform { use std::sync::Arc; use std::time::Duration; - use rdev::{listen, Event, EventType, Key}; + use rdev::{listen, Button, Event, EventType, Key}; use super::{ - install_error, start_listener_thread, update_shared_binding, HotkeyAdapter, HotkeyEvent, - Shared, StartupTx, + dispatch_hotkey_code, install_error, send_or_log, start_listener_thread, + update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, StartupTx, }; - use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; + use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError}; pub fn start_adapter( binding: HotkeyBinding, @@ -730,11 +1076,10 @@ mod platform { } fn dispatch_event(shared: &Shared, tx: &Sender, event: Event) { - let trigger = shared.binding.read().trigger; match event.event_type { EventType::KeyPress(key) => { if key == Key::Escape { - let _ = tx.send(HotkeyEvent::Cancelled); + send_or_log(tx, HotkeyEvent::Cancelled); return; } // Shift(任一侧)= 翻译模式修饰键。详见 issue #4。 @@ -743,15 +1088,11 @@ mod platform { .translation_modifier_held .swap(true, Ordering::SeqCst); if !was_held { - let _ = tx.send(HotkeyEvent::TranslationModifierPressed); + send_or_log(tx, HotkeyEvent::TranslationModifierPressed); } - return; } - if key == trigger_to_rdev_key(trigger) { - let was_held = shared.trigger_held.swap(true, Ordering::SeqCst); - if !was_held { - let _ = tx.send(HotkeyEvent::Pressed); - } + if let Some(code) = rdev_key_to_hotkey_code(key) { + dispatch_hotkey_code(shared, tx, code, true); } } EventType::KeyRelease(key) => { @@ -759,27 +1100,138 @@ mod platform { shared .translation_modifier_held .store(false, Ordering::SeqCst); - return; } - if key == trigger_to_rdev_key(trigger) { - let was_held = shared.trigger_held.swap(false, Ordering::SeqCst); - if was_held { - let _ = tx.send(HotkeyEvent::Released); - } + if let Some(code) = rdev_key_to_hotkey_code(key) { + dispatch_hotkey_code(shared, tx, code, false); + } + } + EventType::ButtonPress(button) => { + if let Some(code) = rdev_button_to_hotkey_code(button) { + dispatch_hotkey_code(shared, tx, code, true); + } + } + EventType::ButtonRelease(button) => { + if let Some(code) = rdev_button_to_hotkey_code(button) { + dispatch_hotkey_code(shared, tx, code, false); } } _ => {} } } - fn trigger_to_rdev_key(trigger: HotkeyTrigger) -> Key { - match trigger { - HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => Key::AltGr, - HotkeyTrigger::LeftOption => Key::Alt, - HotkeyTrigger::RightControl => Key::ControlRight, - HotkeyTrigger::LeftControl => Key::ControlLeft, - HotkeyTrigger::RightCommand => Key::MetaRight, - HotkeyTrigger::Fn => Key::Function, + fn rdev_button_to_hotkey_code(button: Button) -> Option<&'static str> { + match button { + Button::Unknown(4) | Button::Unknown(8) => Some("Mouse4"), + Button::Unknown(5) | Button::Unknown(9) => Some("Mouse5"), + _ => None, + } + } + + fn rdev_key_to_hotkey_code(key: Key) -> Option<&'static str> { + match key { + Key::Alt => Some("AltLeft"), + Key::AltGr => Some("AltRight"), + Key::Backspace => Some("Backspace"), + Key::CapsLock => Some("CapsLock"), + Key::ControlLeft => Some("ControlLeft"), + Key::ControlRight => Some("ControlRight"), + Key::Delete => Some("Delete"), + Key::DownArrow => Some("ArrowDown"), + Key::End => Some("End"), + Key::F1 => Some("F1"), + Key::F2 => Some("F2"), + Key::F3 => Some("F3"), + Key::F4 => Some("F4"), + Key::F5 => Some("F5"), + Key::F6 => Some("F6"), + Key::F7 => Some("F7"), + Key::F8 => Some("F8"), + Key::F9 => Some("F9"), + Key::F10 => Some("F10"), + Key::F11 => Some("F11"), + Key::F12 => Some("F12"), + Key::Home => Some("Home"), + Key::LeftArrow => Some("ArrowLeft"), + Key::MetaLeft => Some("MetaLeft"), + Key::MetaRight => Some("MetaRight"), + Key::PageDown => Some("PageDown"), + Key::PageUp => Some("PageUp"), + Key::Return => Some("Enter"), + Key::RightArrow => Some("ArrowRight"), + Key::ShiftLeft => Some("ShiftLeft"), + Key::ShiftRight => Some("ShiftRight"), + Key::Space => Some("Space"), + Key::Tab => Some("Tab"), + Key::UpArrow => Some("ArrowUp"), + Key::PrintScreen => Some("PrintScreen"), + Key::ScrollLock => Some("ScrollLock"), + Key::Pause => Some("Pause"), + Key::BackQuote => Some("Backquote"), + Key::Num0 => Some("Digit0"), + Key::Num1 => Some("Digit1"), + Key::Num2 => Some("Digit2"), + Key::Num3 => Some("Digit3"), + Key::Num4 => Some("Digit4"), + Key::Num5 => Some("Digit5"), + Key::Num6 => Some("Digit6"), + Key::Num7 => Some("Digit7"), + Key::Num8 => Some("Digit8"), + Key::Num9 => Some("Digit9"), + Key::Minus => Some("Minus"), + Key::Equal => Some("Equal"), + Key::KeyA => Some("KeyA"), + Key::KeyB => Some("KeyB"), + Key::KeyC => Some("KeyC"), + Key::KeyD => Some("KeyD"), + Key::KeyE => Some("KeyE"), + Key::KeyF => Some("KeyF"), + Key::KeyG => Some("KeyG"), + Key::KeyH => Some("KeyH"), + Key::KeyI => Some("KeyI"), + Key::KeyJ => Some("KeyJ"), + Key::KeyK => Some("KeyK"), + Key::KeyL => Some("KeyL"), + Key::KeyM => Some("KeyM"), + Key::KeyN => Some("KeyN"), + Key::KeyO => Some("KeyO"), + Key::KeyP => Some("KeyP"), + Key::KeyQ => Some("KeyQ"), + Key::KeyR => Some("KeyR"), + Key::KeyS => Some("KeyS"), + Key::KeyT => Some("KeyT"), + Key::KeyU => Some("KeyU"), + Key::KeyV => Some("KeyV"), + Key::KeyW => Some("KeyW"), + Key::KeyX => Some("KeyX"), + Key::KeyY => Some("KeyY"), + Key::KeyZ => Some("KeyZ"), + Key::LeftBracket => Some("BracketLeft"), + Key::RightBracket => Some("BracketRight"), + Key::SemiColon => Some("Semicolon"), + Key::Quote => Some("Quote"), + Key::BackSlash | Key::IntlBackslash => Some("Backslash"), + Key::Comma => Some("Comma"), + Key::Dot => Some("Period"), + Key::Slash => Some("Slash"), + Key::Insert => Some("Insert"), + Key::KpReturn => Some("NumpadEnter"), + Key::KpMinus => Some("NumpadSubtract"), + Key::KpPlus => Some("NumpadAdd"), + Key::KpMultiply => Some("NumpadMultiply"), + Key::KpDivide => Some("NumpadDivide"), + Key::Kp0 => Some("Numpad0"), + Key::Kp1 => Some("Numpad1"), + Key::Kp2 => Some("Numpad2"), + Key::Kp3 => Some("Numpad3"), + Key::Kp4 => Some("Numpad4"), + Key::Kp5 => Some("Numpad5"), + Key::Kp6 => Some("Numpad6"), + Key::Kp7 => Some("Numpad7"), + Key::Kp8 => Some("Numpad8"), + Key::Kp9 => Some("Numpad9"), + Key::KpDelete => Some("NumpadDecimal"), + Key::Function => Some("Fn"), + _ => None, } } } diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 50b2f078..fae9cc64 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -258,6 +258,7 @@ impl HotkeyTrigger { pub enum HotkeyMode { Toggle, Hold, + DoubleClick, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -278,11 +279,131 @@ impl HotkeyAdapterKind { } } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct HotkeyKey { + pub code: String, +} + +impl HotkeyKey { + pub fn new(code: impl Into) -> Self { + Self { code: code.into() } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default, rename_all = "camelCase")] pub struct HotkeyBinding { pub trigger: HotkeyTrigger, pub mode: HotkeyMode, + pub keys: Option>, +} + +impl HotkeyBinding { + pub fn effective_codes(&self) -> Vec { + let Some(keys) = &self.keys else { + return vec![legacy_trigger_code(self.trigger).to_string()]; + }; + keys.iter() + .map(|key| key.code.trim().to_string()) + .filter(|code| !code.is_empty()) + .collect() + } + + pub fn display_label(&self) -> String { + let codes = self.effective_codes(); + if codes.is_empty() { + return "未设置".to_string(); + } + codes + .iter() + .map(|code| display_hotkey_code(code)) + .collect::>() + .join("+") + } +} + +fn legacy_trigger_code(trigger: HotkeyTrigger) -> &'static str { + match trigger { + HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => "AltRight", + HotkeyTrigger::LeftOption => "AltLeft", + HotkeyTrigger::RightControl => "ControlRight", + HotkeyTrigger::LeftControl => "ControlLeft", + HotkeyTrigger::RightCommand => "MetaRight", + HotkeyTrigger::Fn => "Fn", + } +} + +fn display_hotkey_code(code: &str) -> String { + let label = match code { + "ControlLeft" => "左Ctrl", + "ControlRight" => "右 Control", + "AltLeft" => "左Alt", + "AltRight" => "右Alt", + "ShiftLeft" => "左Shift", + "ShiftRight" => "右Shift", + "MetaLeft" | "OSLeft" => "左Win", + "MetaRight" | "OSRight" => "右Win", + "Fn" => "Fn", + "FnLock" => "FnLock", + "CapsLock" => "CapsLock", + "ScrollLock" => "ScrLock", + "Pause" => "Pause", + "PrintScreen" => "PrtSc", + "Backspace" => "Backspace", + "Tab" => "Tab", + "Enter" => "Enter", + "Space" => "Space", + "Insert" => "Insert", + "Delete" => "Delete", + "Home" => "Home", + "End" => "End", + "PageUp" => "PageUp", + "PageDown" => "PageDown", + "ArrowUp" => "Up", + "ArrowDown" => "Down", + "ArrowLeft" => "Left", + "ArrowRight" => "Right", + "NumpadAdd" => "Num+", + "NumpadSubtract" => "Num-", + "NumpadMultiply" => "Num*", + "NumpadDivide" => "Num/", + "NumpadDecimal" => "Num.", + "NumpadEnter" => "NumEnter", + "Mouse4" => "Mouse4", + "Mouse5" => "Mouse5", + "Backquote" => "`", + "Minus" => "-", + "Equal" => "=", + "BracketLeft" => "[", + "BracketRight" => "]", + "Backslash" => "\\", + "Semicolon" => ";", + "Quote" => "'", + "Comma" => ",", + "Period" => ".", + "Slash" => "/", + _ => "", + }; + if !label.is_empty() { + return label.to_string(); + } + if let Some(letter) = code.strip_prefix("Key") { + if letter.len() == 1 { + return letter.to_string(); + } + } + if let Some(digit) = code.strip_prefix("Digit") { + if digit.len() == 1 { + return digit.to_string(); + } + } + if let Some(num) = code.strip_prefix("Numpad") { + if num.len() == 1 && num.as_bytes()[0].is_ascii_digit() { + return format!("Num{num}"); + } + } + code.to_string() } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -334,7 +455,7 @@ impl HotkeyCapability { supports_side_specific_modifiers: true, explicit_fallback_available: false, status_hint: Some( - "默认建议使用“右 Control + 切换式说话”;若更习惯按住说话,可在录音设置里切回。若无响应,可在权限页查看 hook 安装状态。" + "默认建议使用“右Ctrl + 单击”;若更习惯按住说话,可在录音设置里切回“按住”。若无响应,可在权限页查看 hook 安装状态。" .into(), ), }; @@ -427,6 +548,7 @@ impl Default for HotkeyBinding { Self { trigger: HotkeyTrigger::RightControl, mode: HotkeyMode::Toggle, + keys: Some(vec![HotkeyKey::new("ControlRight")]), } } @@ -435,6 +557,7 @@ impl Default for HotkeyBinding { Self { trigger: HotkeyTrigger::RightOption, mode: HotkeyMode::Toggle, + keys: Some(vec![HotkeyKey::new("AltRight")]), } } } @@ -511,4 +634,48 @@ mod tests { assert!(prefs.allow_non_tsf_insertion_fallback); } + + #[test] + fn legacy_hotkey_trigger_still_produces_effective_key_codes() { + let binding: HotkeyBinding = + serde_json::from_str(r#"{"trigger":"rightControl","mode":"toggle"}"#).unwrap(); + + assert_eq!(binding.effective_codes(), vec!["ControlRight".to_string()]); + assert_eq!(binding.display_label(), "右 Control"); + } + + #[test] + fn hotkey_binding_supports_combo_side_keys_mouse_and_double_click_mode() { + let binding = HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::DoubleClick, + keys: Some(vec![ + HotkeyKey::new("ControlLeft"), + HotkeyKey::new("AltLeft"), + HotkeyKey::new("Mouse4"), + ]), + }; + + assert_eq!( + binding.effective_codes(), + vec![ + "ControlLeft".to_string(), + "AltLeft".to_string(), + "Mouse4".to_string() + ] + ); + assert_eq!(binding.display_label(), "左Ctrl+左Alt+Mouse4"); + + let json = serde_json::to_value(&binding).unwrap(); + assert_eq!(json["mode"], "doubleClick"); + } + + #[test] + fn explicit_empty_hotkey_keys_clear_the_binding() { + let binding: HotkeyBinding = + serde_json::from_str(r#"{"trigger":"rightControl","mode":"toggle","keys":[]}"#) + .unwrap(); + + assert!(binding.effective_codes().is_empty()); + } } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index b4a3804a..36476876 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -84,7 +84,7 @@ export const en: typeof zhCN = { }, hotkeyModePrompt: { title: 'Review your recording mode', - body: 'This version now defaults to Toggle. If you changed the hotkey trigger mode before, please open Recording settings and confirm it once. This update also adjusts how the hotkey mode preference is read; if you prefer push-to-talk, switch it back manually.', + body: 'This version now defaults to Single. If you changed the hotkey trigger mode before, please open Recording settings and confirm it once. This update also adjusts how the hotkey mode preference is read; if you prefer hold-to-talk, switch it back to Hold.', later: 'Remind me later', openSettings: 'Open Recording', }, @@ -254,14 +254,20 @@ export const en: typeof zhCN = { title: 'Recording', desc: 'Define the global recording hotkey and how it triggers.', hotkeyLabel: 'Recording hotkey', - hotkeyDescAcc: 'Pressing it captures voice globally. Requires Accessibility permission.', - hotkeyDescNoAcc: 'Pressing it captures voice globally. No Accessibility permission required.', + hotkeyDescAcc: 'Choose single, hold, or double trigger; the hotkey can be left empty.', + hotkeyDescNoAcc: 'Choose single, hold, or double trigger; the hotkey can be left empty.', + hotkeyRecording: 'Press keys...', + hotkeyClear: 'Clear hotkey', + hotkeySetStatus: 'Set: {{hotkey}}', + hotkeyUnsetStatus: 'Not set', modeLabel: 'Trigger mode', - modeDesc: 'Toggle = press once to start, again to stop. Push-to-talk = hold to record, release to stop.', - modeToggle: 'Toggle', - modeHold: 'Push-to-talk', - migrationNoticeTitle: 'Default recording mode is now Toggle', - migrationNoticeDesc: 'If you changed the hotkey trigger mode before, please confirm it here once. This update changes both the default value and the preference-reading path; if you prefer push-to-talk, switch it back manually.', + keyLabel: 'Hotkey', + modeDesc: 'Single starts/stops recording. Hold starts on press and stops on release. Double requires two presses.', + modeToggle: 'Single', + modeHold: 'Hold', + modeDoubleClick: 'Double', + migrationNoticeTitle: 'Default recording mode is now Single', + migrationNoticeDesc: 'If you changed the hotkey trigger mode before, please confirm it here once. This update changes both the default value and the preference-reading path; if you prefer hold-to-talk, switch it back to Hold.', capsuleLabel: 'Recording capsule', capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording / transcribing.', restoreClipboardLabel: 'Restore clipboard after insert', @@ -485,10 +491,13 @@ export const en: typeof zhCN = { rightAlt: 'Right Alt', }, fallback: 'Global hotkey', - modeHoldSuffix: ' (push-to-talk)', - modeToggleSuffix: ' (start / stop)', + unset: 'Not set', + modeHoldSuffix: ' (hold)', + modeToggleSuffix: ' (single)', + modeDoubleClickSuffix: ' (double)', usageHold: 'Hold {{trigger}} to talk, release to stop.', usageToggle: 'Press {{trigger}} to start, press again to stop.', + usageDoubleClick: 'Double-click {{trigger}} to start or stop recording.', adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows low-level keyboard hook', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 99380f66..f170657d 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -82,7 +82,7 @@ export const zhCN = { }, hotkeyModePrompt: { title: '检查录音方式', - body: '本版本默认改为“切换式说话”。如果你之前改过快捷键触发方式,请到“录音”里手动确认一次。本次更新同时调整了快捷键方式的读取逻辑;如果你更习惯按住说话,可以重新切回“按住说话”。', + body: '本版本默认改为“单击”触发。如果你之前改过快捷键触发方式,请到“录音”里手动确认一次。本次更新同时调整了快捷键方式的读取逻辑;如果你更习惯按住说话,可以切回“按住”。', later: '稍后提醒', openSettings: '去录音设置', }, @@ -252,14 +252,20 @@ export const zhCN = { title: '录音', desc: '定义全局录音的快捷键与触发方式。', hotkeyLabel: '录音快捷键', - hotkeyDescAcc: '按下即开始捕获语音,全局生效。需要授予辅助功能权限。', - hotkeyDescNoAcc: '按下即开始捕获语音,全局生效。无需额外辅助功能授权。', - modeLabel: '录音方式', - modeDesc: '切换式 = 按一次开始、再按一次结束;按住说话 = 按住开始、松开结束。', - modeToggle: '切换式', - modeHold: '按住说话', - migrationNoticeTitle: '默认已改为切换式说话', - migrationNoticeDesc: '如果你之前改过快捷键触发方式,请在这里手动确认一次。本次更新调整了快捷键方式的默认值与读取逻辑;如果你更习惯按住说话,可以重新切回“按住说话”。', + hotkeyDescAcc: '选择单击、按住或双击触发;快捷键可留空。', + hotkeyDescNoAcc: '选择单击、按住或双击触发;快捷键可留空。', + hotkeyRecording: '请按键...', + hotkeyClear: '清除快捷键', + hotkeySetStatus: '已设置:{{hotkey}}', + hotkeyUnsetStatus: '未设置', + modeLabel: '触发方式', + keyLabel: '快捷键', + modeDesc: '单击开始/停止录音;按住为按下开始、松开结束;双击需要连续按两次。', + modeToggle: '单击', + modeHold: '按住', + modeDoubleClick: '双击', + migrationNoticeTitle: '默认已改为单击触发', + migrationNoticeDesc: '如果你之前改过快捷键触发方式,请在这里手动确认一次。本次更新调整了快捷键方式的默认值与读取逻辑;如果你更习惯按住说话,可以切回“按住”。', capsuleLabel: '录音胶囊', capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。', restoreClipboardLabel: '插入后恢复剪贴板', @@ -483,10 +489,13 @@ export const zhCN = { rightAlt: '右 Alt', }, fallback: '全局快捷键', - modeHoldSuffix: '(按住说话)', - modeToggleSuffix: '(开始 / 停止)', + unset: '未设置', + modeHoldSuffix: '(按住)', + modeToggleSuffix: '(单击)', + modeDoubleClickSuffix: '(双击)', usageHold: '按住 {{trigger}} 说话,松开结束。', usageToggle: '按 {{trigger}} 开始录音,再按一次结束。', + usageDoubleClick: '双击 {{trigger}} 开始或结束录音。', adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 低层键盘 hook', diff --git a/openless-all/app/src/lib/hotkey.ts b/openless-all/app/src/lib/hotkey.ts index 4819d8b1..e5123a91 100644 --- a/openless-all/app/src/lib/hotkey.ts +++ b/openless-all/app/src/lib/hotkey.ts @@ -7,16 +7,117 @@ export function getHotkeyTriggerLabel(trigger: HotkeyTrigger | null | undefined) } export function getHotkeyStartStopLabel(binding: HotkeyBinding | null | undefined): string { - const trigger = getHotkeyTriggerLabel(binding?.trigger); + const trigger = getHotkeyBindingLabel(binding); const suffix = binding?.mode === 'hold' ? i18n.t('hotkey.modeHoldSuffix') - : i18n.t('hotkey.modeToggleSuffix'); + : binding?.mode === 'doubleClick' + ? i18n.t('hotkey.modeDoubleClickSuffix') + : i18n.t('hotkey.modeToggleSuffix'); return `${trigger}${suffix}`; } export function getHotkeyUsageHint(binding: HotkeyBinding | null | undefined): string { - const trigger = getHotkeyTriggerLabel(binding?.trigger); - return binding?.mode === 'hold' - ? i18n.t('hotkey.usageHold', { trigger }) - : i18n.t('hotkey.usageToggle', { trigger }); + const trigger = getHotkeyBindingLabel(binding); + if (binding?.mode === 'hold') return i18n.t('hotkey.usageHold', { trigger }); + if (binding?.mode === 'doubleClick') return i18n.t('hotkey.usageDoubleClick', { trigger }); + return i18n.t('hotkey.usageToggle', { trigger }); +} + +export function getHotkeyBindingCodes(binding: HotkeyBinding | null | undefined): string[] { + if (!binding) return []; + if (Array.isArray(binding.keys)) { + return binding.keys.map(key => key.code.trim()).filter(Boolean); + } + const legacy = legacyTriggerCode(binding.trigger); + return legacy ? [legacy] : []; +} + +export function getHotkeyBindingLabel(binding: HotkeyBinding | null | undefined): string { + const codes = getHotkeyBindingCodes(binding); + if (codes.length === 0) return i18n.t('hotkey.unset'); + return codes.map(getHotkeyCodeLabel).join('+'); +} + +export function getHotkeyCodeLabel(code: string): string { + const zh = i18n.language.toLowerCase().startsWith('zh'); + const labels: Record = { + ControlLeft: zh ? '左Ctrl' : 'Left Ctrl', + ControlRight: zh ? '右Ctrl' : 'Right Ctrl', + AltLeft: zh ? '左Alt' : 'Left Alt', + AltRight: zh ? '右Alt' : 'Right Alt', + ShiftLeft: zh ? '左Shift' : 'Left Shift', + ShiftRight: zh ? '右Shift' : 'Right Shift', + MetaLeft: zh ? '左Win' : 'Left Win', + MetaRight: zh ? '右Win' : 'Right Win', + OSLeft: zh ? '左Win' : 'Left Win', + OSRight: zh ? '右Win' : 'Right Win', + Fn: 'Fn', + FnLock: 'FnLock', + CapsLock: 'CapsLock', + ScrollLock: 'ScrLock', + Pause: 'Pause', + PrintScreen: 'PrtSc', + Backspace: 'Backspace', + Tab: 'Tab', + Enter: 'Enter', + Space: 'Space', + Insert: 'Insert', + Delete: 'Delete', + Home: 'Home', + End: 'End', + PageUp: 'PageUp', + PageDown: 'PageDown', + ArrowUp: 'Up', + ArrowDown: 'Down', + ArrowLeft: 'Left', + ArrowRight: 'Right', + ContextMenu: 'Menu', + NumpadAdd: 'Num+', + NumpadSubtract: 'Num-', + NumpadMultiply: 'Num*', + NumpadDivide: 'Num/', + NumpadDecimal: 'Num.', + NumpadEnter: 'NumEnter', + Mouse4: 'Mouse4', + Mouse5: 'Mouse5', + Backquote: '`', + Minus: '-', + Equal: '=', + BracketLeft: '[', + BracketRight: ']', + Backslash: '\\', + Semicolon: ';', + Quote: "'", + Comma: ',', + Period: '.', + Slash: '/', + }; + if (labels[code]) return labels[code]; + const letter = code.match(/^Key([A-Z])$/); + if (letter) return letter[1]; + const digit = code.match(/^Digit([0-9])$/); + if (digit) return digit[1]; + const numpad = code.match(/^Numpad([0-9])$/); + if (numpad) return `Num${numpad[1]}`; + return code; +} + +function legacyTriggerCode(trigger: HotkeyTrigger | null | undefined): string | null { + switch (trigger) { + case 'rightOption': + case 'rightAlt': + return 'AltRight'; + case 'leftOption': + return 'AltLeft'; + case 'rightControl': + return 'ControlRight'; + case 'leftControl': + return 'ControlLeft'; + case 'rightCommand': + return 'MetaRight'; + case 'fn': + return 'Fn'; + default: + return null; + } } diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index e49824f8..5342eca0 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -38,7 +38,7 @@ export async function invokeOrMock( // ── Mock fixtures ────────────────────────────────────────────────────── const mockSettings: UserPreferences = { - hotkey: { trigger: 'rightControl', mode: 'toggle' }, + hotkey: { trigger: 'rightControl', mode: 'toggle', keys: [{ code: 'ControlRight' }] }, defaultMode: 'structured', enabledModes: ['raw', 'light', 'structured', 'formal'], launchAtLogin: false, @@ -60,7 +60,7 @@ const mockHotkeyCapability: HotkeyCapability = { supportsModifierOnlyTrigger: true, supportsSideSpecificModifiers: true, explicitFallbackAvailable: false, - statusHint: '默认建议使用“右 Control + 切换式说话”;若更习惯按住说话,可在录音设置里切回。若无响应,可在权限页查看 hook 安装状态。', + statusHint: '默认建议使用“右Ctrl + 单击”;若更习惯按住说话,可在录音设置里切回“按住”。若无响应,可在权限页查看 hook 安装状态。', }; const mockCredentialsStatus: CredentialsStatus = { diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 53b62037..da318394 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -38,11 +38,16 @@ export type HotkeyTrigger = | 'fn' | 'rightAlt'; -export type HotkeyMode = 'toggle' | 'hold'; +export type HotkeyMode = 'toggle' | 'hold' | 'doubleClick'; + +export interface HotkeyKey { + code: string; +} export interface HotkeyBinding { trigger: HotkeyTrigger; mode: HotkeyMode; + keys?: HotkeyKey[] | null; } export type HotkeyAdapterKind = 'macEventTap' | 'windowsLowLevel' | 'rdev'; diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index 511851b1..5a4b8b23 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { detectOS } from '../components/WindowChrome'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { getHotkeyBindingLabel } from '../lib/hotkey'; import { clearHistory, deleteHistoryEntry, listHistory } from '../lib/ipc'; import type { DictationSession, PolishMode } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; @@ -130,7 +130,7 @@ export function History() { {loading &&
{t('common.loading')}
} {!loading && filtered.length === 0 && (
- {t('history.empty', { trigger: getHotkeyTriggerLabel(hotkey?.trigger) })} + {t('history.empty', { trigger: getHotkeyBindingLabel(hotkey) })}
)} {filtered.map(s => ( diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 32ac32f0..e63940a1 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { getHotkeyBindingLabel } from '../lib/hotkey'; import { getCredentials, listHistory } from '../lib/ipc'; import type { CredentialsStatus, DictationSession, PolishMode } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; @@ -114,7 +114,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
{history.length === 0 && (
- {t('overview.recentEmpty', { trigger: getHotkeyTriggerLabel(hotkey?.trigger) })} + {t('overview.recentEmpty', { trigger: getHotkeyBindingLabel(hotkey) })}
)} {history.slice(0, 5).map(s => ( diff --git a/openless-all/app/src/pages/QaPanel.tsx b/openless-all/app/src/pages/QaPanel.tsx index 5703ac82..00261951 100644 --- a/openless-all/app/src/pages/QaPanel.tsx +++ b/openless-all/app/src/pages/QaPanel.tsx @@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'; import { marked } from 'marked'; import { getSettings, isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc'; import type { QaChatMessage, QaStatePayload, UserPreferences } from '../lib/types'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { getHotkeyBindingLabel } from '../lib/hotkey'; const SELECTION_PREVIEW_MAX = 60; @@ -123,7 +123,7 @@ export function QaPanel() { // webview,没有 HotkeySettingsContext;如果用户在主窗口改了录音键, // 浮窗里的 "{recordHotkey}" 文案必须立刻跟上,否则会一直停在旧值。 const prefsHandle = await listen('prefs:changed', event => { - setRecordHotkeyLabel(getHotkeyTriggerLabel(event.payload?.hotkey?.trigger)); + setRecordHotkeyLabel(getHotkeyBindingLabel(event.payload?.hotkey)); }); if (cancelled) { stateHandle(); @@ -174,7 +174,7 @@ export function QaPanel() { void getSettings() .then(prefs => { if (cancelled) return; - setRecordHotkeyLabel(getHotkeyTriggerLabel(prefs.hotkey?.trigger)); + setRecordHotkeyLabel(getHotkeyBindingLabel(prefs.hotkey)); }) .catch(err => { console.warn('[QaPanel] load hotkey label failed', err); diff --git a/openless-all/app/src/pages/SelectionAsk.tsx b/openless-all/app/src/pages/SelectionAsk.tsx index 98952160..79871321 100644 --- a/openless-all/app/src/pages/SelectionAsk.tsx +++ b/openless-all/app/src/pages/SelectionAsk.tsx @@ -11,7 +11,7 @@ import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { setQaHotkey } from '../lib/ipc'; import type { QaHotkeyBinding } from '../lib/types'; import { detectOS, type OS } from '../components/WindowChrome'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { getHotkeyBindingLabel } from '../lib/hotkey'; const QA_HOTKEY_DISABLED_ID = 'disabled' as const; @@ -83,7 +83,7 @@ export function SelectionAsk() { const os = detectOS(); const qaHotkeyPresets = getQaHotkeyPresets(os); const defaultHotkeyLabel = qaHotkeyPresets[0]?.label ?? '快捷键'; - const recordHotkeyLabel = getHotkeyTriggerLabel(hotkey?.trigger); + const recordHotkeyLabel = getHotkeyBindingLabel(hotkey); if (!prefs) { return ( diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 7ab68f90..c291c3fd 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -9,7 +9,7 @@ import { Icon } from '../components/Icon'; import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoUpdate'; import { APP_VERSION_LABEL } from '../lib/appVersion'; import { isHotkeyModeMigrationNoticeActive } from '../lib/hotkeyMigration'; -import { getHotkeyStartStopLabel, getHotkeyTriggerLabel } from '../lib/hotkey'; +import { getHotkeyBindingCodes, getHotkeyBindingLabel, getHotkeyCodeLabel, getHotkeyStartStopLabel } from '../lib/hotkey'; import { checkAccessibilityPermission, checkMicrophonePermission, @@ -28,6 +28,7 @@ import { } from '../lib/ipc'; import type { HotkeyCapability, + HotkeyBinding, HotkeyMode, HotkeyStatus, HotkeyTrigger, @@ -149,10 +150,17 @@ function RecordingSection() { ); } - const onTriggerChange = (trigger: HotkeyTrigger) => - savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, trigger } }); const onModeChange = (mode: HotkeyMode) => savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, mode } }); + const onHotkeyKeysChange = (codes: string[]) => + savePrefs({ + ...prefs, + hotkey: { + ...prefs.hotkey, + trigger: inferLegacyTrigger(codes, prefs.hotkey.trigger), + keys: codes.map(code => ({ code })), + }, + }); const onShowCapsuleChange = (showCapsule: boolean) => savePrefs({ ...prefs, showCapsule }); const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) => @@ -163,6 +171,7 @@ function RecordingSection() { const choices: Array<[HotkeyMode, string]> = [ ['toggle', t('settings.recording.modeToggle')], ['hold', t('settings.recording.modeHold')], + ['doubleClick', t('settings.recording.modeDoubleClick')], ]; const hotkeyDesc = capability.requiresAccessibilityPermission ? t('settings.recording.hotkeyDescAcc') @@ -192,39 +201,49 @@ function RecordingSection() {
)} - - - -
- {choices.map(([v, l]) => ( - - ))} +
+
+ {t('settings.recording.modeLabel')} +
+ {choices.map(([v, l]) => ( + + ))} +
+
+
+ {t('settings.recording.keyLabel')} + +
+
0 ? '#16813d' : 'var(--ol-ink-4)', + }} + > + {getHotkeyBindingCodes(prefs.hotkey).length > 0 + ? t('settings.recording.hotkeySetStatus', { hotkey: getHotkeyBindingLabel(prefs.hotkey) }) + : t('settings.recording.hotkeyUnsetStatus')} +
@@ -259,6 +278,204 @@ function RecordingSection() { // 不存进 prefs:autostart 状态由 OS 持有(mac LaunchAgent plist / linux .desktop / // windows HKCU\Run),prefs 缓存反而会与 OS 真相不一致。issue #194。 +function HotkeyRecorder({ + binding, + onCommit, +}: { + binding: HotkeyBinding; + onCommit: (codes: string[]) => void; +}) { + const { t } = useTranslation(); + const [recording, setRecording] = useState(false); + const [draftCodes, setDraftCodes] = useState([]); + const pressedRef = useRef>(new Set()); + const recordingRef = useRef(false); + + const resetRecording = () => { + recordingRef.current = false; + pressedRef.current.clear(); + setDraftCodes([]); + setRecording(false); + }; + + const commitCodes = (codes: string[]) => { + const ordered = orderHotkeyCodes(codes); + resetRecording(); + onCommit(ordered); + }; + + const startRecording = () => { + recordingRef.current = true; + pressedRef.current.clear(); + setDraftCodes([]); + setRecording(true); + }; + + useEffect(() => { + if (!recording) return undefined; + + const stopEvent = (event: Event) => { + event.preventDefault(); + event.stopPropagation(); + }; + + const onKeyDown = (event: KeyboardEvent) => { + stopEvent(event); + if (event.key === 'Escape' || event.code === 'Escape') { + resetRecording(); + return; + } + const code = normalizeKeyboardHotkeyCode(event); + if (!code) return; + pressedRef.current.add(code); + setDraftCodes(orderHotkeyCodes([...pressedRef.current])); + }; + + const onKeyUp = (event: KeyboardEvent) => { + stopEvent(event); + if (!recordingRef.current) return; + if (event.key === 'Escape' || event.code === 'Escape') { + resetRecording(); + return; + } + const codes = orderHotkeyCodes([...pressedRef.current]); + if (codes.length > 0) commitCodes(codes); + }; + + const onMouseDown = (event: MouseEvent) => { + const code = mouseButtonToHotkeyCode(event.button); + if (!code) return; + stopEvent(event); + pressedRef.current.add(code); + commitCodes([...pressedRef.current]); + }; + + window.addEventListener('keydown', onKeyDown, true); + window.addEventListener('keyup', onKeyUp, true); + window.addEventListener('mousedown', onMouseDown, true); + return () => { + window.removeEventListener('keydown', onKeyDown, true); + window.removeEventListener('keyup', onKeyUp, true); + window.removeEventListener('mousedown', onMouseDown, true); + }; + }, [recording]); + + const label = recording + ? draftCodes.length > 0 + ? draftCodes.map(getHotkeyCodeLabel).join('+') + : t('settings.recording.hotkeyRecording') + : getHotkeyBindingLabel(binding); + const hasKeys = getHotkeyBindingCodes(binding).length > 0; + + return ( +
+ +
+ ); +} + +function inferLegacyTrigger(codes: string[], fallback: HotkeyTrigger): HotkeyTrigger { + if (codes.includes('ControlRight')) return 'rightControl'; + if (codes.includes('ControlLeft')) return 'leftControl'; + if (codes.includes('AltRight')) return 'rightAlt'; + if (codes.includes('AltLeft')) return 'leftOption'; + if (codes.includes('MetaRight')) return 'rightCommand'; + if (codes.includes('Fn')) return 'fn'; + return fallback; +} + +function normalizeKeyboardHotkeyCode(event: KeyboardEvent): string | null { + if (event.key === 'Fn' || event.code === 'Fn') return 'Fn'; + if (event.key === 'FnLock' || event.code === 'FnLock') return 'FnLock'; + const code = event.code === 'OSLeft' ? 'MetaLeft' : event.code === 'OSRight' ? 'MetaRight' : event.code; + if (SUPPORTED_HOTKEY_CODES.has(code)) return code; + if (/^Key[A-Z]$/.test(code)) return code; + if (/^Digit[0-9]$/.test(code)) return code; + if (/^F([1-9]|1[0-9]|2[0-4])$/.test(code)) return code; + if (/^Numpad[0-9]$/.test(code)) return code; + return null; +} + +function mouseButtonToHotkeyCode(button: number): string | null { + if (button === 3) return 'Mouse4'; + if (button === 4) return 'Mouse5'; + return null; +} + +function orderHotkeyCodes(codes: string[]): string[] { + const seen = new Set(); + return codes + .filter(code => { + if (!code || seen.has(code)) return false; + seen.add(code); + return true; + }) + .sort((a, b) => hotkeyCodeRank(a) - hotkeyCodeRank(b)); +} + +function hotkeyCodeRank(code: string): number { + const index = HOTKEY_CODE_ORDER.indexOf(code); + if (index >= 0) return index; + if (/^Key[A-Z]$/.test(code)) return 100 + code.charCodeAt(3); + if (/^Digit[0-9]$/.test(code)) return 200 + Number(code.slice(5)); + if (/^F([1-9]|1[0-9]|2[0-4])$/.test(code)) return 300 + Number(code.slice(1)); + if (/^Numpad[0-9]$/.test(code)) return 400 + Number(code.slice(6)); + return 1000; +} + +const SUPPORTED_HOTKEY_CODES = new Set([ + 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight', + 'MetaLeft', 'MetaRight', 'CapsLock', 'ScrollLock', 'Pause', 'PrintScreen', + 'Backspace', 'Tab', 'Enter', 'Space', 'Insert', 'Delete', 'Home', 'End', + 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', + 'ContextMenu', 'NumpadAdd', 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', + 'NumpadDecimal', 'NumpadEnter', 'Backquote', 'Minus', 'Equal', 'BracketLeft', + 'BracketRight', 'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash', + 'Fn', 'FnLock', +]); + +const HOTKEY_CODE_ORDER = [ + 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight', + 'MetaLeft', 'MetaRight', 'Fn', 'FnLock', 'CapsLock', 'ScrollLock', 'Pause', + 'PrintScreen', 'Backspace', 'Tab', 'Enter', 'Space', 'Insert', 'Delete', 'Home', + 'End', 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', + 'ContextMenu', 'Backquote', 'Minus', 'Equal', 'BracketLeft', 'BracketRight', + 'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash', 'NumpadAdd', + 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', 'NumpadDecimal', 'NumpadEnter', + 'Mouse4', 'Mouse5', +]; + function AutostartRow() { const { t } = useTranslation(); const [enabled, setEnabled] = useState(false); @@ -842,6 +1059,79 @@ const miniBtnStyle: CSSProperties = { transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)', }; +const recordingHotkeyControlWidth = 178; + +const hotkeyRecorderButtonStyle: CSSProperties = { + width: recordingHotkeyControlWidth, + height: 32, + padding: '0 8px 0 11px', + border: '0.5px solid var(--ol-line-strong)', + borderRadius: 8, + background: 'var(--ol-surface-2)', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + fontFamily: 'var(--ol-font-mono)', + fontSize: 12.5, + cursor: 'default', + transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)', +}; + +const recordingHotkeySegmentedStyle: CSSProperties = { + width: recordingHotkeyControlWidth, + display: 'inline-flex', + padding: 2, + borderRadius: 8, + background: 'rgba(0,0,0,0.05)', +}; + +const recordingHotkeyGroupStyle: CSSProperties = { + display: 'grid', + gridTemplateColumns: 'auto', + rowGap: 10, + justifyItems: 'start', +}; + +const recordingHotkeyLineStyle: CSSProperties = { + display: 'grid', + gridTemplateColumns: '64px auto', + alignItems: 'center', + columnGap: 10, +}; + +const recordingHotkeyFieldLabelStyle: CSSProperties = { + fontSize: 12, + color: 'var(--ol-ink-4)', + textAlign: 'right', + whiteSpace: 'nowrap', +}; + +const recordingHotkeyStatusStyle: CSSProperties = { + marginLeft: 74, + fontSize: 12, + lineHeight: 1.3, +}; + +const hotkeyRecorderLabelStyle: CSSProperties = { + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}; + +const hotkeyClearButtonStyle: CSSProperties = { + width: 18, + height: 18, + borderRadius: 999, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + background: 'rgba(0,0,0,0.2)', + color: '#fff', +}; + const iconBtnStyle: CSSProperties = { width: 32, height: 32, border: '0.5px solid var(--ol-line-strong)', diff --git a/openless-all/app/src/pages/Translation.tsx b/openless-all/app/src/pages/Translation.tsx index 74d9759c..70c1293a 100644 --- a/openless-all/app/src/pages/Translation.tsx +++ b/openless-all/app/src/pages/Translation.tsx @@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'; import { Card, PageHeader } from './_atoms'; import { SUPPORTED_LANGUAGES } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { getHotkeyBindingLabel } from '../lib/hotkey'; export function Translation() { const { t } = useTranslation(); @@ -40,7 +40,7 @@ export function Translation() { const onTargetChange = (translationTargetLanguage: string) => savePrefs({ ...prefs, translationTargetLanguage }); - const triggerLabel = getHotkeyTriggerLabel(hotkey?.trigger); + const triggerLabel = getHotkeyBindingLabel(hotkey); const enabled = prefs.translationTargetLanguage.trim() !== ''; return ( From e7625c5eb51ff18db42923101c242584ea4df4fb Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 19:03:23 +0800 Subject: [PATCH 02/37] fix: address hotkey review feedback --- openless-all/app/src-tauri/src/coordinator.rs | 173 ++++++++++++++-- openless-all/app/src-tauri/src/hotkey.rs | 193 ++++++++++++------ 2 files changed, 289 insertions(+), 77 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 0b860916..c95ed14c 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -5,6 +5,7 @@ //! insertion, persists history, emits `capsule:state` events to the capsule //! window. +use std::collections::BTreeSet; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::sync::Arc; @@ -19,7 +20,7 @@ use crate::asr::{ DictionaryHotword, RawTranscript, VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR, }; -use crate::hotkey::{HotkeyEvent, HotkeyMonitor}; +use crate::hotkey::{binding_matches_pressed_codes, HotkeyEvent, HotkeyMonitor}; use crate::insertion::TextInserter; use crate::persistence::{ CredentialAccount, CredentialsVault, DictionaryStore, HistoryStore, PreferencesStore, @@ -130,6 +131,7 @@ struct Inner { hotkey_status: Mutex, hotkey_trigger_held: AtomicBool, hotkey_last_click_at: Mutex>, + window_hotkey_pressed_codes: Mutex>, /// 翻译模式触发标志。每次 begin_session 重置为 false;hotkey 监听器在 /// Listening / Starting 阶段看到 Shift down 边沿时 set true。 /// end_session 在调 polish/translate 前读这个 flag + translation_target_language @@ -226,6 +228,7 @@ impl Coordinator { hotkey_status: Mutex::new(HotkeyStatus::default()), hotkey_trigger_held: AtomicBool::new(false), hotkey_last_click_at: Mutex::new(None), + window_hotkey_pressed_codes: Mutex::new(BTreeSet::new()), translation_modifier_seen: AtomicBool::new(false), qa_hotkey: Mutex::new(None), qa_state: Mutex::new(QaSessionState::default()), @@ -374,6 +377,7 @@ impl Coordinator { } pub fn update_hotkey_binding(&self) { + self.inner.window_hotkey_pressed_codes.lock().clear(); if let Some(monitor) = self.inner.hotkey.lock().as_ref() { monitor.update_binding(self.inner.prefs.get().hotkey); } @@ -700,11 +704,14 @@ fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { } async fn handle_pressed_edge(inner: &Arc) { - let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst); + let was_held = inner.hotkey_trigger_held.load(Ordering::SeqCst); if !was_held { if !should_accept_pressed_edge(inner) { return; } + if inner.hotkey_trigger_held.swap(true, Ordering::SeqCst) { + return; + } // 路由:QA 浮窗可见时,rightOption 边沿走 QA;否则走主听写。详见 issue #118 v2。 let panel_visible = inner.qa_state.lock().panel_visible; if panel_visible { @@ -908,22 +915,41 @@ async fn handle_window_hotkey_event( return Ok(()); } - let binding = inner.prefs.get().hotkey; - if !window_key_matches_binding(&binding, &key, &code) { - return Ok(()); - } - match event_type.as_str() { "keydown" => { if repeat { return Ok(()); } - log::info!("[window-hotkey] pressed code={code} repeat={repeat}"); - handle_pressed_edge(inner).await; + let binding = inner.prefs.get().hotkey; + let Some((was_active, is_active)) = update_window_hotkey_pressed_codes( + &mut inner.window_hotkey_pressed_codes.lock(), + &binding, + &key, + &code, + true, + ) else { + return Ok(()); + }; + if is_active && !was_active { + log::info!("[window-hotkey] pressed code={code} repeat={repeat}"); + handle_pressed_edge(inner).await; + } } "keyup" => { - log::info!("[window-hotkey] released code={code}"); - handle_released_edge(inner).await; + let binding = inner.prefs.get().hotkey; + let Some((was_active, is_active)) = update_window_hotkey_pressed_codes( + &mut inner.window_hotkey_pressed_codes.lock(), + &binding, + &key, + &code, + false, + ) else { + return Ok(()); + }; + if was_active && !is_active { + log::info!("[window-hotkey] released code={code}"); + handle_released_edge(inner).await; + } } _ => {} } @@ -935,6 +961,31 @@ fn window_hotkey_fallback_enabled() -> bool { crate::types::HotkeyCapability::current().explicit_fallback_available } +#[cfg(any(target_os = "windows", test))] +fn update_window_hotkey_pressed_codes( + pressed_codes: &mut BTreeSet, + binding: &HotkeyBinding, + key: &str, + code: &str, + pressed: bool, +) -> Option<(bool, bool)> { + if !window_key_matches_binding(binding, key, code) { + return None; + } + + let normalized = normalize_window_hotkey_code(key, code); + let was_active = binding_matches_pressed_codes(binding, pressed_codes); + if pressed { + pressed_codes.insert(normalized); + } else { + pressed_codes.remove(&normalized); + } + Some(( + was_active, + binding_matches_pressed_codes(binding, pressed_codes), + )) +} + #[cfg(any(target_os = "windows", test))] fn window_key_matches_binding(binding: &HotkeyBinding, key: &str, code: &str) -> bool { let normalized = normalize_window_hotkey_code(key, code); @@ -2607,16 +2658,85 @@ mod tests { trigger: HotkeyTrigger::RightControl, ..Default::default() }; - assert!(window_key_matches_binding(&legacy, "Control", "ControlRight")); - assert!(!window_key_matches_binding(&legacy, "Control", "ControlLeft")); + assert!(window_key_matches_binding( + &legacy, + "Control", + "ControlRight" + )); + assert!(!window_key_matches_binding( + &legacy, + "Control", + "ControlLeft" + )); let caps_lock = HotkeyBinding { trigger: HotkeyTrigger::RightControl, keys: Some(vec![HotkeyKey::new("CapsLock")]), ..Default::default() }; - assert!(window_key_matches_binding(&caps_lock, "CapsLock", "CapsLock")); - assert!(!window_key_matches_binding(&caps_lock, "Control", "ControlRight")); + assert!(window_key_matches_binding( + &caps_lock, "CapsLock", "CapsLock" + )); + assert!(!window_key_matches_binding( + &caps_lock, + "Control", + "ControlRight" + )); + } + + #[test] + fn window_hotkey_fallback_requires_full_combo_before_activating() { + let binding = HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + keys: Some(vec![ + HotkeyKey::new("ControlLeft"), + HotkeyKey::new("AltLeft"), + HotkeyKey::new("Mouse4"), + ]), + ..Default::default() + }; + let mut pressed_codes = std::collections::BTreeSet::new(); + + assert_eq!( + update_window_hotkey_pressed_codes( + &mut pressed_codes, + &binding, + "Control", + "ControlLeft", + true, + ), + Some((false, false)) + ); + assert_eq!( + update_window_hotkey_pressed_codes( + &mut pressed_codes, + &binding, + "Alt", + "AltLeft", + true + ), + Some((false, false)) + ); + assert_eq!( + update_window_hotkey_pressed_codes( + &mut pressed_codes, + &binding, + "Mouse4", + "Mouse4", + true, + ), + Some((false, true)) + ); + assert_eq!( + update_window_hotkey_pressed_codes( + &mut pressed_codes, + &binding, + "Alt", + "AltLeft", + false + ), + Some((true, false)) + ); } #[test] @@ -2732,6 +2852,29 @@ mod tests { assert_eq!(state.session_id, 41); } + #[tokio::test] + async fn rejected_double_click_press_does_not_mark_trigger_held() { + let coordinator = Coordinator::new(); + coordinator + .inner + .prefs + .set(crate::types::UserPreferences { + hotkey: crate::types::HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::DoubleClick, + ..Default::default() + }, + ..Default::default() + }) + .unwrap(); + + handle_pressed_edge(&coordinator.inner).await; + + assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); + handle_released_edge(&coordinator.inner).await; + assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); + } + #[test] fn double_click_mode_requires_second_press_within_window() { let coordinator = Coordinator::new(); diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index c7ff68bc..f6315f1a 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -11,7 +11,7 @@ //! 仅产出"边沿"事件,toggle vs hold 由 Coordinator 解释。 use std::collections::BTreeSet; -use std::sync::atomic::AtomicBool; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Sender}; use std::sync::Arc; use std::time::Duration; @@ -166,7 +166,48 @@ fn dispatch_hotkey_code(shared: &Shared, tx: &Sender, code: &str, p } } -fn binding_matches_pressed_codes(binding: &HotkeyBinding, pressed_codes: &BTreeSet) -> bool { +fn dispatch_translation_modifier_code( + shared: &Shared, + tx: &Sender, + code: &str, + pressed: bool, +) { + if !is_shift_hotkey_code(code) { + return; + } + + let shift_is_hotkey = shared + .binding + .read() + .effective_codes() + .iter() + .any(|candidate| candidate == code); + if shift_is_hotkey { + return; + } + + if pressed { + let was_held = shared + .translation_modifier_held + .swap(true, Ordering::SeqCst); + if !was_held { + send_or_log(tx, HotkeyEvent::TranslationModifierPressed); + } + } else { + shared + .translation_modifier_held + .store(false, Ordering::SeqCst); + } +} + +fn is_shift_hotkey_code(code: &str) -> bool { + matches!(code, "ShiftLeft" | "ShiftRight") +} + +pub(crate) fn binding_matches_pressed_codes( + binding: &HotkeyBinding, + pressed_codes: &BTreeSet, +) -> bool { let codes = binding.effective_codes(); !codes.is_empty() && codes @@ -179,13 +220,13 @@ fn binding_matches_pressed_codes(binding: &HotkeyBinding, pressed_codes: &BTreeS #[cfg(target_os = "macos")] mod platform { use std::ffi::c_void; - use std::sync::atomic::Ordering; use std::sync::mpsc::Sender; use std::sync::Arc; use super::{ - dispatch_hotkey_code, install_error, send_or_log, start_listener_thread, - update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, StartupTx, + dispatch_hotkey_code, dispatch_translation_modifier_code, install_error, + is_shift_hotkey_code, send_or_log, start_listener_thread, update_shared_binding, + HotkeyAdapter, HotkeyEvent, Shared, StartupTx, }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError}; @@ -392,23 +433,17 @@ mod platform { fn handle_flags_changed(ctx: &CallbackContext, event: CgEventRef) { let flags = unsafe { CGEventGetFlags(event) }; - - // Shift 是翻译模式修饰键 — 与触发键的 keycode 检查独立,任何时刻按 Shift 都生效。 - let shift_active = (flags & FLAG_MASK_SHIFT) != 0; - let shift_was_held = ctx.shared.translation_modifier_held.load(Ordering::SeqCst); - if shift_active && !shift_was_held { - ctx.shared - .translation_modifier_held - .store(true, Ordering::SeqCst); - send_or_log(&ctx.tx, HotkeyEvent::TranslationModifierPressed); - } else if !shift_active && shift_was_held { - ctx.shared - .translation_modifier_held - .store(false, Ordering::SeqCst); - } - let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; if let Some(code) = mac_keycode_to_hotkey_code(keycode) { + if is_shift_hotkey_code(code) { + // Shift 作为录音热键成员时只参与热键匹配,不再额外切到翻译模式。 + dispatch_translation_modifier_code( + &ctx.shared, + &ctx.tx, + code, + (flags & FLAG_MASK_SHIFT) != 0, + ); + } if let Some(mask) = mac_keycode_flag_mask(keycode) { let family_active = (flags & mask) != 0; let code_was_pressed = ctx.shared.pressed_codes.read().contains(code); @@ -578,11 +613,67 @@ mod platform { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{HotkeyKey, HotkeyMode, HotkeyTrigger}; + + fn shared_with_binding(binding: HotkeyBinding) -> Shared { + Shared { + binding: RwLock::new(binding), + pressed_codes: RwLock::new(BTreeSet::new()), + trigger_held: AtomicBool::new(false), + translation_modifier_held: AtomicBool::new(false), + } + } + + #[test] + fn shift_hotkey_press_does_not_emit_translation_modifier() { + let shared = shared_with_binding(HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::Toggle, + keys: Some(vec![HotkeyKey::new("ShiftLeft")]), + }); + let (tx, rx) = mpsc::channel(); + + dispatch_translation_modifier_code(&shared, &tx, "ShiftLeft", true); + assert!(rx.try_recv().is_err()); + + dispatch_hotkey_code(&shared, &tx, "ShiftLeft", true); + assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Pressed))); + } + + #[test] + fn unbound_shift_press_still_emits_translation_modifier_once_per_hold() { + let shared = shared_with_binding(HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::Toggle, + keys: Some(vec![HotkeyKey::new("ControlRight")]), + }); + let (tx, rx) = mpsc::channel(); + + dispatch_translation_modifier_code(&shared, &tx, "ShiftLeft", true); + assert!(matches!( + rx.try_recv(), + Ok(HotkeyEvent::TranslationModifierPressed) + )); + + dispatch_translation_modifier_code(&shared, &tx, "ShiftLeft", true); + assert!(rx.try_recv().is_err()); + + dispatch_translation_modifier_code(&shared, &tx, "ShiftLeft", false); + dispatch_translation_modifier_code(&shared, &tx, "ShiftLeft", true); + assert!(matches!( + rx.try_recv(), + Ok(HotkeyEvent::TranslationModifierPressed) + )); + } +} + // ─────────────────────────── Windows implementation ─────────────────────────── #[cfg(target_os = "windows")] mod platform { - use std::sync::atomic::Ordering; use std::sync::atomic::{AtomicPtr, Ordering as AtomicOrdering}; use std::sync::mpsc::Sender; use std::sync::Arc; @@ -591,13 +682,14 @@ mod platform { use windows::Win32::System::Threading::GetCurrentThreadId; use windows::Win32::UI::WindowsAndMessaging::{ CallNextHookEx, DispatchMessageW, GetMessageW, PostThreadMessageW, SetWindowsHookExW, - TranslateMessage, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, - MSLLHOOKSTRUCT, MSG, WH_KEYBOARD_LL, WH_MOUSE_LL, WM_QUIT, + TranslateMessage, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, MSG, + MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL, WM_QUIT, }; use super::{ - dispatch_hotkey_code, install_error, send_or_log, start_listener_thread, - update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, StartupTx, + dispatch_hotkey_code, dispatch_translation_modifier_code, install_error, send_or_log, + start_listener_thread, update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, + StartupTx, }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError}; @@ -830,28 +922,11 @@ mod platform { return; } - // Shift(任一侧)= 翻译模式修饰键。在录音过程中任意时刻按下都生效。详见 issue #4。 - if matches!(vk_code, VK_SHIFT | VK_LSHIFT | VK_RSHIFT) { - match message { - WM_KEYDOWN | WM_SYSKEYDOWN => { - let was_held = ctx - .shared - .translation_modifier_held - .swap(true, Ordering::SeqCst); - if !was_held { - send_or_log(&ctx.tx, HotkeyEvent::TranslationModifierPressed); - } - } - WM_KEYUP | WM_SYSKEYUP => { - ctx.shared - .translation_modifier_held - .store(false, Ordering::SeqCst); - } - _ => {} - } - } - if let Some(code) = vk_to_hotkey_code(vk_code) { + if matches!(vk_code, VK_SHIFT | VK_LSHIFT | VK_RSHIFT) { + // Shift 作为录音热键成员时只参与热键匹配,不再额外切到翻译模式。 + dispatch_translation_modifier_code(&ctx.shared, &ctx.tx, code, is_down); + } if is_down { dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, true); } else if is_up { @@ -1008,8 +1083,9 @@ mod platform { use rdev::{listen, Button, Event, EventType, Key}; use super::{ - dispatch_hotkey_code, install_error, send_or_log, start_listener_thread, - update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, StartupTx, + dispatch_hotkey_code, dispatch_translation_modifier_code, install_error, send_or_log, + start_listener_thread, update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, + StartupTx, }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError}; @@ -1082,26 +1158,19 @@ mod platform { send_or_log(tx, HotkeyEvent::Cancelled); return; } - // Shift(任一侧)= 翻译模式修饰键。详见 issue #4。 - if matches!(key, Key::ShiftLeft | Key::ShiftRight) { - let was_held = shared - .translation_modifier_held - .swap(true, Ordering::SeqCst); - if !was_held { - send_or_log(tx, HotkeyEvent::TranslationModifierPressed); - } - } if let Some(code) = rdev_key_to_hotkey_code(key) { + if matches!(key, Key::ShiftLeft | Key::ShiftRight) { + // Shift 作为录音热键成员时只参与热键匹配,不再额外切到翻译模式。 + dispatch_translation_modifier_code(shared, tx, code, true); + } dispatch_hotkey_code(shared, tx, code, true); } } EventType::KeyRelease(key) => { - if matches!(key, Key::ShiftLeft | Key::ShiftRight) { - shared - .translation_modifier_held - .store(false, Ordering::SeqCst); - } if let Some(code) = rdev_key_to_hotkey_code(key) { + if matches!(key, Key::ShiftLeft | Key::ShiftRight) { + dispatch_translation_modifier_code(shared, tx, code, false); + } dispatch_hotkey_code(shared, tx, code, false); } } From b8607b21569383f7548cb95f3d4a79a687d5bea6 Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 19:05:48 +0800 Subject: [PATCH 03/37] refactor: clarify window hotkey fallback matching --- openless-all/app/src-tauri/src/coordinator.rs | 35 ++++++------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c95ed14c..6750fa88 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -969,11 +969,11 @@ fn update_window_hotkey_pressed_codes( code: &str, pressed: bool, ) -> Option<(bool, bool)> { - if !window_key_matches_binding(binding, key, code) { + let normalized = normalize_window_hotkey_code(key, code); + if !window_code_belongs_to_binding(binding, &normalized) { return None; } - let normalized = normalize_window_hotkey_code(key, code); let was_active = binding_matches_pressed_codes(binding, pressed_codes); if pressed { pressed_codes.insert(normalized); @@ -987,13 +987,12 @@ fn update_window_hotkey_pressed_codes( } #[cfg(any(target_os = "windows", test))] -fn window_key_matches_binding(binding: &HotkeyBinding, key: &str, code: &str) -> bool { - let normalized = normalize_window_hotkey_code(key, code); - !normalized.is_empty() +fn window_code_belongs_to_binding(binding: &HotkeyBinding, code: &str) -> bool { + !code.is_empty() && binding .effective_codes() .iter() - .any(|candidate| candidate == &normalized) + .any(|candidate| candidate == code) } #[cfg(any(target_os = "windows", test))] @@ -2653,35 +2652,21 @@ mod tests { } #[test] - fn window_key_matcher_accepts_legacy_and_configured_codes() { + fn window_code_filter_accepts_legacy_and_configured_codes() { let legacy = HotkeyBinding { trigger: HotkeyTrigger::RightControl, ..Default::default() }; - assert!(window_key_matches_binding( - &legacy, - "Control", - "ControlRight" - )); - assert!(!window_key_matches_binding( - &legacy, - "Control", - "ControlLeft" - )); + assert!(window_code_belongs_to_binding(&legacy, "ControlRight")); + assert!(!window_code_belongs_to_binding(&legacy, "ControlLeft")); let caps_lock = HotkeyBinding { trigger: HotkeyTrigger::RightControl, keys: Some(vec![HotkeyKey::new("CapsLock")]), ..Default::default() }; - assert!(window_key_matches_binding( - &caps_lock, "CapsLock", "CapsLock" - )); - assert!(!window_key_matches_binding( - &caps_lock, - "Control", - "ControlRight" - )); + assert!(window_code_belongs_to_binding(&caps_lock, "CapsLock")); + assert!(!window_code_belongs_to_binding(&caps_lock, "ControlRight")); } #[test] From 95a86fddd55b36e2a01881215196ef12cedbe76f Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 19:23:45 +0800 Subject: [PATCH 04/37] fix: preserve QA hotkey edge semantics --- openless-all/app/src-tauri/src/coordinator.rs | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 6750fa88..abc1a7f2 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -378,6 +378,7 @@ impl Coordinator { pub fn update_hotkey_binding(&self) { self.inner.window_hotkey_pressed_codes.lock().clear(); + *self.inner.hotkey_last_click_at.lock() = None; if let Some(monitor) = self.inner.hotkey.lock().as_ref() { monitor.update_binding(self.inner.prefs.get().hotkey); } @@ -706,14 +707,17 @@ fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { async fn handle_pressed_edge(inner: &Arc) { let was_held = inner.hotkey_trigger_held.load(Ordering::SeqCst); if !was_held { - if !should_accept_pressed_edge(inner) { + let panel_visible = inner.qa_state.lock().panel_visible; + if !panel_visible && !should_accept_pressed_edge(inner) { return; } + if panel_visible { + *inner.hotkey_last_click_at.lock() = None; + } if inner.hotkey_trigger_held.swap(true, Ordering::SeqCst) { return; } // 路由:QA 浮窗可见时,rightOption 边沿走 QA;否则走主听写。详见 issue #118 v2。 - let panel_visible = inner.qa_state.lock().panel_visible; if panel_visible { handle_qa_option_edge(inner).await; } else { @@ -2860,6 +2864,43 @@ mod tests { assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); } + #[tokio::test] + async fn qa_panel_press_bypasses_double_click_gate() { + let coordinator = Coordinator::new(); + coordinator + .inner + .prefs + .set(crate::types::UserPreferences { + hotkey: crate::types::HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::DoubleClick, + ..Default::default() + }, + ..Default::default() + }) + .unwrap(); + { + let mut qa_state = coordinator.inner.qa_state.lock(); + qa_state.panel_visible = true; + qa_state.phase = QaPhase::Processing; + } + + handle_pressed_edge(&coordinator.inner).await; + + assert!(coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); + assert!(coordinator.inner.hotkey_last_click_at.lock().is_none()); + } + + #[test] + fn hotkey_binding_update_resets_double_click_state() { + let coordinator = Coordinator::new(); + *coordinator.inner.hotkey_last_click_at.lock() = Some(Instant::now()); + + coordinator.update_hotkey_binding(); + + assert!(coordinator.inner.hotkey_last_click_at.lock().is_none()); + } + #[test] fn double_click_mode_requires_second_press_within_window() { let coordinator = Coordinator::new(); From 983b4126d7ac662868bc45b89063a37bb8261e03 Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 20:00:36 +0800 Subject: [PATCH 05/37] fix: stabilize hotkey recorder combos --- .../app/scripts/check-hotkey-recorder.mjs | 22 +++++ .../app/src/lib/hotkeyRecorder.test.ts | 85 +++++++++++++++++++ openless-all/app/src/lib/hotkeyRecorder.ts | 70 +++++++++++++++ openless-all/app/src/pages/Settings.tsx | 67 ++++++--------- 4 files changed, 203 insertions(+), 41 deletions(-) create mode 100644 openless-all/app/scripts/check-hotkey-recorder.mjs create mode 100644 openless-all/app/src/lib/hotkeyRecorder.test.ts create mode 100644 openless-all/app/src/lib/hotkeyRecorder.ts diff --git a/openless-all/app/scripts/check-hotkey-recorder.mjs b/openless-all/app/scripts/check-hotkey-recorder.mjs new file mode 100644 index 00000000..f14aff3a --- /dev/null +++ b/openless-all/app/scripts/check-hotkey-recorder.mjs @@ -0,0 +1,22 @@ +import * as esbuild from 'esbuild'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const tmp = await mkdtemp(join(tmpdir(), 'openless-hotkey-recorder-')); +const outfile = join(tmp, 'hotkey-recorder-test.mjs'); + +try { + await esbuild.build({ + entryPoints: [fileURLToPath(new URL('../src/lib/hotkeyRecorder.test.ts', import.meta.url))], + outfile, + bundle: true, + platform: 'node', + format: 'esm', + logLevel: 'silent', + }); + await import(pathToFileURL(outfile).href); +} finally { + await rm(tmp, { recursive: true, force: true }); +} diff --git a/openless-all/app/src/lib/hotkeyRecorder.test.ts b/openless-all/app/src/lib/hotkeyRecorder.test.ts new file mode 100644 index 00000000..a1a5841a --- /dev/null +++ b/openless-all/app/src/lib/hotkeyRecorder.test.ts @@ -0,0 +1,85 @@ +import { + createHotkeyRecorderState, + orderHotkeyCodes, + updateHotkeyRecorderState, +} from './hotkeyRecorder'; + +function assertEqual(actual: T, expected: T, name: string) { + if (actual !== expected) { + throw new Error(`${name}: expected ${expected}, got ${actual}`); + } +} + +function assertDeepEqual(actual: unknown, expected: unknown, name: string) { + const actualJson = JSON.stringify(actual); + const expectedJson = JSON.stringify(expected); + if (actualJson !== expectedJson) { + throw new Error(`${name}: expected ${expectedJson}, got ${actualJson}`); + } +} + +function apply( + state = createHotkeyRecorderState(), + code: string, + pressed: boolean, +) { + const next = updateHotkeyRecorderState(state, code, pressed); + return next; +} + +{ + let result = apply(undefined, 'ControlLeft', true); + assertDeepEqual(result.state.draftCodes, ['ControlLeft'], 'tracks first pressed key'); + assertEqual(result.commitCodes, null, 'does not commit until release'); + + result = apply(result.state, 'ControlLeft', false); + assertDeepEqual(result.commitCodes, ['ControlLeft'], 'commits single key on release'); + + result = apply(createHotkeyRecorderState(), 'KeyK', true); + assertDeepEqual(result.state.draftCodes, ['KeyK'], 'starts a new recording state cleanly'); + assertEqual(result.commitCodes, null, 'new keydown does not include old released keys'); +} + +{ + let result = apply(undefined, 'ControlLeft', true); + result = apply(result.state, 'KeyK', true); + assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'KeyK'], 'records keyboard combo draft'); + + result = apply(result.state, 'ControlLeft', false); + assertEqual(result.commitCodes, null, 'keyboard combo waits for final release'); + assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'KeyK'], 'released combo member stays in draft only'); + + result = apply(result.state, 'KeyK', false); + assertDeepEqual(result.commitCodes, ['ControlLeft', 'KeyK'], 'keyboard combo commits after all keys release'); + assertDeepEqual(result.state, createHotkeyRecorderState(), 'state resets after commit'); +} + +{ + let result = apply(undefined, 'Mouse4', true); + assertDeepEqual(result.state.draftCodes, ['Mouse4'], 'tracks mouse button as draft'); + assertEqual(result.commitCodes, null, 'mouse button does not commit on mousedown'); + + result = apply(result.state, 'ControlLeft', true); + assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'Mouse4'], 'records keyboard plus mouse combo'); + assertEqual(result.commitCodes, null, 'combo does not commit while inputs remain pressed'); + + result = apply(result.state, 'Mouse4', false); + assertEqual(result.commitCodes, null, 'releasing one combo member does not commit early'); + + result = apply(result.state, 'ControlLeft', false); + assertDeepEqual(result.commitCodes, ['ControlLeft', 'Mouse4'], 'commits combo after final release'); +} + +{ + let result = apply(undefined, 'ControlLeft', true); + result = apply(result.state, 'Mouse5', true); + assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'Mouse5'], 'records mouse button pressed after keyboard'); + + result = apply(result.state, 'ControlLeft', false); + assertEqual(result.commitCodes, null, 'mouse combo keeps waiting while mouse remains pressed'); + + result = apply(result.state, 'Mouse5', false); + assertDeepEqual(result.commitCodes, ['ControlLeft', 'Mouse5'], 'commits mouse-last combo after mouse release'); +} + +assertDeepEqual(orderHotkeyCodes(['Mouse4', 'ControlLeft']), ['ControlLeft', 'Mouse4'], 'orders mouse after modifiers'); diff --git a/openless-all/app/src/lib/hotkeyRecorder.ts b/openless-all/app/src/lib/hotkeyRecorder.ts new file mode 100644 index 00000000..8ab692f5 --- /dev/null +++ b/openless-all/app/src/lib/hotkeyRecorder.ts @@ -0,0 +1,70 @@ +export interface HotkeyRecorderState { + pressedCodes: string[]; + draftCodes: string[]; +} + +export interface HotkeyRecorderUpdate { + state: HotkeyRecorderState; + commitCodes: string[] | null; +} + +export function createHotkeyRecorderState(): HotkeyRecorderState { + return { + pressedCodes: [], + draftCodes: [], + }; +} + +export function updateHotkeyRecorderState( + state: HotkeyRecorderState, + code: string, + pressed: boolean, +): HotkeyRecorderUpdate { + const active = new Set(state.pressedCodes); + if (pressed) { + active.add(code); + } else { + active.delete(code); + } + + const pressedCodes = orderHotkeyCodes([...active]); + const draftCodes = pressed ? pressedCodes : state.draftCodes; + const shouldCommit = !pressed && pressedCodes.length === 0 && draftCodes.length > 0; + + return { + state: shouldCommit ? createHotkeyRecorderState() : { pressedCodes, draftCodes }, + commitCodes: shouldCommit ? draftCodes : null, + }; +} + +export function orderHotkeyCodes(codes: string[]): string[] { + const seen = new Set(); + return codes + .filter(code => { + if (!code || seen.has(code)) return false; + seen.add(code); + return true; + }) + .sort((a, b) => hotkeyCodeRank(a) - hotkeyCodeRank(b)); +} + +function hotkeyCodeRank(code: string): number { + const index = HOTKEY_CODE_ORDER.indexOf(code); + if (index >= 0) return index; + if (/^Key[A-Z]$/.test(code)) return 100 + code.charCodeAt(3); + if (/^Digit[0-9]$/.test(code)) return 200 + Number(code.slice(5)); + if (/^F([1-9]|1[0-9]|2[0-4])$/.test(code)) return 300 + Number(code.slice(1)); + if (/^Numpad[0-9]$/.test(code)) return 400 + Number(code.slice(6)); + return 1000; +} + +const HOTKEY_CODE_ORDER = [ + 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight', + 'MetaLeft', 'MetaRight', 'Fn', 'FnLock', 'CapsLock', 'ScrollLock', 'Pause', + 'PrintScreen', 'Backspace', 'Tab', 'Enter', 'Space', 'Insert', 'Delete', 'Home', + 'End', 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', + 'ContextMenu', 'Backquote', 'Minus', 'Equal', 'BracketLeft', 'BracketRight', + 'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash', 'NumpadAdd', + 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', 'NumpadDecimal', 'NumpadEnter', + 'Mouse4', 'Mouse5', +]; diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index a925ccee..50609408 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -9,6 +9,7 @@ import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoU import { APP_VERSION_LABEL } from '../lib/appVersion'; import { isHotkeyModeMigrationNoticeActive } from '../lib/hotkeyMigration'; import { getHotkeyBindingCodes, getHotkeyBindingLabel, getHotkeyCodeLabel, getHotkeyStartStopLabel } from '../lib/hotkey'; +import { createHotkeyRecorderState, orderHotkeyCodes, updateHotkeyRecorderState } from '../lib/hotkeyRecorder'; import { checkAccessibilityPermission, checkMicrophonePermission, @@ -303,12 +304,12 @@ function HotkeyRecorder({ const { t } = useTranslation(); const [recording, setRecording] = useState(false); const [draftCodes, setDraftCodes] = useState([]); - const pressedRef = useRef>(new Set()); + const recorderStateRef = useRef(createHotkeyRecorderState()); const recordingRef = useRef(false); const resetRecording = () => { recordingRef.current = false; - pressedRef.current.clear(); + recorderStateRef.current = createHotkeyRecorderState(); setDraftCodes([]); setRecording(false); }; @@ -321,7 +322,7 @@ function HotkeyRecorder({ const startRecording = () => { recordingRef.current = true; - pressedRef.current.clear(); + recorderStateRef.current = createHotkeyRecorderState(); setDraftCodes([]); setRecording(true); }; @@ -334,6 +335,14 @@ function HotkeyRecorder({ event.stopPropagation(); }; + const applyHotkeyCode = (code: string, pressed: boolean) => { + if (!recordingRef.current) return; + const next = updateHotkeyRecorderState(recorderStateRef.current, code, pressed); + recorderStateRef.current = next.state; + setDraftCodes(next.state.draftCodes); + if (next.commitCodes) commitCodes(next.commitCodes); + }; + const onKeyDown = (event: KeyboardEvent) => { stopEvent(event); if (event.key === 'Escape' || event.code === 'Escape') { @@ -342,8 +351,7 @@ function HotkeyRecorder({ } const code = normalizeKeyboardHotkeyCode(event); if (!code) return; - pressedRef.current.add(code); - setDraftCodes(orderHotkeyCodes([...pressedRef.current])); + applyHotkeyCode(code, true); }; const onKeyUp = (event: KeyboardEvent) => { @@ -353,25 +361,34 @@ function HotkeyRecorder({ resetRecording(); return; } - const codes = orderHotkeyCodes([...pressedRef.current]); - if (codes.length > 0) commitCodes(codes); + const code = normalizeKeyboardHotkeyCode(event); + if (!code) return; + applyHotkeyCode(code, false); }; const onMouseDown = (event: MouseEvent) => { const code = mouseButtonToHotkeyCode(event.button); if (!code) return; stopEvent(event); - pressedRef.current.add(code); - commitCodes([...pressedRef.current]); + applyHotkeyCode(code, true); + }; + + const onMouseUp = (event: MouseEvent) => { + const code = mouseButtonToHotkeyCode(event.button); + if (!code) return; + stopEvent(event); + applyHotkeyCode(code, false); }; window.addEventListener('keydown', onKeyDown, true); window.addEventListener('keyup', onKeyUp, true); window.addEventListener('mousedown', onMouseDown, true); + window.addEventListener('mouseup', onMouseUp, true); return () => { window.removeEventListener('keydown', onKeyDown, true); window.removeEventListener('keyup', onKeyUp, true); window.removeEventListener('mousedown', onMouseDown, true); + window.removeEventListener('mouseup', onMouseUp, true); }; }, [recording]); @@ -448,27 +465,6 @@ function mouseButtonToHotkeyCode(button: number): string | null { return null; } -function orderHotkeyCodes(codes: string[]): string[] { - const seen = new Set(); - return codes - .filter(code => { - if (!code || seen.has(code)) return false; - seen.add(code); - return true; - }) - .sort((a, b) => hotkeyCodeRank(a) - hotkeyCodeRank(b)); -} - -function hotkeyCodeRank(code: string): number { - const index = HOTKEY_CODE_ORDER.indexOf(code); - if (index >= 0) return index; - if (/^Key[A-Z]$/.test(code)) return 100 + code.charCodeAt(3); - if (/^Digit[0-9]$/.test(code)) return 200 + Number(code.slice(5)); - if (/^F([1-9]|1[0-9]|2[0-4])$/.test(code)) return 300 + Number(code.slice(1)); - if (/^Numpad[0-9]$/.test(code)) return 400 + Number(code.slice(6)); - return 1000; -} - const SUPPORTED_HOTKEY_CODES = new Set([ 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight', 'MetaLeft', 'MetaRight', 'CapsLock', 'ScrollLock', 'Pause', 'PrintScreen', @@ -480,17 +476,6 @@ const SUPPORTED_HOTKEY_CODES = new Set([ 'Fn', 'FnLock', ]); -const HOTKEY_CODE_ORDER = [ - 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight', - 'MetaLeft', 'MetaRight', 'Fn', 'FnLock', 'CapsLock', 'ScrollLock', 'Pause', - 'PrintScreen', 'Backspace', 'Tab', 'Enter', 'Space', 'Insert', 'Delete', 'Home', - 'End', 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', - 'ContextMenu', 'Backquote', 'Minus', 'Equal', 'BracketLeft', 'BracketRight', - 'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash', 'NumpadAdd', - 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', 'NumpadDecimal', 'NumpadEnter', - 'Mouse4', 'Mouse5', -]; - function AutostartRow() { const { t } = useTranslation(); const [enabled, setEnabled] = useState(false); From 6d3af7056d77342098f0e50ba17085563336fdb4 Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 20:27:05 +0800 Subject: [PATCH 06/37] fix: forward fallback hotkey edges --- .../scripts/check-window-hotkey-fallback.mjs | 22 +++++++++ openless-all/app/src-tauri/src/coordinator.rs | 5 -- openless-all/app/src-tauri/src/hotkey.rs | 48 +++++++++++++++++-- openless-all/app/src/App.tsx | 30 +++++++----- .../app/src/lib/windowHotkeyFallback.test.ts | 42 ++++++++++++++++ .../app/src/lib/windowHotkeyFallback.ts | 27 +++++++++++ 6 files changed, 153 insertions(+), 21 deletions(-) create mode 100644 openless-all/app/scripts/check-window-hotkey-fallback.mjs create mode 100644 openless-all/app/src/lib/windowHotkeyFallback.test.ts create mode 100644 openless-all/app/src/lib/windowHotkeyFallback.ts diff --git a/openless-all/app/scripts/check-window-hotkey-fallback.mjs b/openless-all/app/scripts/check-window-hotkey-fallback.mjs new file mode 100644 index 00000000..0891d4b2 --- /dev/null +++ b/openless-all/app/scripts/check-window-hotkey-fallback.mjs @@ -0,0 +1,22 @@ +import * as esbuild from 'esbuild'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const tmp = await mkdtemp(join(tmpdir(), 'openless-window-hotkey-fallback-')); +const outfile = join(tmp, 'window-hotkey-fallback-test.mjs'); + +try { + await esbuild.build({ + entryPoints: [fileURLToPath(new URL('../src/lib/windowHotkeyFallback.test.ts', import.meta.url))], + outfile, + bundle: true, + platform: 'node', + format: 'esm', + logLevel: 'silent', + }); + await import(pathToFileURL(outfile).href); +} finally { + await rm(tmp, { recursive: true, force: true }); +} diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index abc1a7f2..03b5f163 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -911,11 +911,6 @@ async fn handle_window_hotkey_event( #[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(()); } diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index f6315f1a..2932553c 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -204,6 +204,17 @@ fn is_shift_hotkey_code(code: &str) -> bool { matches!(code, "ShiftLeft" | "ShiftRight") } +#[cfg(any(target_os = "macos", test))] +fn dispatch_mac_caps_lock_edge(shared: &Shared, tx: &Sender) { + dispatch_hotkey_code(shared, tx, "CapsLock", true); + dispatch_hotkey_code(shared, tx, "CapsLock", false); +} + +#[cfg(any(target_os = "macos", test))] +fn mac_keycode_uses_modifier_flags(keycode: i64) -> bool { + matches!(keycode, 54 | 55 | 56 | 58 | 59 | 60 | 61 | 62 | 63) +} + pub(crate) fn binding_matches_pressed_codes( binding: &HotkeyBinding, pressed_codes: &BTreeSet, @@ -224,9 +235,10 @@ mod platform { use std::sync::Arc; use super::{ - dispatch_hotkey_code, dispatch_translation_modifier_code, install_error, - is_shift_hotkey_code, send_or_log, start_listener_thread, update_shared_binding, - HotkeyAdapter, HotkeyEvent, Shared, StartupTx, + dispatch_hotkey_code, dispatch_mac_caps_lock_edge, dispatch_translation_modifier_code, + install_error, is_shift_hotkey_code, mac_keycode_uses_modifier_flags, send_or_log, + start_listener_thread, update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, + StartupTx, }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError}; @@ -305,7 +317,6 @@ mod platform { const MOUSE_EVENT_BUTTON_NUMBER: CgEventField = 3; const KEYBOARD_EVENT_KEYCODE: CgEventField = 9; - const FLAG_MASK_ALPHA_SHIFT: CgEventFlags = 0x0001_0000; const FLAG_MASK_SHIFT: CgEventFlags = 0x0002_0000; const FLAG_MASK_CONTROL: CgEventFlags = 0x0004_0000; const FLAG_MASK_ALTERNATE: CgEventFlags = 0x0008_0000; @@ -434,6 +445,10 @@ mod platform { fn handle_flags_changed(ctx: &CallbackContext, event: CgEventRef) { let flags = unsafe { CGEventGetFlags(event) }; let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; + if keycode == 57 { + dispatch_mac_caps_lock_edge(&ctx.shared, &ctx.tx); + return; + } if let Some(code) = mac_keycode_to_hotkey_code(keycode) { if is_shift_hotkey_code(code) { // Shift 作为录音热键成员时只参与热键匹配,不再额外切到翻译模式。 @@ -490,10 +505,12 @@ mod platform { } fn mac_keycode_flag_mask(keycode: i64) -> Option { + if !mac_keycode_uses_modifier_flags(keycode) { + return None; + } match keycode { 54 | 55 => Some(FLAG_MASK_COMMAND), 56 | 60 => Some(FLAG_MASK_SHIFT), - 57 => Some(FLAG_MASK_ALPHA_SHIFT), 58 | 61 => Some(FLAG_MASK_ALTERNATE), 59 | 62 => Some(FLAG_MASK_CONTROL), 63 => Some(FLAG_MASK_SECONDARY_FN), @@ -668,6 +685,27 @@ mod tests { Ok(HotkeyEvent::TranslationModifierPressed) )); } + + #[test] + fn mac_caps_lock_flags_changed_dispatches_single_key_edge() { + let shared = shared_with_binding(HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::Toggle, + keys: Some(vec![HotkeyKey::new("CapsLock")]), + }); + let (tx, rx) = mpsc::channel(); + + dispatch_mac_caps_lock_edge(&shared, &tx); + + assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Pressed))); + assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Released))); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn mac_caps_lock_is_not_a_modifier_flag_code() { + assert!(!mac_keycode_uses_modifier_flags(57)); + } } // ─────────────────────────── Windows implementation ─────────────────────────── diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index b1baac39..fda671cf 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -10,6 +10,10 @@ import { handleWindowHotkeyEvent, isTauri, } from './lib/ipc'; +import { + isWindowHotkeyKeyboardCandidate, + windowMouseHotkeyCode, +} from './lib/windowHotkeyFallback'; import { QaPanel } from './pages/QaPanel'; import { HotkeySettingsProvider } from './state/HotkeySettingsContext'; @@ -110,7 +114,7 @@ export function App({ isCapsule, isQa }: AppProps) { useEffect(() => { if (!isTauri || os !== 'win') return; const forwardKey = (event: KeyboardEvent) => { - if (!isWindowHotkeyCandidate(event)) return; + if (!isWindowHotkeyKeyboardCandidate(event)) return; void handleWindowHotkeyEvent( event.type as 'keydown' | 'keyup', event.key, @@ -118,11 +122,25 @@ export function App({ isCapsule, isQa }: AppProps) { event.repeat, ).catch(error => console.warn('[window-hotkey] forward failed', error)); }; + const forwardMouse = (event: MouseEvent) => { + const code = windowMouseHotkeyCode(event.button); + if (!code) return; + void handleWindowHotkeyEvent( + event.type === 'mousedown' ? 'keydown' : 'keyup', + code, + code, + false, + ).catch(error => console.warn('[window-hotkey] mouse forward failed', error)); + }; window.addEventListener('keydown', forwardKey, true); window.addEventListener('keyup', forwardKey, true); + window.addEventListener('mousedown', forwardMouse, true); + window.addEventListener('mouseup', forwardMouse, true); return () => { window.removeEventListener('keydown', forwardKey, true); window.removeEventListener('keyup', forwardKey, true); + window.removeEventListener('mousedown', forwardMouse, true); + window.removeEventListener('mouseup', forwardMouse, true); }; }, [os]); @@ -136,16 +154,6 @@ export function App({ isCapsule, isQa }: AppProps) { ); } -function isWindowHotkeyCandidate(event: KeyboardEvent): boolean { - return ( - event.key === 'Escape' || - event.code === 'ControlRight' || - event.code === 'ControlLeft' || - event.code === 'AltRight' || - event.code === 'MetaRight' - ); -} - function StartupShell() { return (
(actual: T, expected: T, name: string) { + if (actual !== expected) { + throw new Error(`${name}: expected ${expected}, got ${actual}`); + } +} + +function keyboardEvent(code: string, key = code): KeyboardEvent { + return { code, key } as KeyboardEvent; +} + +assertEqual( + isWindowHotkeyKeyboardCandidate(keyboardEvent('KeyK', 'k')), + true, + 'fallback forwards letter hotkeys', +); + +assertEqual( + isWindowHotkeyKeyboardCandidate(keyboardEvent('CapsLock')), + true, + 'fallback forwards CapsLock hotkeys', +); + +assertEqual( + isWindowHotkeyKeyboardCandidate(keyboardEvent('F12')), + true, + 'fallback forwards function key hotkeys', +); + +assertEqual( + isWindowHotkeyKeyboardCandidate(keyboardEvent('Numpad7')), + true, + 'fallback forwards numpad digit hotkeys', +); + +assertEqual(windowMouseHotkeyCode(3), 'Mouse4', 'fallback maps Mouse4'); +assertEqual(windowMouseHotkeyCode(4), 'Mouse5', 'fallback maps Mouse5'); +assertEqual(windowMouseHotkeyCode(0), null, 'fallback ignores primary mouse button'); diff --git a/openless-all/app/src/lib/windowHotkeyFallback.ts b/openless-all/app/src/lib/windowHotkeyFallback.ts new file mode 100644 index 00000000..52a7804f --- /dev/null +++ b/openless-all/app/src/lib/windowHotkeyFallback.ts @@ -0,0 +1,27 @@ +export function isWindowHotkeyKeyboardCandidate(event: KeyboardEvent): boolean { + const code = event.code; + if (event.key === 'Escape' || code === 'Escape') return true; + if (SUPPORTED_WINDOW_HOTKEY_CODES.has(code)) return true; + if (/^Key[A-Z]$/.test(code)) return true; + if (/^Digit[0-9]$/.test(code)) return true; + if (/^F([1-9]|1[0-9]|2[0-4])$/.test(code)) return true; + if (/^Numpad[0-9]$/.test(code)) return true; + return false; +} + +export function windowMouseHotkeyCode(button: number): string | null { + if (button === 3) return 'Mouse4'; + if (button === 4) return 'Mouse5'; + return null; +} + +const SUPPORTED_WINDOW_HOTKEY_CODES = new Set([ + 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight', + 'MetaLeft', 'MetaRight', 'CapsLock', 'ScrollLock', 'Pause', 'PrintScreen', + 'Backspace', 'Tab', 'Enter', 'Space', 'Insert', 'Delete', 'Home', 'End', + 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', + 'ContextMenu', 'NumpadAdd', 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', + 'NumpadDecimal', 'NumpadEnter', 'Backquote', 'Minus', 'Equal', 'BracketLeft', + 'BracketRight', 'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash', + 'Fn', 'FnLock', +]); From edcc1eff6ee5cf92cdb3ab5c2648e19090a0e9a6 Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 20:38:50 +0800 Subject: [PATCH 07/37] fix: reset held hotkey state on binding update --- openless-all/app/src-tauri/src/coordinator.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 03b5f163..6ba4dbe4 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -379,6 +379,9 @@ impl Coordinator { pub fn update_hotkey_binding(&self) { self.inner.window_hotkey_pressed_codes.lock().clear(); *self.inner.hotkey_last_click_at.lock() = None; + self.inner + .hotkey_trigger_held + .store(false, Ordering::SeqCst); if let Some(monitor) = self.inner.hotkey.lock().as_ref() { monitor.update_binding(self.inner.prefs.get().hotkey); } @@ -2896,6 +2899,19 @@ mod tests { assert!(coordinator.inner.hotkey_last_click_at.lock().is_none()); } + #[test] + fn hotkey_binding_update_resets_held_trigger_state() { + let coordinator = Coordinator::new(); + coordinator + .inner + .hotkey_trigger_held + .store(true, Ordering::SeqCst); + + coordinator.update_hotkey_binding(); + + assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); + } + #[test] fn double_click_mode_requires_second_press_within_window() { let coordinator = Coordinator::new(); From 47c0da299b45b5b51ad0dd607170645f2226921a Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 21:08:36 +0800 Subject: [PATCH 08/37] fix: preserve CapsLock hold state on macOS --- openless-all/app/src-tauri/src/hotkey.rs | 69 ++++++++++++++++++++---- 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 2932553c..30e699c9 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -205,9 +205,17 @@ fn is_shift_hotkey_code(code: &str) -> bool { } #[cfg(any(target_os = "macos", test))] -fn dispatch_mac_caps_lock_edge(shared: &Shared, tx: &Sender) { - dispatch_hotkey_code(shared, tx, "CapsLock", true); - dispatch_hotkey_code(shared, tx, "CapsLock", false); +fn dispatch_mac_caps_lock_flags_changed( + shared: &Shared, + tx: &Sender, + alpha_shift_active: bool, +) { + if shared.binding.read().mode == crate::types::HotkeyMode::Hold { + dispatch_hotkey_code(shared, tx, "CapsLock", alpha_shift_active); + } else { + dispatch_hotkey_code(shared, tx, "CapsLock", true); + dispatch_hotkey_code(shared, tx, "CapsLock", false); + } } #[cfg(any(target_os = "macos", test))] @@ -235,10 +243,10 @@ mod platform { use std::sync::Arc; use super::{ - dispatch_hotkey_code, dispatch_mac_caps_lock_edge, dispatch_translation_modifier_code, - install_error, is_shift_hotkey_code, mac_keycode_uses_modifier_flags, send_or_log, - start_listener_thread, update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, - StartupTx, + dispatch_hotkey_code, dispatch_mac_caps_lock_flags_changed, + dispatch_translation_modifier_code, install_error, is_shift_hotkey_code, + mac_keycode_uses_modifier_flags, send_or_log, start_listener_thread, update_shared_binding, + HotkeyAdapter, HotkeyEvent, Shared, StartupTx, }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError}; @@ -317,6 +325,7 @@ mod platform { const MOUSE_EVENT_BUTTON_NUMBER: CgEventField = 3; const KEYBOARD_EVENT_KEYCODE: CgEventField = 9; + const FLAG_MASK_ALPHA_SHIFT: CgEventFlags = 0x0001_0000; const FLAG_MASK_SHIFT: CgEventFlags = 0x0002_0000; const FLAG_MASK_CONTROL: CgEventFlags = 0x0004_0000; const FLAG_MASK_ALTERNATE: CgEventFlags = 0x0008_0000; @@ -446,7 +455,11 @@ mod platform { let flags = unsafe { CGEventGetFlags(event) }; let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; if keycode == 57 { - dispatch_mac_caps_lock_edge(&ctx.shared, &ctx.tx); + dispatch_mac_caps_lock_flags_changed( + &ctx.shared, + &ctx.tx, + (flags & FLAG_MASK_ALPHA_SHIFT) != 0, + ); return; } if let Some(code) = mac_keycode_to_hotkey_code(keycode) { @@ -687,7 +700,7 @@ mod tests { } #[test] - fn mac_caps_lock_flags_changed_dispatches_single_key_edge() { + fn mac_caps_lock_toggle_mode_dispatches_click_edge_per_toggle() { let shared = shared_with_binding(HotkeyBinding { trigger: HotkeyTrigger::RightControl, mode: HotkeyMode::Toggle, @@ -695,13 +708,49 @@ mod tests { }); let (tx, rx) = mpsc::channel(); - dispatch_mac_caps_lock_edge(&shared, &tx); + dispatch_mac_caps_lock_flags_changed(&shared, &tx, true); assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Pressed))); assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Released))); assert!(rx.try_recv().is_err()); } + #[test] + fn mac_caps_lock_double_click_mode_dispatches_click_edge_per_toggle() { + let shared = shared_with_binding(HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::DoubleClick, + keys: Some(vec![HotkeyKey::new("CapsLock")]), + }); + let (tx, rx) = mpsc::channel(); + + dispatch_mac_caps_lock_flags_changed(&shared, &tx, true); + + assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Pressed))); + assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Released))); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn mac_caps_lock_hold_mode_tracks_toggle_state() { + let shared = shared_with_binding(HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::Hold, + keys: Some(vec![HotkeyKey::new("CapsLock")]), + }); + let (tx, rx) = mpsc::channel(); + + dispatch_mac_caps_lock_flags_changed(&shared, &tx, true); + + assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Pressed))); + assert!(rx.try_recv().is_err()); + + dispatch_mac_caps_lock_flags_changed(&shared, &tx, false); + + assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Released))); + assert!(rx.try_recv().is_err()); + } + #[test] fn mac_caps_lock_is_not_a_modifier_flag_code() { assert!(!mac_keycode_uses_modifier_flags(57)); From ce64e2e45015f03c39d0b5df9ed0f386bdff685b Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 21:27:06 +0800 Subject: [PATCH 09/37] fix: preserve legacy Fn hotkey on Windows --- openless-all/app/src-tauri/src/types.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index e861f0ee..4819437b 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -346,6 +346,9 @@ fn legacy_trigger_code(trigger: HotkeyTrigger) -> &'static str { HotkeyTrigger::RightControl => "ControlRight", HotkeyTrigger::LeftControl => "ControlLeft", HotkeyTrigger::RightCommand => "MetaRight", + #[cfg(target_os = "windows")] + HotkeyTrigger::Fn => "ControlRight", + #[cfg(not(target_os = "windows"))] HotkeyTrigger::Fn => "Fn", } } @@ -660,6 +663,15 @@ mod tests { assert_eq!(binding.display_label(), "右 Control"); } + #[cfg(target_os = "windows")] + #[test] + fn legacy_fn_trigger_uses_windows_control_right_alias() { + let binding: HotkeyBinding = + serde_json::from_str(r#"{"trigger":"fn","mode":"toggle"}"#).unwrap(); + + assert_eq!(binding.effective_codes(), vec!["ControlRight".to_string()]); + } + #[test] fn hotkey_binding_supports_combo_side_keys_mouse_and_double_click_mode() { let binding = HotkeyBinding { From 844c1da0985e80c1dc2a9b26db6a2d6aac6a4bbe Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 13:59:39 +0800 Subject: [PATCH 10/37] docs: design Windows local ASR --- .../2026-05-06-windows-local-asr-design.md | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-06-windows-local-asr-design.md diff --git a/docs/superpowers/specs/2026-05-06-windows-local-asr-design.md b/docs/superpowers/specs/2026-05-06-windows-local-asr-design.md new file mode 100644 index 00000000..8d2f9552 --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-windows-local-asr-design.md @@ -0,0 +1,248 @@ +# Windows 本地 ASR 设计 + +## 背景 + +OpenLess 的产品契约是:全局热键启动听写,胶囊显示录音状态,ASR 产出 transcript,现有 LLM provider 做润色、翻译或语义处理,再通过当前平台插入链路写回光标位置并保存历史。 + +Windows 新用户目前仍需要配置外部 ASR provider,才能完成真实听写。目标是在 Windows 上提供一个不依赖外部 ASR API Key 的本地识别方案,同时不调用 `Win+H`,不显示 Windows Voice Typing 系统面板,不绕开现有 polish、insert 和 history 流水线。 + +已确认的边界: + +- Windows `Win+H` / Voice Typing 是用户级系统功能,没有适合 OpenLess 嵌入并拿回 transcript 的公开 API。 +- `SendInput` 模拟 `Win+H` 只会打开系统面板,OpenLess 拿不到 transcript,也无法 polish 或写 history。 +- `Windows.Media.SpeechRecognition` 对普通 desktop app 的支持和授权路径不适合作为主线。 +- SAPI COM 可做 desktop dictation,但质量和现代体验不足以满足高品质目标。 + +## 官方资料核对 + +核对时间:2026-05-06。 + +Microsoft Learn 当前资料显示: + +- Foundry Local 是本地 AI runtime,支持 Windows、macOS Apple silicon 和 Linux,提供 C#、JavaScript、Rust、Python SDK;本地推理数据不离开设备,首次模型和执行 provider 下载仍需要网络。 +- Foundry Local catalog 覆盖 chat completion 和 audio transcription;音频转写示例明确使用 Whisper 模型。 +- Rust SDK 在 Windows 上使用 `foundry-local-sdk --features winml`,Windows 包集成 Windows ML runtime。 +- Rust native audio API 当前文档示例是:下载并 load Whisper 模型后 `model.create_audio_client()`,再调用 `audio_client.transcribe(file_path).await`。 +- Foundry Local 也能启动 OpenAI-compatible local REST service;REST endpoint `POST /v1/audio/transcriptions` 接收 multipart `file`、`model`,可选 `language`、`temperature`、`response_format`,返回 `text`。 +- REST service 的端口是动态分配,文档要求通过 SDK 暴露的 endpoint / urls 获取,不要硬编码。 +- CLI 是开发和管理辅助工具,不是应用集成主线;生产应用应使用 SDK 嵌入 runtime。 +- Foundry Local 仍是 preview,API、安装和分发方式可能变动。 + +主要来源: + +- https://learn.microsoft.com/en-us/azure/foundry-local/what-is-foundry-local +- https://learn.microsoft.com/en-us/azure/foundry-local/get-started +- https://learn.microsoft.com/en-us/azure/foundry-local/how-to/how-to-transcribe-audio +- https://learn.microsoft.com/en-us/azure/foundry-local/reference/reference-rest +- https://learn.microsoft.com/en-us/azure/foundry-local/reference/reference-sdk-current +- https://learn.microsoft.com/en-us/azure/foundry-local/how-to/how-to-use-foundry-local-cli +- https://learn.microsoft.com/en-us/azure/foundry-local/concepts/foundry-local-architecture + +## 目标 + +- Windows 新用户无需 Volcengine、Whisper HTTP、DashScope 等外部 ASR API Key,即可完成听写。 +- 不调用 `Win+H`,用户完全看不到 Windows Voice Typing 弹窗。 +- 现有交互不变:热键、OpenLess capsule、录音状态、转写、LLM polish / 翻译、插入、历史保存都走当前主流水线。 +- LLM polish 仍沿用用户配置的 OpenAI-compatible LLM provider;LLM 未配置或失败时插入原始 transcript。 +- 本地 ASR 缺 runtime / 模型时给出可操作引导,而不是静默失败。 +- 下载完成后可离线识别;首次模型 / execution provider 下载可以联网。 + +## 非目标 + +- 不把 Windows Voice Typing、SAPI 或系统听写面板嵌入 OpenLess。 +- 不在本阶段把 LLM polish 也改成本地模型;本设计只解决 ASR。 +- 不把大型模型直接打进默认 Windows 安装包,除非后续逐项确认模型 license、再分发条款、安装包体积和 updater 影响。 +- 不重写 Windows TSF IME 插入链路。 +- 不保证所有隔离目标窗口都能 TSF 上屏;现有 TSF / Unicode / clipboard fallback 策略继续负责插入可用性。 + +## 现有系统切入点 + +主听写状态机集中在 `openless-all/app/src-tauri/src/coordinator.rs`: + +- `ActiveAsr` 当前有 `Volcengine`、`Whisper`,以及 macOS-only `Local`。 +- `begin_session` 从 `CredentialsVault::get_active_asr()` 读取 active provider,再分流到 local Qwen3、OpenAI-compatible Whisper 或 Volcengine。 +- `end_session` 统一取得 `RawTranscript` 后,继续走 `polish_or_passthrough` / `translate_or_passthrough`、Windows TSF-first 插入和 history append。 +- `ensure_asr_credentials` 是录音前的 provider gate;本地 ASR 需要在这里改成“无需云凭据,但需要 runtime / model ready”。 +- `is_whisper_compatible_provider` 只覆盖云端 OpenAI-compatible `/audio/transcriptions` provider;Foundry Local 不应塞进这里,因为它需要 runtime / model lifecycle。 + +现有本地 ASR 模块在 `openless-all/app/src-tauri/src/asr/local/`: + +- provider id 是 `local-qwen3`,模型枚举是 `qwen3-asr-0.6b` / `qwen3-asr-1.7b`。 +- `LocalAsrCache` 目前只在 macOS 持有 `QwenAsrEngine`。 +- 下载页和 IPC 命令已覆盖 model status、下载、删除、test、preload、release,但 UI 文案和目录语义都强绑定 Qwen3-ASR。 +- Windows 端 `engine_available` 当前为 false,设置页提示“仅 macOS 已支持”。 + +Windows 插入链路已经满足本需求: + +- 会话开始时 `prepare_session()` 捕获当前输入法 profile 并临时激活 OpenLess TSF。 +- 会话结束时 `insert_with_windows_ime_first()` 通过 named pipe 把最终文本提交给 TSF DLL。 +- TSF DLL 在目标应用内调用 `ITfInsertAtSelection::InsertTextAtSelection`。 +- TSF 失败后按用户偏好走 Unicode `SendInput` 或 clipboard fallback。 + +## 推荐方案 + +新增 Windows-only provider:`foundry-local-whisper`。 + +实现上分两层: + +1. `FoundryLocalWhisperAsr`:形状对齐 `WhisperBatchASR` 和 `LocalQwenAsr`,实现 `AudioConsumer`,录音阶段 buffer 16 kHz mono i16 PCM,stop 后编码 WAV 并调用 Foundry Local。 +2. `FoundryLocalRuntime`:封装 Foundry Local SDK 的初始化、catalog 查询、execution provider 下载、模型下载、模型加载、endpoint 获取和卸载 / keep-loaded 管理。 + +MVP 调用路径建议先用 Foundry Local SDK 启动 local REST service,再调用 `/v1/audio/transcriptions`。原因: + +- OpenLess 已经有成熟的 multipart WAV 转写路径。 +- REST API 文档明确支持 `language` 参数,便于后续中文 / 中英混输策略调优。 +- SDK 仍负责动态端口、模型下载和加载,避免硬编码本地服务地址。 +- 后续如果 Rust native audio client 提供足够参数和稳定 API,可以把 REST 调用替换为纯 native audio client。 + +## Provider 与模型命名 + +新增 id: + +- `foundry-local-whisper`:Windows 主线本地 ASR。 + +模型别名: + +- 默认:`whisper-small`。 +- 低配选项:`whisper-base`。 +- 调试选项:`whisper-tiny`。 + +默认不强制 `language=zh`。中英混输时让 Whisper 自动检测更稳,避免英文产品名、代码词或中英夹杂被错误归入单一中文模式。后续可在高级设置里增加“优先中文识别”,仅用户明确选择时传 `language=zh`。 + +不要把 `foundry-local-whisper` 混入现有 `local-qwen3` provider。两者模型来源、runtime、平台支持和下载语义不同,应共享“本地 ASR 管理”页面的外壳,但后端 provider 和模型 registry 要分开。 + +## 会话时序 + +1. 用户按当前 OpenLess 全局热键。 +2. `Coordinator` 进入 `Starting`,Windows 侧准备 TSF IME session。 +3. `ensure_asr_credentials` 识别 active provider 是 `foundry-local-whisper`: + - runtime 可用且模型已缓存:继续; + - 模型未缓存:返回可操作错误,胶囊显示“请先下载本地语音模型”,不开始录音; + - runtime 初始化失败:显示“本地语音运行时不可用”,引导设置页。 +4. 创建 `FoundryLocalWhisperAsr`,把它作为 `AudioConsumer` 传给 `Recorder::start`。 +5. 录音期间 recorder 继续向 consumer 推 PCM,capsule 继续显示电平。 +6. 用户再次按热键或松开热键结束录音。 +7. `end_session` 停 recorder,调用 `FoundryLocalWhisperAsr::transcribe()`: + - PCM buffer 编码成临时 WAV; + - 确保模型 loaded; + - 通过 SDK endpoint 调 `/v1/audio/transcriptions`; + - 解析 `{ text }` 为 `RawTranscript`。 +8. 后续完全复用现有逻辑:空 transcript guard、polish / translate、Chinese script preference、Windows TSF-first insert、history append、capsule Done。 + +## 首次使用 UX + +Windows 新用户默认 active ASR 使用 `foundry-local-whisper`,但只在“没有现有 preferences / credentials active ASR”的新安装路径生效,不覆盖老用户。 + +设置页增加或改造“本地语音识别”区: + +- 显示 runtime 状态:可用、初始化中、不可用。 +- 显示 execution provider 状态:已注册、需要下载、下载中、失败。 +- 显示模型列表:`whisper-small`、`whisper-base`、`whisper-tiny`,尺寸和 license 从 Foundry catalog / REST metadata 获取。 +- 提供一键下载 / 取消 / 删除 / 设为默认 / 加载并测试。 +- 下载完成后后台 preload,减少第一次热键录音结束后的等待。 + +首次按热键但模型缺失时: + +- 不调用 Win+H。 +- 不弹系统 Voice Typing。 +- 不开始录音,避免用户说完才发现没有模型。 +- capsule 显示短错误,主窗口跳到本地语音识别页或给出“下载模型”入口。 + +## 质量与性能评估 + +中文 / 中英混输: + +- Whisper 系列对普通话和英文都可用,但 `tiny/base/small` 本地模型质量通常低于云端大模型 ASR 或 Whisper large。 +- `whisper-small` 更适合作为默认质量档;`whisper-base` 用于低配机器。 +- 热词 bias 当前不会直接进入 Whisper 解码;词汇表仍可作为 LLM polish 上下文和 history 命中统计使用。 + +首次延迟: + +- 首次下载 execution provider 和模型可能需要数分钟,取决于网络和硬件。 +- 首次 load 模型可能需要数秒;应在切换 provider / 下载完成后后台 preload。 +- 单次转写是 batch 型,不是 Volcengine 那种 streaming final;capsule 可保持“转写中”直到返回。 + +模型体积: + +- 体积不硬编码。UI 通过 Foundry catalog / REST metadata 显示当前真实 `fileSizeMb`。 +- 安装包不内置模型,避免 release artifact 暴涨和 license 风险。 + +离线能力: + +- 模型和 execution provider 下载完成后,ASR 推理可离线。 +- LLM polish 仍取决于用户配置的 LLM provider;LLM 不可用时按现有规则插入 raw transcript。 + +隐私: + +- ASR 音频在本机处理,不发送到外部 ASR 服务。 +- 首次下载模型和组件会访问 Foundry catalog / Microsoft 分发源。 +- LLM polish 仍可能把 transcript 发送到用户配置的 LLM endpoint;设置页文案需要明确区分“ASR 本地”和“LLM 仍按配置调用”。 + +## Windows 安装器与分发 + +MVP 不修改 Windows TSF IME 注册流程。 + +需要验证: + +- `foundry-local-sdk --features winml` 在 Tauri Windows build 中会引入哪些 DLL、runtime 文件和 redistributable 要求。 +- NSIS / MSI 是否能自动收集这些 native 依赖。 +- Windows release workflow 当前对 NSIS / MSI 有固定红线,不能把 bundler 两轮 invoke、`-sice:ICE80` repair 或 `bash` shell 约束顺手改掉。 +- 如果 Foundry Local runtime 需要额外安装或动态下载组件,UI 必须把“正在准备本地语音运行时”作为一键流程的一部分,而不是要求用户手动跑 `winget`。 + +## 失败与 fallback + +- Foundry runtime 缺失或初始化失败:不开始录音,提示本地语音运行时不可用,保留用户切回云 ASR 的入口。 +- 模型未下载:不开始录音,提示下载模型。 +- 模型下载失败:保留 partial / retry 状态,不切换到 Win+H。 +- 转写超时:沿用 coordinator global timeout,写失败状态,不插入空文本。 +- 转写返回空:沿用 `emptyTranscript` history guard。 +- LLM polish 失败:插入 raw transcript,history 标记 `polishFailed`。 +- TSF 提交失败:按现有 `allow_non_tsf_insertion_fallback` 走 Unicode / clipboard fallback;关闭 fallback 时标记 `windowsImeTsfRequired`。 + +## 文件与模块边界 + +后续实现计划触碰范围: + +- `openless-all/app/src-tauri/Cargo.toml`:Windows 依赖增加 Foundry Local Rust SDK,必要时启用 `winml` feature。 +- `openless-all/app/src-tauri/src/asr/local/`:拆出 provider-neutral local ASR registry,新增 Foundry Whisper runtime / provider;保留 macOS Qwen3 代码。 +- `openless-all/app/src-tauri/src/coordinator.rs`:扩展 `ActiveAsr`,在 `begin_session` 和 `end_session` 分支接入 `FoundryLocalWhisperAsr`。 +- `openless-all/app/src-tauri/src/commands.rs`:新增 Windows local Whisper runtime/model status、download、test、preload 命令,或把现有 `local_asr_*` 扩展成多 backend。 +- `openless-all/app/src-tauri/src/types.rs`:新增 Windows local ASR preferences,如 active Foundry Whisper model、keep-loaded 时长、语言 hint。 +- `openless-all/app/src/lib/localAsr.ts`、`src/pages/LocalAsr.tsx`、`src/pages/Settings.tsx`、`src/i18n/*`:展示 Windows 本地语音识别和模型管理。 +- `openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1`:增加 local ASR 模式,不再强制 Volcengine 凭据。 + +Rust 叶子模块仍只依赖 `types.rs` 和自身 provider 内部类型。跨模块编排继续放在 `coordinator.rs`。 + +## 验证计划 + +静态与单元验证: + +- `asr_configured_for_provider("foundry-local-whisper")` 返回 true,不要求云端 API Key。 +- `ensure_asr_credentials` 对模型缺失返回明确错误。 +- fake Foundry endpoint 返回 `{ "text": "..." }` 时,`FoundryLocalWhisperAsr` 能把 PCM 编成 WAV 并产出 `RawTranscript`。 +- model id、provider id、prefs default 的序列化和迁移测试。 + +集成验证: + +- Windows 真机启动 OpenLess,active ASR 为 `foundry-local-whisper`,未配置 Volcengine / Whisper HTTP。 +- 首次缺模型时按热键,不出现 Win+H 面板,不开始录音,提示下载模型。 +- 下载模型后聚焦 Notepad,按热键录音,说测试短句,结束后 history 新增 session,`rawTranscript` 非空,`finalText` 非空。 +- Ark / LLM 未配置时,最终插入 raw transcript,并按现有 polish fallback 规则记录。 +- Ark / LLM 已配置时,transcript 进入现有 polish / translation 逻辑。 +- Windows TSF IME 已安装时 `insertStatus=inserted`;禁用 TSF 或目标不支持时按当前 fallback 策略表现。 +- 断网后重复已下载模型的听写,ASR 仍可完成;LLM 不可用时 raw transcript 不丢。 + +No Win+H 验证: + +- 代码搜索确认没有 `Win+H`、Voice Typing、`Windows.Media.SpeechRecognition`、SAPI dictation 调用路径。 +- 真机 smoke 过程中截图或窗口枚举确认没有 Voice Typing 面板窗口。 +- 日志只出现 OpenLess recorder、Foundry local ASR、polish、Windows IME / fallback 插入事件。 + +## 开放风险 + +- Foundry Local preview API 可能变化,尤其是 Rust audio client 和 WinML package 分发。 +- Foundry Local 的 Whisper 模型质量和中文标点风格需要真机样本验证,不能只靠官方能力声明。 +- 首次 execution provider 下载和模型下载的错误码、进度回调、缓存位置需要实测。 +- Windows installer 对 SDK native 依赖的收集需要 release workflow 验证。 +- 如果 Foundry Local runtime 无法在 Tauri app 内稳定嵌入,备选路线是用 SDK 管理 local REST service;若 REST 也不稳定,再评估 `whisper.cpp` / ONNX Runtime 自管路线。 + From 9ebedebf7d0410b8f063fc39f6871d10b0e91e09 Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 14:14:58 +0800 Subject: [PATCH 11/37] docs: plan Windows local ASR --- .../plans/2026-05-06-windows-local-asr.md | 1396 +++++++++++++++++ 1 file changed, 1396 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-06-windows-local-asr.md diff --git a/docs/superpowers/plans/2026-05-06-windows-local-asr.md b/docs/superpowers/plans/2026-05-06-windows-local-asr.md new file mode 100644 index 00000000..01785022 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-windows-local-asr.md @@ -0,0 +1,1396 @@ +# Windows Local ASR Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Windows-only `foundry-local-whisper` ASR provider so new Windows users can dictate through OpenLess without external ASR keys or Windows Win+H Voice Typing. + +**Architecture:** Keep `coordinator::Coordinator` as the single owner of dictation state. Add a Windows Foundry Local Whisper provider that buffers existing recorder PCM, transcribes it locally, then returns `RawTranscript` into the existing polish, Windows TSF IME insertion, and history pipeline. + +**Tech Stack:** Tauri 2, Rust, React/TypeScript, Foundry Local Rust SDK, reqwest multipart REST call to local `/v1/audio/transcriptions`, existing Windows TSF IME backend. + +--- + +## File Map + +- Modify `openless-all/app/src-tauri/Cargo.toml`: add Windows-only Foundry Local SDK dependency after a compile probe. +- Create `openless-all/app/src-tauri/src/asr/wav.rs`: shared WAV encoder for Whisper HTTP and Foundry Local. +- Modify `openless-all/app/src-tauri/src/asr/mod.rs`: export `wav` and Windows Foundry Local modules. +- Modify `openless-all/app/src-tauri/src/asr/whisper.rs`: use the shared WAV encoder. +- Create `openless-all/app/src-tauri/src/asr/local/foundry.rs`: provider id, model registry, runtime status structs, and Windows runtime/proxy exports. +- Create `openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs`: Windows-only Foundry Local SDK wrapper for model status, download, load, endpoint discovery, and local transcription. +- Create `openless-all/app/src-tauri/src/asr/local/foundry_provider.rs`: `FoundryLocalWhisperAsr` implementing `AudioConsumer` and producing `RawTranscript`. +- Modify `openless-all/app/src-tauri/src/asr/local/mod.rs`: keep Qwen3 macOS exports and add Foundry Whisper exports. +- Modify `openless-all/app/src-tauri/src/types.rs`: add Windows local ASR preferences and Windows default provider. +- Modify `openless-all/app/src-tauri/src/persistence.rs`: align credentials active ASR default with Windows local ASR for new installs. +- Modify `openless-all/app/src-tauri/src/commands.rs`: expose Foundry Local settings/status/download/test commands and ASR credential status. +- Modify `openless-all/app/src-tauri/src/lib.rs`: manage a shared Foundry Local runtime and register commands. +- Modify `openless-all/app/src-tauri/src/coordinator.rs`: add `ActiveAsr::FoundryLocalWhisper`, provider startup, transcribe branch, timeout, cancel, and preload/release hooks. +- Modify `openless-all/app/src/lib/localAsr.ts`: add Foundry Local IPC types and wrapper functions. +- Modify `openless-all/app/src/lib/types.ts` and `openless-all/app/src/lib/ipc.ts`: add preferences/mock defaults. +- Modify `openless-all/app/src/pages/Settings.tsx`: add `foundry-local-whisper` provider preset and local ASR hint behavior. +- Modify `openless-all/app/src/pages/LocalAsr.tsx`: show Windows Foundry Local model/runtime controls alongside macOS Qwen3. +- Modify `openless-all/app/src/i18n/zh-CN.ts` and `openless-all/app/src/i18n/en.ts`: add user-facing strings. +- Modify `openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1`: add a local ASR mode that does not require Volcengine credentials. + +## Implementation Tasks + +### Task 1: Shared WAV Encoder + +**Files:** +- Create: `openless-all/app/src-tauri/src/asr/wav.rs` +- Modify: `openless-all/app/src-tauri/src/asr/mod.rs` +- Modify: `openless-all/app/src-tauri/src/asr/whisper.rs` + +- [ ] **Step 1: Write the shared WAV encoder tests** + +Add this file: + +```rust +//! WAV helpers for ASR providers that accept complete audio files. + +/// Encode 16 kHz / mono / 16-bit little-endian PCM as a RIFF WAV file. +pub fn encode_wav_16k_mono(pcm: &[u8]) -> Vec { + let sample_rate: u32 = 16_000; + let num_channels: u16 = 1; + let bits_per_sample: u16 = 16; + let byte_rate = sample_rate * num_channels as u32 * (bits_per_sample as u32 / 8); + let block_align = num_channels * (bits_per_sample / 8); + let data_size = pcm.len() as u32; + let chunk_size = 36 + data_size; + + let mut wav = Vec::with_capacity(44 + pcm.len()); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&chunk_size.to_le_bytes()); + wav.extend_from_slice(b"WAVE"); + wav.extend_from_slice(b"fmt "); + wav.extend_from_slice(&16u32.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); + wav.extend_from_slice(&num_channels.to_le_bytes()); + wav.extend_from_slice(&sample_rate.to_le_bytes()); + wav.extend_from_slice(&byte_rate.to_le_bytes()); + wav.extend_from_slice(&block_align.to_le_bytes()); + wav.extend_from_slice(&bits_per_sample.to_le_bytes()); + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&data_size.to_le_bytes()); + wav.extend_from_slice(pcm); + wav +} + +#[cfg(test)] +mod tests { + use super::encode_wav_16k_mono; + + #[test] + fn wav_header_matches_16k_mono_pcm() { + let pcm = [0x01, 0x00, 0xff, 0x7f]; + let wav = encode_wav_16k_mono(&pcm); + + assert_eq!(&wav[0..4], b"RIFF"); + assert_eq!(u32::from_le_bytes(wav[4..8].try_into().unwrap()), 40); + assert_eq!(&wav[8..12], b"WAVE"); + assert_eq!(&wav[12..16], b"fmt "); + assert_eq!(u16::from_le_bytes(wav[20..22].try_into().unwrap()), 1); + assert_eq!(u16::from_le_bytes(wav[22..24].try_into().unwrap()), 1); + assert_eq!(u32::from_le_bytes(wav[24..28].try_into().unwrap()), 16_000); + assert_eq!(u16::from_le_bytes(wav[34..36].try_into().unwrap()), 16); + assert_eq!(&wav[36..40], b"data"); + assert_eq!(u32::from_le_bytes(wav[40..44].try_into().unwrap()), 4); + assert_eq!(&wav[44..], &pcm); + } +} +``` + +- [ ] **Step 2: Run the new unit test and verify the module is not wired yet** + +Run: + +```powershell +cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml wav_header_matches_16k_mono_pcm +``` + +Expected: FAIL with an unresolved module only if `wav.rs` has not been registered yet. + +- [ ] **Step 3: Register the module and replace Whisper's private encoder** + +In `openless-all/app/src-tauri/src/asr/mod.rs`, add: + +```rust +pub mod wav; +``` + +In `openless-all/app/src-tauri/src/asr/whisper.rs`, add: + +```rust +use crate::asr::wav::encode_wav_16k_mono; +``` + +Then remove the private `fn encode_wav_16k_mono(pcm: &[u8]) -> Vec` from the bottom of `whisper.rs`. + +- [ ] **Step 4: Run the WAV test** + +Run: + +```powershell +cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml wav_header_matches_16k_mono_pcm +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```powershell +git add -- openless-all/app/src-tauri/src/asr/mod.rs openless-all/app/src-tauri/src/asr/whisper.rs openless-all/app/src-tauri/src/asr/wav.rs +git commit -m "refactor(asr): share wav encoding" +``` + +### Task 2: Provider Constants, Preferences, and Defaults + +**Files:** +- Create: `openless-all/app/src-tauri/src/asr/local/foundry.rs` +- Modify: `openless-all/app/src-tauri/src/asr/local/mod.rs` +- Modify: `openless-all/app/src-tauri/src/types.rs` +- Modify: `openless-all/app/src-tauri/src/persistence.rs` +- Modify: `openless-all/app/src/lib/types.ts` +- Modify: `openless-all/app/src/lib/ipc.ts` + +- [ ] **Step 1: Add provider constants and model registry** + +Create `openless-all/app/src-tauri/src/asr/local/foundry.rs`: + +```rust +use serde::Serialize; + +pub const PROVIDER_ID: &str = "foundry-local-whisper"; +pub const DEFAULT_MODEL_ALIAS: &str = "whisper-small"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FoundryWhisperModel { + pub alias: &'static str, + pub display_name: &'static str, + pub quality_tier: &'static str, +} + +pub const MODELS: &[FoundryWhisperModel] = &[ + FoundryWhisperModel { + alias: "whisper-small", + display_name: "Whisper Small", + quality_tier: "balanced", + }, + FoundryWhisperModel { + alias: "whisper-base", + display_name: "Whisper Base", + quality_tier: "low-resource", + }, + FoundryWhisperModel { + alias: "whisper-tiny", + display_name: "Whisper Tiny", + quality_tier: "smoke-test", + }, +]; + +pub fn is_foundry_local_whisper(id: &str) -> bool { + id == PROVIDER_ID +} + +pub fn model_alias_is_known(alias: &str) -> bool { + MODELS.iter().any(|model| model.alias == alias) +} + +pub fn default_language_hint() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_id_is_stable() { + assert!(is_foundry_local_whisper("foundry-local-whisper")); + assert!(!is_foundry_local_whisper("local-qwen3")); + } + + #[test] + fn default_model_is_registered() { + assert!(model_alias_is_known(DEFAULT_MODEL_ALIAS)); + } +} +``` + +- [ ] **Step 2: Export the Foundry module** + +In `openless-all/app/src-tauri/src/asr/local/mod.rs`, add: + +```rust +pub mod foundry; +``` + +- [ ] **Step 3: Add Rust preferences** + +In `openless-all/app/src-tauri/src/types.rs`, add fields to `UserPreferences` after `local_asr_keep_loaded_secs`: + +```rust +/// Windows Foundry Local Whisper 当前激活的模型 alias。 +#[serde(default = "default_foundry_local_asr_model")] +pub foundry_local_asr_model: String, +/// Windows Foundry Local Whisper 语言 hint。空串 = 自动检测。 +#[serde(default)] +pub foundry_local_asr_language_hint: String, +/// Windows Foundry Local Whisper 模型在 runtime 中保持加载多久。 +#[serde(default = "default_local_asr_keep_loaded_secs")] +pub foundry_local_asr_keep_loaded_secs: u32, +``` + +Add the default helper: + +```rust +fn default_foundry_local_asr_model() -> String { + crate::asr::local::foundry::DEFAULT_MODEL_ALIAS.into() +} +``` + +Update `impl Default for UserPreferences`: + +```rust +active_asr_provider: default_active_asr_provider(), +foundry_local_asr_model: default_foundry_local_asr_model(), +foundry_local_asr_language_hint: String::new(), +foundry_local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(), +``` + +Add this helper near the existing preference defaults: + +```rust +fn default_active_asr_provider() -> String { + #[cfg(target_os = "windows")] + { + return crate::asr::local::foundry::PROVIDER_ID.into(); + } + #[cfg(not(target_os = "windows"))] + { + "volcengine".into() + } +} +``` + +- [ ] **Step 4: Align credentials active ASR default** + +In `openless-all/app/src-tauri/src/persistence.rs`, replace `creds_default_asr()` with: + +```rust +fn creds_default_asr() -> String { + #[cfg(target_os = "windows")] + { + return crate::asr::local::foundry::PROVIDER_ID.into(); + } + #[cfg(not(target_os = "windows"))] + { + "volcengine".into() + } +} +``` + +- [ ] **Step 5: Add TypeScript preference fields** + +In `openless-all/app/src/lib/types.ts`, add: + +```ts + foundryLocalAsrModel: string; + foundryLocalAsrLanguageHint: string; + foundryLocalAsrKeepLoadedSecs: number; +``` + +In `openless-all/app/src/lib/ipc.ts`, update mock defaults: + +```ts + activeAsrProvider: 'foundry-local-whisper', + foundryLocalAsrModel: 'whisper-small', + foundryLocalAsrLanguageHint: '', + foundryLocalAsrKeepLoadedSecs: 300, +``` + +- [ ] **Step 6: Run default and provider tests** + +Run: + +```powershell +cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml provider_id_is_stable default_model_is_registered +npm --prefix openless-all/app run build +``` + +Expected: Rust tests PASS; TypeScript build PASS. + +- [ ] **Step 7: Commit** + +```powershell +git add -- openless-all/app/src-tauri/src/asr/local/foundry.rs openless-all/app/src-tauri/src/asr/local/mod.rs openless-all/app/src-tauri/src/types.rs openless-all/app/src-tauri/src/persistence.rs openless-all/app/src/lib/types.ts openless-all/app/src/lib/ipc.ts +git commit -m "feat(asr): add Foundry local provider defaults" +``` + +### Task 3: Foundry Runtime Compile Probe + +**Files:** +- Modify: `openless-all/app/src-tauri/Cargo.toml` +- Create: `openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs` +- Modify: `openless-all/app/src-tauri/src/asr/local/foundry.rs` +- Modify: `openless-all/app/src-tauri/src/asr/local/mod.rs` + +- [ ] **Step 1: Add the official Windows SDK dependency** + +Run: + +```powershell +cd openless-all/app/src-tauri +cargo add foundry-local-sdk --features winml --target 'cfg(target_os = "windows")' +``` + +Expected: `Cargo.toml` gains a Windows-only `foundry-local-sdk` dependency and `Cargo.lock` is updated. + +- [ ] **Step 2: Add runtime status types** + +Append to `openless-all/app/src-tauri/src/asr/local/foundry.rs`: + +```rust +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FoundryRuntimeStatus { + pub provider_id: String, + pub available: bool, + pub active_model: String, + pub loaded_model_id: Option, + pub endpoint: Option, + pub error: Option, +} + +impl FoundryRuntimeStatus { + pub fn unavailable(active_model: String, error: impl Into) -> Self { + Self { + provider_id: PROVIDER_ID.into(), + available: false, + active_model, + loaded_model_id: None, + endpoint: None, + error: Some(error.into()), + } + } +} +``` + +- [ ] **Step 3: Add the minimal Windows runtime wrapper** + +Create `openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs`: + +```rust +#[cfg(target_os = "windows")] +mod imp { + use anyhow::{Context, Result}; + use parking_lot::Mutex; + + use super::super::foundry::{FoundryRuntimeStatus, PROVIDER_ID}; + use foundry_local_sdk::{FoundryLocalConfig, FoundryLocalManager}; + + #[derive(Debug, Clone)] + struct LoadedModel { + alias: String, + model_id: String, + endpoint: String, + } + + pub struct FoundryLocalRuntime { + loaded: Mutex>, + } + + impl Default for FoundryLocalRuntime { + fn default() -> Self { + Self::new() + } + } + + impl FoundryLocalRuntime { + pub fn new() -> Self { + Self { + loaded: Mutex::new(None), + } + } + + pub fn status_snapshot(&self, active_model: &str) -> FoundryRuntimeStatus { + let loaded = self.loaded.lock().clone(); + FoundryRuntimeStatus { + provider_id: PROVIDER_ID.into(), + available: true, + active_model: active_model.to_string(), + loaded_model_id: loaded.as_ref().map(|model| model.model_id.clone()), + endpoint: loaded.as_ref().map(|model| model.endpoint.clone()), + error: None, + } + } + + pub async fn ensure_loaded(&self, alias: &str) -> Result<(String, String)> { + if let Some(loaded) = self.loaded.lock().as_ref() { + if loaded.alias == alias { + return Ok((loaded.model_id.clone(), loaded.endpoint.clone())); + } + } + + let manager = + FoundryLocalManager::create(FoundryLocalConfig::new("openless")) + .context("initialize Foundry Local manager")?; + manager + .download_and_register_eps_with_progress(None, |_ep, _percent| {}) + .await + .context("download/register Foundry execution providers")?; + let model = manager + .catalog() + .get_model(alias) + .await + .with_context(|| format!("get Foundry model {alias}"))?; + if !model.is_cached().await.context("check Foundry model cache")? { + model.download(Some(|_percent| {})).await.context("download Foundry model")?; + } + model.load().await.context("load Foundry model")?; + manager.start_web_service().await.context("start Foundry web service")?; + let endpoint = manager + .urls() + .context("read Foundry web service urls")? + .first() + .cloned() + .context("Foundry web service returned no endpoint")?; + let model_id = model.id().to_string(); + + *self.loaded.lock() = Some(LoadedModel { + alias: alias.to_string(), + model_id: model_id.clone(), + endpoint: endpoint.clone(), + }); + Ok((model_id, endpoint)) + } + + pub fn release_now(&self) { + self.loaded.lock().take(); + } + } +} + +#[cfg(target_os = "windows")] +pub use imp::FoundryLocalRuntime; + +#[cfg(not(target_os = "windows"))] +pub struct FoundryLocalRuntime; + +#[cfg(not(target_os = "windows"))] +impl FoundryLocalRuntime { + pub fn new() -> Self { + Self + } + + pub fn status_snapshot( + &self, + active_model: &str, + ) -> super::foundry::FoundryRuntimeStatus { + super::foundry::FoundryRuntimeStatus::unavailable( + active_model.to_string(), + "Foundry Local Whisper is only available on Windows", + ) + } + + pub fn release_now(&self) {} +} +``` + +- [ ] **Step 4: Export the runtime** + +In `openless-all/app/src-tauri/src/asr/local/mod.rs`, add: + +```rust +pub mod foundry_runtime; +pub use foundry_runtime::FoundryLocalRuntime; +``` + +- [ ] **Step 5: Compile-check the SDK API** + +Run: + +```powershell +cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml +``` + +Expected: PASS. If the Foundry SDK names differ from Microsoft Learn, update only `foundry_runtime.rs` and rerun until this command passes before continuing. + +- [ ] **Step 6: Commit** + +```powershell +git add -- openless-all/app/src-tauri/Cargo.toml openless-all/app/src-tauri/Cargo.lock openless-all/app/src-tauri/src/asr/local/foundry.rs openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs openless-all/app/src-tauri/src/asr/local/mod.rs +git commit -m "feat(asr): add Foundry local runtime wrapper" +``` + +### Task 4: Foundry Local Whisper Provider + +**Files:** +- Create: `openless-all/app/src-tauri/src/asr/local/foundry_provider.rs` +- Modify: `openless-all/app/src-tauri/src/asr/local/mod.rs` + +- [ ] **Step 1: Add provider with fakeable HTTP transcription** + +Create `openless-all/app/src-tauri/src/asr/local/foundry_provider.rs`: + +```rust +#[cfg(target_os = "windows")] +use std::sync::Arc; + +use anyhow::{Context, Result}; +use parking_lot::Mutex; + +use crate::asr::wav::encode_wav_16k_mono; +use crate::asr::RawTranscript; + +#[cfg(target_os = "windows")] +use super::foundry_runtime::FoundryLocalRuntime; + +pub struct FoundryLocalWhisperAsr { + #[cfg(target_os = "windows")] + runtime: Arc, + model_alias: String, + language_hint: Option, + buffer: Mutex>, + client: reqwest::Client, +} + +impl FoundryLocalWhisperAsr { + #[cfg(target_os = "windows")] + pub fn new( + runtime: Arc, + model_alias: String, + language_hint: Option, + ) -> Self { + Self { + runtime, + model_alias, + language_hint, + buffer: Mutex::new(Vec::new()), + client: reqwest::Client::new(), + } + } + + pub async fn transcribe(&self) -> Result { + let pcm = self.buffer.lock().clone(); + if pcm.is_empty() { + return Ok(RawTranscript { + text: String::new(), + duration_ms: 0, + }); + } + let duration_ms = (pcm.len() as u64 / 2) * 1000 / 16_000; + let raw = self.transcribe_pcm(&pcm).await?; + self.buffer.lock().clear(); + Ok(RawTranscript { + text: raw.trim().to_string(), + duration_ms, + }) + } + + #[cfg(target_os = "windows")] + async fn transcribe_pcm(&self, pcm: &[u8]) -> Result { + let (model_id, endpoint) = self.runtime.ensure_loaded(&self.model_alias).await?; + self.post_transcription(&endpoint, &model_id, pcm).await + } + + #[cfg(not(target_os = "windows"))] + async fn transcribe_pcm(&self, _pcm: &[u8]) -> Result { + anyhow::bail!("Foundry Local Whisper is only available on Windows") + } + + async fn post_transcription( + &self, + endpoint: &str, + model_id: &str, + pcm: &[u8], + ) -> Result { + let wav = encode_wav_16k_mono(pcm); + let wav_part = reqwest::multipart::Part::bytes(wav) + .file_name("openless-foundry.wav") + .mime_str("audio/wav") + .context("set Foundry transcription MIME type")?; + let mut form = reqwest::multipart::Form::new() + .part("file", wav_part) + .text("model", model_id.to_string()) + .text("response_format", "json".to_string()); + if let Some(language) = self.language_hint.as_deref().filter(|s| !s.trim().is_empty()) { + form = form.text("language", language.to_string()); + } + let url = format!("{}/v1/audio/transcriptions", endpoint.trim_end_matches('/')); + let response = self + .client + .post(url) + .multipart(form) + .send() + .await + .context("Foundry Local transcription request failed")?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("Foundry Local transcription HTTP {status}: {body}"); + } + let json: serde_json::Value = response + .json() + .await + .context("parse Foundry Local transcription response")?; + Ok(json["text"].as_str().unwrap_or("").to_string()) + } + + pub fn cancel(&self) { + self.buffer.lock().clear(); + } +} + +impl crate::recorder::AudioConsumer for FoundryLocalWhisperAsr { + fn consume_pcm_chunk(&self, pcm: &[u8]) { + self.buffer.lock().extend_from_slice(pcm); + } +} +``` + +- [ ] **Step 2: Export the provider** + +In `openless-all/app/src-tauri/src/asr/local/mod.rs`, add: + +```rust +pub mod foundry_provider; +pub use foundry_provider::FoundryLocalWhisperAsr; +``` + +- [ ] **Step 3: Run cargo check** + +Run: + +```powershell +cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```powershell +git add -- openless-all/app/src-tauri/src/asr/local/foundry_provider.rs openless-all/app/src-tauri/src/asr/local/mod.rs +git commit -m "feat(asr): add Foundry local Whisper provider" +``` + +### Task 5: Backend Commands and Runtime State + +**Files:** +- Modify: `openless-all/app/src-tauri/src/commands.rs` +- Modify: `openless-all/app/src-tauri/src/lib.rs` + +- [ ] **Step 1: Manage runtime in Tauri** + +In `openless-all/app/src-tauri/src/lib.rs`, after the local Qwen download manager: + +```rust +let foundry_local_runtime = Arc::new(asr::local::FoundryLocalRuntime::new()); +``` + +Add `.manage(foundry_local_runtime.clone())` to the Tauri builder. + +- [ ] **Step 2: Add command result type and status command** + +In `commands.rs`, import: + +```rust +use crate::asr::local::foundry::{ + model_alias_is_known, FoundryRuntimeStatus, DEFAULT_MODEL_ALIAS, + PROVIDER_ID as FOUNDRY_LOCAL_PROVIDER_ID, +}; +use crate::asr::local::FoundryLocalRuntime; +``` + +Add commands: + +```rust +#[tauri::command] +pub fn foundry_local_asr_status( + coord: CoordinatorState<'_>, + runtime: State<'_, Arc>, +) -> FoundryRuntimeStatus { + let prefs = coord.prefs().get(); + let active_model = if model_alias_is_known(&prefs.foundry_local_asr_model) { + prefs.foundry_local_asr_model + } else { + DEFAULT_MODEL_ALIAS.to_string() + }; + runtime.status_snapshot(&active_model) +} + +#[tauri::command] +pub fn foundry_local_asr_set_model( + coord: CoordinatorState<'_>, + model_alias: String, +) -> Result<(), String> { + if !model_alias_is_known(&model_alias) { + return Err(format!("unknown Foundry Whisper model alias: {model_alias}")); + } + let mut prefs = coord.prefs().get(); + prefs.foundry_local_asr_model = model_alias; + coord.prefs().set(prefs).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn foundry_local_asr_set_language_hint( + coord: CoordinatorState<'_>, + language_hint: String, +) -> Result<(), String> { + let normalized = language_hint.trim().to_string(); + if !normalized.is_empty() + && (normalized.len() != 2 || !normalized.bytes().all(|b| b.is_ascii_lowercase())) + { + return Err("language hint must be empty or ISO 639-1 lowercase code".to_string()); + } + let mut prefs = coord.prefs().get(); + prefs.foundry_local_asr_language_hint = normalized; + coord.prefs().set(prefs).map_err(|e| e.to_string()) +} +``` + +- [ ] **Step 3: Make credential status treat Foundry as credential-free** + +In `asr_configured_for_provider`, add: + +```rust +if provider == FOUNDRY_LOCAL_PROVIDER_ID { + return true; +} +``` + +- [ ] **Step 4: Register commands** + +In `lib.rs` `invoke_handler`, add: + +```rust +commands::foundry_local_asr_status, +commands::foundry_local_asr_set_model, +commands::foundry_local_asr_set_language_hint, +``` + +- [ ] **Step 5: Add command tests** + +In `commands.rs` tests, add: + +```rust +#[test] +fn credentials_status_treats_foundry_local_asr_as_configured() { + assert!(asr_configured_for_provider( + crate::asr::local::foundry::PROVIDER_ID, + &CredentialsSnapshot::default() + )); +} +``` + +- [ ] **Step 6: Run tests and build** + +Run: + +```powershell +cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml credentials_status_treats_foundry_local_asr_as_configured +cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```powershell +git add -- openless-all/app/src-tauri/src/commands.rs openless-all/app/src-tauri/src/lib.rs +git commit -m "feat(asr): expose Foundry local ASR status" +``` + +### Task 6: Coordinator Integration + +**Files:** +- Modify: `openless-all/app/src-tauri/src/coordinator.rs` + +- [ ] **Step 1: Add runtime to `Inner`** + +Import Foundry types: + +```rust +#[cfg(target_os = "windows")] +use crate::asr::local::{foundry, FoundryLocalRuntime, FoundryLocalWhisperAsr}; +``` + +Add field to `Inner`: + +```rust +#[cfg(target_os = "windows")] +foundry_local_runtime: Arc, +``` + +Initialize it in `Coordinator::new()`: + +```rust +#[cfg(target_os = "windows")] +foundry_local_runtime: Arc::new(FoundryLocalRuntime::new()), +``` + +- [ ] **Step 2: Add active ASR variant** + +Add to `ActiveAsr`: + +```rust +#[cfg(target_os = "windows")] +FoundryLocalWhisper(Arc), +``` + +Update `cancel_active_asr`: + +```rust +#[cfg(target_os = "windows")] +ActiveAsr::FoundryLocalWhisper(local) => local.cancel(), +``` + +- [ ] **Step 3: Start Foundry local provider in `begin_session`** + +After `let active_asr = CredentialsVault::get_active_asr();`, add before Whisper-compatible branch: + +```rust +#[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, + language_hint, + )); + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::FoundryLocalWhisper(Arc::clone(&local)), + ); + let consumer: Arc = local; + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + .await?; + return Ok(()); +} +``` + +- [ ] **Step 4: Transcribe Foundry local results in `end_session`** + +Add a match branch next to `ActiveAsr::Whisper`: + +```rust +#[cfg(target_os = "windows")] +ActiveAsr::FoundryLocalWhisper(local) => { + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, local.transcribe()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] Foundry Local Whisper transcribe failed: {e:#}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(format!("本地识别失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + Err(_) => { + log::error!( + "[coord] Foundry Local Whisper 全局超时 {} 秒", + COORDINATOR_GLOBAL_TIMEOUT_SECS + ); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some("识别超时".to_string()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("foundry local global timeout".to_string()); + } + } +} +``` + +- [ ] **Step 5: Relax ASR credential gate** + +In `ensure_asr_credentials`, add before local Qwen3: + +```rust +#[cfg(target_os = "windows")] +if foundry::is_foundry_local_whisper(&active_asr) { + return Ok(()); +} +``` + +- [ ] **Step 6: Add coordinator tests for fallback routing** + +Add tests in `coordinator.rs` tests: + +```rust +#[test] +fn foundry_local_provider_is_not_whisper_compatible_cloud_provider() { + assert!(!is_whisper_compatible_provider( + crate::asr::local::foundry::PROVIDER_ID + )); +} +``` + +- [ ] **Step 7: Run backend checks** + +Run: + +```powershell +cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml foundry_local_provider_is_not_whisper_compatible_cloud_provider +cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```powershell +git add -- openless-all/app/src-tauri/src/coordinator.rs +git commit -m "feat(asr): route dictation through Foundry local Whisper" +``` + +### Task 7: Frontend IPC and Settings Provider + +**Files:** +- Modify: `openless-all/app/src/lib/localAsr.ts` +- Modify: `openless-all/app/src/pages/Settings.tsx` +- Modify: `openless-all/app/src/i18n/zh-CN.ts` +- Modify: `openless-all/app/src/i18n/en.ts` + +- [ ] **Step 1: Add TypeScript IPC wrappers** + +In `openless-all/app/src/lib/localAsr.ts`, add: + +```ts +export interface FoundryLocalAsrStatus { + providerId: string; + available: boolean; + activeModel: string; + loadedModelId: string | null; + endpoint: string | null; + error: string | null; +} + +export function getFoundryLocalAsrStatus(): Promise { + return invokeOrMock('foundry_local_asr_status', undefined, () => ({ + providerId: 'foundry-local-whisper', + available: true, + activeModel: 'whisper-small', + loadedModelId: null, + endpoint: null, + error: null, + })); +} + +export function setFoundryLocalAsrModel(modelAlias: string): Promise { + return invokeOrMock('foundry_local_asr_set_model', { modelAlias }, () => undefined); +} + +export function setFoundryLocalAsrLanguageHint(languageHint: string): Promise { + return invokeOrMock( + 'foundry_local_asr_set_language_hint', + { languageHint }, + () => undefined, + ); +} +``` + +- [ ] **Step 2: Add provider preset** + +In `Settings.tsx`, add to `ASR_PRESETS` before `local-qwen3`: + +```ts +{ id: 'foundry-local-whisper', nameKey: 'asrFoundryLocalWhisper', baseUrl: '', model: '' }, +``` + +Update the union type automatically via `as const`. + +- [ ] **Step 3: Render local provider hint** + +Change: + +```tsx +) : committedAsrProvider === 'local-qwen3' ? ( + +) : ( +``` + +to: + +```tsx +) : committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper' ? ( + +) : ( +``` + +Change `LocalAsrProviderHint` signature: + +```tsx +function LocalAsrProviderHint({ provider }: { provider: 'local-qwen3' | 'foundry-local-whisper' }) { +``` + +Use provider-specific text: + +```tsx +const hintKey = provider === 'foundry-local-whisper' + ? 'settings.providers.foundryLocalAsrHint' + : 'settings.providers.localAsrHint'; +``` + +- [ ] **Step 4: Add i18n strings** + +In `zh-CN.ts` under `settings.providers.presets`: + +```ts +asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)', +``` + +Under `settings.providers`: + +```ts +foundryLocalAsrHint: 'Windows 本地 Whisper 在本机运行,无需 ASR API Key。首次使用需下载 Foundry Local 运行组件和 Whisper 模型;LLM 润色仍按你配置的模型供应商调用。', +``` + +In `en.ts` add: + +```ts +asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)', +foundryLocalAsrHint: 'Windows local Whisper runs on this device and does not need an ASR API key. First use downloads Foundry Local runtime components and a Whisper model; LLM polishing still uses your configured LLM provider.', +``` + +- [ ] **Step 5: Build frontend** + +Run: + +```powershell +npm --prefix openless-all/app run build +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```powershell +git add -- openless-all/app/src/lib/localAsr.ts openless-all/app/src/pages/Settings.tsx openless-all/app/src/i18n/zh-CN.ts openless-all/app/src/i18n/en.ts +git commit -m "feat(ui): add Foundry local ASR provider" +``` + +### Task 8: Local ASR Page for Windows Foundry Models + +**Files:** +- Modify: `openless-all/app/src/pages/LocalAsr.tsx` +- Modify: `openless-all/app/src/i18n/zh-CN.ts` +- Modify: `openless-all/app/src/i18n/en.ts` + +- [ ] **Step 1: Load Foundry status on Local ASR page** + +In `LocalAsr.tsx`, import: + +```ts +getFoundryLocalAsrStatus, +setFoundryLocalAsrModel, +setFoundryLocalAsrLanguageHint, +type FoundryLocalAsrStatus, +``` + +Add state: + +```ts +const [foundryStatus, setFoundryStatus] = useState(null); +``` + +Add refresh function: + +```ts +const refreshFoundryStatus = async () => { + try { + const status = await getFoundryLocalAsrStatus(); + setFoundryStatus(status); + } catch (err) { + console.warn('[localAsr] Foundry status query failed', err); + } +}; +``` + +Call it inside `refresh()`: + +```ts +void refreshFoundryStatus(); +``` + +- [ ] **Step 2: Add Windows Foundry model controls** + +Add this block after the top page header: + +```tsx + +
+
+
+ {t('localAsr.foundryTitle')} +
+
+ {t('localAsr.foundryDesc')} +
+
+ + {foundryStatus?.available ? t('localAsr.runtimeReady') : t('localAsr.runtimeUnavailable')} + +
+
+ + +
+ {foundryStatus?.error && ( +
+ {foundryStatus.error} +
+ )} +
+``` + +- [ ] **Step 3: Add i18n strings** + +In `zh-CN.ts` under `localAsr`: + +```ts +foundryTitle: 'Windows 本地 Whisper', +foundryDesc: '使用 Microsoft Foundry Local 在本机转写语音。无需 ASR API Key;首次使用会准备运行组件和 Whisper 模型。', +runtimeReady: '运行时可用', +runtimeUnavailable: '运行时不可用', +foundryModelLabel: 'Whisper 模型', +languageHintLabel: '识别语言', +languageAuto: '自动检测', +languageZh: '优先中文', +languageEn: '优先英文', +``` + +Add matching English strings in `en.ts`. + +- [ ] **Step 4: Build frontend** + +Run: + +```powershell +npm --prefix openless-all/app run build +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```powershell +git add -- openless-all/app/src/pages/LocalAsr.tsx openless-all/app/src/i18n/zh-CN.ts openless-all/app/src/i18n/en.ts +git commit -m "feat(ui): manage Windows local Whisper" +``` + +### Task 9: Windows Smoke Script Local ASR Mode + +**Files:** +- Modify: `openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1` + +- [ ] **Step 1: Add ASR mode parameter** + +Add parameter: + +```powershell +[ValidateSet("volcengine", "foundry-local-whisper")] +[string]$AsrProvider = "volcengine", +``` + +- [ ] **Step 2: Write active ASR preference for smoke** + +In `Set-HoldHotkeyPreference`, replace the active ASR default line with: + +```powershell +if ($null -eq $prefs.activeAsrProvider) { + $prefs | Add-Member -NotePropertyName activeAsrProvider -NotePropertyValue $AsrProvider +} else { + $prefs.activeAsrProvider = $AsrProvider +} +``` + +- [ ] **Step 3: Skip Volcengine credential requirement for local ASR** + +Replace: + +```powershell +if ($RequireJsonCredentials -and (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured)) { + throw "Real ASR regression requires configured Volcengine ASR and Ark LLM credentials." +} +``` + +with: + +```powershell +if ($RequireJsonCredentials -and $AsrProvider -eq "volcengine" -and (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured)) { + throw "Real ASR regression requires configured Volcengine ASR and Ark LLM credentials." +} +if ($RequireJsonCredentials -and $AsrProvider -eq "foundry-local-whisper" -and (-not $credentialStatus.ArkConfigured)) { + Write-Warning "Ark LLM credentials are not configured; local ASR smoke will accept raw transcript fallback." +} +``` + +- [ ] **Step 4: Add no Win+H log assertion** + +After history verification, add: + +```powershell +$logText = Get-Content -Raw $logPath +if ($logText -match "Win\\+H|Voice Typing|Windows\\.Media\\.SpeechRecognition|SAPI") { + throw "Unexpected Windows system dictation path appeared in OpenLess log." +} +``` + +- [ ] **Step 5: Run script syntax check** + +Run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -Command "$null = [scriptblock]::Create((Get-Content -Raw '.\openless-all\app\scripts\windows-real-asr-insertion-smoke.ps1')); 'ok'" +``` + +Expected: prints `ok`. + +- [ ] **Step 6: Commit** + +```powershell +git add -- openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 +git commit -m "test(windows): add local ASR smoke mode" +``` + +### Task 10: End-to-End Verification + +**Files:** +- No code changes unless a verification step exposes a bug. + +- [ ] **Step 1: Run backend unit and type checks** + +Run: + +```powershell +cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml +cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml +``` + +Expected: PASS. + +- [ ] **Step 2: Run frontend build** + +Run: + +```powershell +npm --prefix openless-all/app run build +``` + +Expected: PASS. + +- [ ] **Step 3: Run no Win+H source search** + +Run: + +```powershell +rg -n "Win\\+H|Voice Typing|Windows\\.Media\\.SpeechRecognition|SAPI|SendInput.*H" openless-all/app/src-tauri/src openless-all/app/windows-ime openless-all/app/src +``` + +Expected: no matches except documentation or explicit negative test strings. + +- [ ] **Step 4: Run local ASR smoke on Windows** + +Run after building a Windows executable: + +```powershell +powershell -ExecutionPolicy Bypass -File .\openless-all\app\scripts\windows-real-asr-insertion-smoke.ps1 -AsrProvider foundry-local-whisper -Target notepad -ManualSpeech -AllowClipboardFallback +``` + +Expected: + +- OpenLess observes hotkey and starts session. +- No Windows Voice Typing panel appears. +- History receives a new item with non-empty `rawTranscript` and `finalText`. +- If Ark is not configured, `finalText` equals raw transcript or records polish fallback. +- Notepad receives the final text through TSF or permitted fallback. + +- [ ] **Step 5: Confirm verification did not create file changes** + +Run: + +```powershell +git status --short +``` + +Expected: no output. If a verification step exposed a code defect, stop this task and write a new focused fix task before continuing. + +## Self-Review + +Spec coverage: + +- No Win+H: Task 10 source search and smoke log assertion cover it. +- Existing interaction: Task 6 routes through `Coordinator`; no UI shortcut path bypasses recorder/capsule. +- Local transcript into polish/history: Task 6 returns `RawTranscript` before existing polish and history code. +- First-use UX: Tasks 7 and 8 expose provider and runtime/model state. +- Windows TSF insertion unchanged: Task 6 leaves `insert_with_windows_ime_first` intact. +- Offline behavior after cache: Task 3 runtime caches loaded model state; Task 10 smoke can be repeated after model download. + +Placeholder scan: + +- This plan contains no unresolved placeholders or unspecified file paths. + +Type consistency: + +- Provider id is consistently `foundry-local-whisper`. +- Rust preference fields are `foundry_local_asr_model`, `foundry_local_asr_language_hint`, and `foundry_local_asr_keep_loaded_secs`. +- TypeScript preference fields use camelCase equivalents. From 23f1dcb8dccb5e9e04f90981af58c23a761ff80e Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 14:31:58 +0800 Subject: [PATCH 12/37] refactor(asr): share wav encoding --- openless-all/app/src-tauri/src/asr/mod.rs | 1 + openless-all/app/src-tauri/src/asr/wav.rs | 61 +++++++++++++++++++ openless-all/app/src-tauri/src/asr/whisper.rs | 33 ++-------- 3 files changed, 68 insertions(+), 27 deletions(-) create mode 100644 openless-all/app/src-tauri/src/asr/wav.rs diff --git a/openless-all/app/src-tauri/src/asr/mod.rs b/openless-all/app/src-tauri/src/asr/mod.rs index d7347091..203cdea9 100644 --- a/openless-all/app/src-tauri/src/asr/mod.rs +++ b/openless-all/app/src-tauri/src/asr/mod.rs @@ -8,6 +8,7 @@ mod frame; pub mod local; pub mod volcengine; +pub mod wav; pub mod whisper; pub use volcengine::{VolcengineCredentials, VolcengineStreamingASR}; diff --git a/openless-all/app/src-tauri/src/asr/wav.rs b/openless-all/app/src-tauri/src/asr/wav.rs new file mode 100644 index 00000000..91503d15 --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/wav.rs @@ -0,0 +1,61 @@ +//! WAV helpers for ASR providers that accept complete audio files. + +/// Encode 16 kHz / mono / 16-bit little-endian PCM samples as a RIFF WAV file. +pub fn encode_wav_16k_mono(samples: &[i16]) -> Vec { + let sample_rate: u32 = 16_000; + let num_channels: u16 = 1; + let bits_per_sample: u16 = 16; + let bytes_per_sample = bits_per_sample as u32 / 8; + let byte_rate = sample_rate * num_channels as u32 * bytes_per_sample; + let block_align = num_channels * (bits_per_sample / 8); + let data_size = samples.len() as u32 * bytes_per_sample; + let chunk_size = 36 + data_size; + + let mut wav = Vec::with_capacity(44 + data_size as usize); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&chunk_size.to_le_bytes()); + wav.extend_from_slice(b"WAVE"); + wav.extend_from_slice(b"fmt "); + wav.extend_from_slice(&16u32.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); + wav.extend_from_slice(&num_channels.to_le_bytes()); + wav.extend_from_slice(&sample_rate.to_le_bytes()); + wav.extend_from_slice(&byte_rate.to_le_bytes()); + wav.extend_from_slice(&block_align.to_le_bytes()); + wav.extend_from_slice(&bits_per_sample.to_le_bytes()); + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&data_size.to_le_bytes()); + for sample in samples { + wav.extend_from_slice(&sample.to_le_bytes()); + } + wav +} + +#[cfg(test)] +mod tests { + use super::encode_wav_16k_mono; + + #[test] + fn wav_header_matches_16k_mono_pcm() { + let samples = [1i16, i16::MAX, i16::MIN, -2i16]; + let wav = encode_wav_16k_mono(&samples); + + assert_eq!(&wav[0..4], b"RIFF"); + assert_eq!(u32::from_le_bytes(wav[4..8].try_into().unwrap()), 44); + assert_eq!(&wav[8..12], b"WAVE"); + assert_eq!(&wav[12..16], b"fmt "); + assert_eq!(u32::from_le_bytes(wav[16..20].try_into().unwrap()), 16); + assert_eq!(u16::from_le_bytes(wav[20..22].try_into().unwrap()), 1); + assert_eq!(u16::from_le_bytes(wav[22..24].try_into().unwrap()), 1); + assert_eq!(u32::from_le_bytes(wav[24..28].try_into().unwrap()), 16_000); + assert_eq!(u32::from_le_bytes(wav[28..32].try_into().unwrap()), 32_000); + assert_eq!(u16::from_le_bytes(wav[32..34].try_into().unwrap()), 2); + assert_eq!(u16::from_le_bytes(wav[34..36].try_into().unwrap()), 16); + assert_eq!(&wav[36..40], b"data"); + assert_eq!(u32::from_le_bytes(wav[40..44].try_into().unwrap()), 8); + assert_eq!( + &wav[44..], + &[0x01, 0x00, 0xff, 0x7f, 0x00, 0x80, 0xfe, 0xff] + ); + } +} diff --git a/openless-all/app/src-tauri/src/asr/whisper.rs b/openless-all/app/src-tauri/src/asr/whisper.rs index c2465052..9be5fd09 100644 --- a/openless-all/app/src-tauri/src/asr/whisper.rs +++ b/openless-all/app/src-tauri/src/asr/whisper.rs @@ -4,6 +4,7 @@ use anyhow::{Context, Result}; use parking_lot::Mutex; +use crate::asr::wav::encode_wav_16k_mono; use crate::asr::RawTranscript; pub struct WhisperBatchASR { @@ -56,7 +57,11 @@ impl WhisperBatchASR { anyhow::bail!("Whisper API key missing"); } - let wav = encode_wav_16k_mono(pcm); + let samples: Vec = pcm + .chunks_exact(2) + .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + let wav = encode_wav_16k_mono(&samples); let base_url = self.base_url.trim_end_matches('/'); let url = format!("{}/audio/transcriptions", base_url); @@ -100,29 +105,3 @@ impl crate::recorder::AudioConsumer for WhisperBatchASR { } } -fn encode_wav_16k_mono(pcm: &[u8]) -> Vec { - let sample_rate: u32 = 16_000; - let num_channels: u16 = 1; - let bits_per_sample: u16 = 16; - let byte_rate = sample_rate * num_channels as u32 * (bits_per_sample as u32 / 8); - let block_align = num_channels * (bits_per_sample / 8); - let data_size = pcm.len() as u32; - let chunk_size = 36 + data_size; - - let mut wav = Vec::with_capacity(44 + pcm.len()); - wav.extend_from_slice(b"RIFF"); - wav.extend_from_slice(&chunk_size.to_le_bytes()); - wav.extend_from_slice(b"WAVE"); - wav.extend_from_slice(b"fmt "); - wav.extend_from_slice(&16u32.to_le_bytes()); - wav.extend_from_slice(&1u16.to_le_bytes()); // PCM - wav.extend_from_slice(&num_channels.to_le_bytes()); - wav.extend_from_slice(&sample_rate.to_le_bytes()); - wav.extend_from_slice(&byte_rate.to_le_bytes()); - wav.extend_from_slice(&block_align.to_le_bytes()); - wav.extend_from_slice(&bits_per_sample.to_le_bytes()); - wav.extend_from_slice(b"data"); - wav.extend_from_slice(&data_size.to_le_bytes()); - wav.extend_from_slice(pcm); - wav -} From e7b1c7f558afc3a53ecb08f387b8b87c3e0d61d4 Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 14:47:17 +0800 Subject: [PATCH 13/37] feat(asr): add Foundry local provider defaults --- .../app/src-tauri/src/asr/local/foundry.rs | 63 +++++++++++++++++++ .../app/src-tauri/src/asr/local/mod.rs | 1 + openless-all/app/src-tauri/src/commands.rs | 8 ++- openless-all/app/src-tauri/src/coordinator.rs | 40 +++++++++++- openless-all/app/src-tauri/src/persistence.rs | 9 ++- openless-all/app/src-tauri/src/types.rs | 29 ++++++++- openless-all/app/src/lib/ipc.ts | 7 ++- openless-all/app/src/lib/types.ts | 6 ++ 8 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 openless-all/app/src-tauri/src/asr/local/foundry.rs diff --git a/openless-all/app/src-tauri/src/asr/local/foundry.rs b/openless-all/app/src-tauri/src/asr/local/foundry.rs new file mode 100644 index 00000000..83a13b25 --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/foundry.rs @@ -0,0 +1,63 @@ +use serde::Serialize; + +pub const PROVIDER_ID: &str = "foundry-local-whisper"; +pub const DEFAULT_MODEL_ALIAS: &str = "whisper-small"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct FoundryWhisperModel { + pub alias: &'static str, + pub display_name: &'static str, + pub quality_tier: &'static str, +} + +#[allow(dead_code)] +pub const MODELS: &[FoundryWhisperModel] = &[ + FoundryWhisperModel { + alias: "whisper-small", + display_name: "Whisper Small", + quality_tier: "balanced", + }, + FoundryWhisperModel { + alias: "whisper-base", + display_name: "Whisper Base", + quality_tier: "low-resource", + }, + FoundryWhisperModel { + alias: "whisper-tiny", + display_name: "Whisper Tiny", + quality_tier: "smoke-test", + }, +]; + +#[allow(dead_code)] +pub fn is_foundry_local_whisper(id: &str) -> bool { + id == PROVIDER_ID +} + +#[allow(dead_code)] +pub fn model_alias_is_known(alias: &str) -> bool { + MODELS.iter().any(|model| model.alias == alias) +} + +#[allow(dead_code)] +pub fn default_language_hint() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_id_is_stable() { + assert!(is_foundry_local_whisper("foundry-local-whisper")); + assert!(!is_foundry_local_whisper("local-qwen3")); + } + + #[test] + fn default_model_is_registered() { + assert!(model_alias_is_known(DEFAULT_MODEL_ALIAS)); + } +} diff --git a/openless-all/app/src-tauri/src/asr/local/mod.rs b/openless-all/app/src-tauri/src/asr/local/mod.rs index 1aca579f..b88edd52 100644 --- a/openless-all/app/src-tauri/src/asr/local/mod.rs +++ b/openless-all/app/src-tauri/src/asr/local/mod.rs @@ -5,6 +5,7 @@ pub mod cache; pub mod download; +pub mod foundry; mod local_provider; pub mod models; pub mod test_run; diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index c9b7fad9..17b243f3 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -124,7 +124,9 @@ fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bo if provider == "volcengine" { return volcengine_configured(snap); } - if provider == crate::asr::local::PROVIDER_ID { + if provider == crate::asr::local::PROVIDER_ID + || crate::asr::local::foundry::is_foundry_local_whisper(provider) + { // 本地 ASR 不依赖云端凭据。 return true; } @@ -948,6 +950,10 @@ mod tests { crate::asr::local::PROVIDER_ID, &snapshot() )); + assert!(asr_configured_for_provider( + crate::asr::local::foundry::PROVIDER_ID, + &snapshot() + )); } #[test] diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 549ac465..e8919e51 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1109,6 +1109,24 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { return Err(message); } + let active_asr = CredentialsVault::get_active_asr(); + if crate::asr::local::foundry::is_foundry_local_whisper(&active_asr) { + let message = "Windows 本地 Whisper 尚未接入听写流程,请先切换到其他 ASR 供应商".to_string(); + log::warn!("[coord] Foundry Local Whisper selected before provider wiring"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(message.clone()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(message); + } + if let Err(message) = ensure_microphone_permission(inner) { log::warn!("[coord] microphone permission gate failed: {message}"); emit_capsule( @@ -1127,8 +1145,6 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); - let active_asr = CredentialsVault::get_active_asr(); - #[cfg(target_os = "macos")] if crate::asr::local::is_local_qwen3(&active_asr) { let local = match build_local_qwen3(inner).await { @@ -2156,6 +2172,10 @@ fn ensure_asr_credentials() -> Result<(), String> { } } + if crate::asr::local::foundry::is_foundry_local_whisper(&active_asr) { + return Ok(()); + } + if is_whisper_compatible_provider(&active_asr) { let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) .ok() @@ -2175,6 +2195,12 @@ fn ensure_asr_credentials() -> Result<(), String> { } } +#[cfg(test)] +fn is_keyless_local_asr_provider(id: &str) -> bool { + crate::asr::local::is_local_qwen3(id) + || crate::asr::local::foundry::is_foundry_local_whisper(id) +} + #[cfg(target_os = "macos")] fn ensure_local_qwen3_model_ready() -> Result<(), String> { let prefs = || -> Result { @@ -3012,6 +3038,16 @@ mod tests { assert!(!window_key_matches_trigger(HotkeyTrigger::Fn, "Fn", "Fn")); } + #[test] + fn foundry_local_provider_is_keyless_and_not_whisper_compatible() { + assert!(is_keyless_local_asr_provider( + crate::asr::local::foundry::PROVIDER_ID + )); + assert!(!is_whisper_compatible_provider( + crate::asr::local::foundry::PROVIDER_ID + )); + } + #[test] fn resolve_ark_endpoint_rejects_blank_key_without_custom_endpoint() { assert_eq!( diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index f5951e97..7d42c383 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -169,7 +169,14 @@ impl Default for CredsActive { } fn creds_default_asr() -> String { - "volcengine".into() + #[cfg(target_os = "windows")] + { + return crate::asr::local::foundry::PROVIDER_ID.into(); + } + #[cfg(not(target_os = "windows"))] + { + "volcengine".into() + } } fn creds_default_llm() -> String { "ark".into() diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index c1266e24..8f258c5d 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -171,6 +171,15 @@ pub struct UserPreferences { /// 默认 300(5 分钟):兼顾连续听写不重加载、长时间不用释放 1.2GB+ RAM。 #[serde(default = "default_local_asr_keep_loaded_secs")] pub local_asr_keep_loaded_secs: u32, + /// Windows Foundry Local Whisper 当前激活的模型 alias。 + #[serde(default = "default_foundry_local_asr_model")] + pub foundry_local_asr_model: String, + /// Windows Foundry Local Whisper 语言 hint。空字符串 = 自动检测。 + #[serde(default)] + pub foundry_local_asr_language_hint: String, + /// Windows Foundry Local Whisper 模型在 runtime 中保持加载多久。 + #[serde(default = "default_local_asr_keep_loaded_secs")] + pub foundry_local_asr_keep_loaded_secs: u32, } fn default_local_asr_model() -> String { @@ -185,6 +194,21 @@ fn default_local_asr_keep_loaded_secs() -> u32 { 300 } +fn default_foundry_local_asr_model() -> String { + crate::asr::local::foundry::DEFAULT_MODEL_ALIAS.into() +} + +fn default_active_asr_provider() -> String { + #[cfg(target_os = "windows")] + { + return crate::asr::local::foundry::PROVIDER_ID.into(); + } + #[cfg(not(target_os = "windows"))] + { + "volcengine".into() + } +} + fn default_qa_hotkey() -> Option { Some(QaHotkeyBinding::default()) } @@ -207,7 +231,7 @@ impl Default for UserPreferences { launch_at_login: false, show_capsule: true, mute_during_recording: false, - active_asr_provider: "volcengine".into(), + active_asr_provider: default_active_asr_provider(), active_llm_provider: "ark".into(), restore_clipboard_after_paste: true, allow_non_tsf_insertion_fallback: true, @@ -220,6 +244,9 @@ impl Default for UserPreferences { local_asr_active_model: default_local_asr_model(), local_asr_mirror: default_local_asr_mirror(), local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(), + foundry_local_asr_model: default_foundry_local_asr_model(), + foundry_local_asr_language_hint: String::new(), + foundry_local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(), } } } diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 146691eb..3bbe68c3 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -46,7 +46,7 @@ const mockSettings: UserPreferences = { launchAtLogin: false, showCapsule: true, muteDuringRecording: false, - activeAsrProvider: 'volcengine', + activeAsrProvider: 'foundry-local-whisper', activeLlmProvider: 'ark', restoreClipboardAfterPaste: true, allowNonTsfInsertionFallback: true, @@ -59,6 +59,9 @@ const mockSettings: UserPreferences = { localAsrActiveModel: 'qwen3-asr-0.6b', localAsrMirror: 'huggingface', localAsrKeepLoadedSecs: 300, + foundryLocalAsrModel: 'whisper-small', + foundryLocalAsrLanguageHint: '', + foundryLocalAsrKeepLoadedSecs: 300, }; const mockHotkeyCapability: HotkeyCapability = { @@ -72,7 +75,7 @@ const mockHotkeyCapability: HotkeyCapability = { }; const mockCredentialsStatus: CredentialsStatus = { - activeAsrProvider: 'volcengine', + activeAsrProvider: 'foundry-local-whisper', activeLlmProvider: 'ark', asrConfigured: true, llmConfigured: true, diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 1634f571..1c7b62f4 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -137,6 +137,12 @@ export interface UserPreferences { /** 本地 ASR 引擎在内存中的保留时长(秒)。0 = 说完话即释放; * 300 = 默认 5 分钟;86400 ≈ 不释放(保持加载)。 */ localAsrKeepLoadedSecs: number; + /** Windows Foundry Local Whisper 当前激活的模型 alias。 */ + foundryLocalAsrModel: string; + /** Windows Foundry Local Whisper 语言 hint。空字符串表示自动检测。 */ + foundryLocalAsrLanguageHint: string; + /** Windows Foundry Local Whisper 模型在 runtime 中保持加载的秒数。 */ + foundryLocalAsrKeepLoadedSecs: number; } /** Rust 通过 `qa:state` 事件下发的 payload。 From 89e48ba3b80125ce81e0a9d3428c87c947f2ff78 Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 15:23:22 +0800 Subject: [PATCH 14/37] feat(asr): add Foundry local runtime wrapper --- openless-all/app/src-tauri/Cargo.lock | 473 +++++++++++++++++- openless-all/app/src-tauri/Cargo.toml | 1 + .../app/src-tauri/src/asr/local/foundry.rs | 38 ++ .../src/asr/local/foundry_runtime.rs | 276 ++++++++++ .../app/src-tauri/src/asr/local/mod.rs | 3 + 5 files changed, 774 insertions(+), 17 deletions(-) create mode 100644 openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 3e0ce8b7..655f75e9 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -99,7 +110,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -110,7 +121,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -215,6 +226,19 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-openai" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc48c3deb4ad9a2ee8c8e364c79eb0f74e69e17ed7e883d55988b90ea44fe986" +dependencies = [ + "bytes", + "derive_builder", + "getrandom 0.3.4", + "serde", + "serde_json", +] + [[package]] name = "async-process" version = "2.5.0" @@ -527,6 +551,25 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -668,6 +711,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -728,6 +781,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "convert_case" version = "0.4.0" @@ -923,6 +982,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1019,14 +1093,38 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] @@ -1042,13 +1140,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -1065,6 +1174,12 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + [[package]] name = "deranged" version = "0.5.8" @@ -1086,6 +1201,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1128,6 +1274,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1168,7 +1315,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1409,7 +1556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1595,6 +1742,27 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "foundry-local-sdk" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6f6b9ce8ef529348022814444562c54d7216417e6ed89af3d56807f52e5788" +dependencies = [ + "async-openai", + "futures-core", + "libloading 0.8.9", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "ureq", + "urlencoding", + "zip 2.4.2", +] + [[package]] name = "fst" version = "0.4.7" @@ -2038,6 +2206,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -2094,6 +2281,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -2165,6 +2361,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -2225,9 +2422,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2242,7 +2441,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.61.2", ] [[package]] @@ -2435,6 +2634,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2786,6 +2994,27 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "mac" version = "0.1.1" @@ -3427,6 +3656,7 @@ dependencies = [ "enigo", "env_logger", "ferrous-opencc", + "foundry-local-sdk", "futures-util", "global-hotkey", "libc", @@ -3524,7 +3754,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3601,6 +3831,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4363,8 +4603,10 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", @@ -4374,6 +4616,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime", "mime_guess", "native-tls", "percent-encoding", @@ -4508,7 +4751,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4517,6 +4760,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -4565,7 +4809,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4874,7 +5118,7 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -5055,7 +5299,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5226,6 +5470,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys 0.8.7", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -5514,7 +5779,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -5629,7 +5894,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5822,6 +6087,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.24.0" @@ -6115,7 +6391,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6189,6 +6465,35 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -6202,6 +6507,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" @@ -6220,6 +6531,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6604,7 +6921,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6842,6 +7159,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.1.2" @@ -6869,6 +7197,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-strings" version = "0.1.0" @@ -6888,6 +7225,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -7465,6 +7811,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yoke" version = "0.8.2" @@ -7595,6 +7950,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -7629,6 +7998,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap 2.14.0", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.18", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + [[package]] name = "zip" version = "4.6.1" @@ -7647,6 +8046,46 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index c503da7a..780c00ca 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -62,6 +62,7 @@ objc2-app-kit = "0.2" libc = "0.2" [target.'cfg(target_os = "windows")'.dependencies] +foundry-local-sdk = { version = "1.1.0", features = ["winml"] } raw-window-handle = "0.6" windows = { version = "0.58", features = [ "Win32_Foundation", diff --git a/openless-all/app/src-tauri/src/asr/local/foundry.rs b/openless-all/app/src-tauri/src/asr/local/foundry.rs index 83a13b25..4da401d7 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry.rs @@ -46,6 +46,32 @@ pub fn default_language_hint() -> Option { None } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct FoundryRuntimeStatus { + pub provider_id: String, + pub available: bool, + pub active_model: String, + pub loaded_model_id: Option, + pub endpoint: Option, + pub error: Option, +} + +impl FoundryRuntimeStatus { + #[allow(dead_code)] + pub fn unavailable(active_model: String, error: impl Into) -> Self { + Self { + provider_id: PROVIDER_ID.into(), + available: false, + active_model, + loaded_model_id: None, + endpoint: None, + error: Some(error.into()), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -60,4 +86,16 @@ mod tests { fn default_model_is_registered() { assert!(model_alias_is_known(DEFAULT_MODEL_ALIAS)); } + + #[test] + fn unavailable_runtime_status_uses_native_audio_shape() { + let status = FoundryRuntimeStatus::unavailable("whisper-base".to_string(), "not ready"); + + assert_eq!(status.provider_id, PROVIDER_ID); + assert!(!status.available); + assert_eq!(status.active_model, "whisper-base"); + assert_eq!(status.loaded_model_id, None); + assert_eq!(status.endpoint, None); + assert_eq!(status.error.as_deref(), Some("not ready")); + } } diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs new file mode 100644 index 00000000..d12bee5c --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs @@ -0,0 +1,276 @@ +#[cfg(target_os = "windows")] +#[allow(dead_code)] +mod imp { + use std::path::Path; + use std::sync::Arc; + + use anyhow::{Context, Result}; + use foundry_local_sdk::{FoundryLocalConfig, FoundryLocalManager, Model}; + use parking_lot::Mutex; + use tokio::sync::Mutex as AsyncMutex; + + use crate::asr::local::foundry::{FoundryRuntimeStatus, PROVIDER_ID}; + + #[derive(Clone)] + struct LoadedModel { + alias: String, + model_id: String, + model: Arc, + } + + #[derive(Default)] + struct RuntimeState { + manager: Option<&'static FoundryLocalManager>, + loaded: Option, + } + + pub struct FoundryLocalRuntime { + lifecycle: AsyncMutex<()>, + state: Mutex, + } + + impl Default for FoundryLocalRuntime { + fn default() -> Self { + Self::new() + } + } + + impl FoundryLocalRuntime { + pub fn new() -> Self { + Self { + lifecycle: AsyncMutex::new(()), + state: Mutex::new(RuntimeState::default()), + } + } + + pub fn status_snapshot(&self, active_model: &str) -> FoundryRuntimeStatus { + let state = self.state.lock(); + FoundryRuntimeStatus { + provider_id: PROVIDER_ID.into(), + available: true, + active_model: active_model.to_string(), + loaded_model_id: state.loaded.as_ref().map(|loaded| loaded.model_id.clone()), + endpoint: None, + error: None, + } + } + + pub async fn ensure_loaded(&self, alias: &str) -> Result { + let _lifecycle = self.lifecycle.lock().await; + Ok(self.ensure_loaded_locked(alias).await?.model_id) + } + + pub async fn transcribe_audio_file( + &self, + alias: &str, + audio_path: &Path, + ) -> Result { + let _lifecycle = self.lifecycle.lock().await; + let model = self.ensure_loaded_locked(alias).await?.model; + let result = model + .create_audio_client() + .transcribe(audio_path) + .await + .with_context(|| format!("transcribe audio with Foundry model {alias}"))?; + Ok(result.text) + } + + pub async fn release_now(&self) -> Result<()> { + let _lifecycle = self.lifecycle.lock().await; + self.release_now_locked().await + } + + async fn ensure_loaded_locked(&self, alias: &str) -> Result { + if let Some(loaded) = self.cached_loaded_model(alias) { + return Ok(loaded); + } + + if let Some(previous) = self.loaded_for_different_alias(alias) { + Self::unload_model(&previous).await?; + self.clear_loaded_if_model_id(&previous.model_id); + } + + let manager = self.manager()?; + manager + .download_and_register_eps_with_progress(None, |_ep_name: &str, _percent: f64| {}) + .await + .context("download/register Foundry execution providers")?; + + let model = manager + .catalog() + .get_model(alias) + .await + .with_context(|| format!("get Foundry model {alias}"))?; + + if !model + .is_cached() + .await + .context("check Foundry model cache")? + { + model + .download(Some(|_progress: f64| {})) + .await + .with_context(|| format!("download Foundry model {alias}"))?; + } + + model + .load() + .await + .with_context(|| format!("load Foundry model {alias}"))?; + + let loaded = LoadedModel { + alias: alias.to_string(), + model_id: model.id().to_string(), + model, + }; + *self.state.lock() = RuntimeState { + manager: Some(manager), + loaded: Some(loaded.clone()), + }; + Ok(loaded) + } + + async fn release_now_locked(&self) -> Result<()> { + if let Some(loaded) = self.loaded_model_snapshot() { + Self::unload_model(&loaded).await?; + self.clear_loaded_if_model_id(&loaded.model_id); + } + Ok(()) + } + + fn cached_loaded_model(&self, alias: &str) -> Option { + self.state + .lock() + .loaded + .as_ref() + .filter(|loaded| loaded.alias == alias) + .cloned() + } + + fn manager(&self) -> Result<&'static FoundryLocalManager> { + if let Some(manager) = self.state.lock().manager { + return Ok(manager); + } + + let manager = + FoundryLocalManager::create(FoundryLocalConfig::new("foundry_local_samples")) + .context("initialize Foundry Local manager")?; + self.state.lock().manager = Some(manager); + Ok(manager) + } + + fn loaded_model_snapshot(&self) -> Option { + self.state.lock().loaded.clone() + } + + fn loaded_for_different_alias(&self, alias: &str) -> Option { + self.state + .lock() + .loaded + .as_ref() + .filter(|loaded| loaded.alias != alias) + .cloned() + } + + fn clear_loaded_if_model_id(&self, model_id: &str) { + let mut state = self.state.lock(); + if state + .loaded + .as_ref() + .is_some_and(|loaded| loaded.model_id == model_id) + { + state.loaded.take(); + } + } + + async fn unload_model(loaded: &LoadedModel) -> Result<()> { + loaded + .model + .unload() + .await + .with_context(|| format!("unload Foundry model {}", loaded.model_id))?; + Ok(()) + } + } + + #[cfg(test)] + mod lifecycle_tests { + use super::FoundryLocalRuntime; + + #[test] + fn runtime_has_async_lifecycle_gate() { + let runtime = FoundryLocalRuntime::new(); + + assert!(runtime.lifecycle.try_lock().is_ok()); + } + } +} + +#[cfg(target_os = "windows")] +pub use imp::FoundryLocalRuntime; + +#[cfg(not(target_os = "windows"))] +pub struct FoundryLocalRuntime; + +#[cfg(not(target_os = "windows"))] +impl Default for FoundryLocalRuntime { + fn default() -> Self { + Self::new() + } +} + +#[cfg(not(target_os = "windows"))] +impl FoundryLocalRuntime { + pub fn new() -> Self { + Self + } + + pub fn status_snapshot(&self, active_model: &str) -> super::foundry::FoundryRuntimeStatus { + super::foundry::FoundryRuntimeStatus::unavailable( + active_model.to_string(), + "Foundry Local Whisper is only available on Windows", + ) + } + + pub async fn ensure_loaded(&self, alias: &str) -> anyhow::Result { + anyhow::bail!("Foundry Local Whisper is only available on Windows: {alias}"); + } + + pub async fn transcribe_audio_file( + &self, + alias: &str, + _audio_path: &std::path::Path, + ) -> anyhow::Result { + anyhow::bail!("Foundry Local Whisper is only available on Windows: {alias}"); + } + + pub async fn release_now(&self) -> anyhow::Result<()> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::FoundryLocalRuntime; + + #[test] + fn new_runtime_reports_native_audio_status_shape() { + let runtime = FoundryLocalRuntime::new(); + let status = runtime.status_snapshot("whisper-small"); + + assert_eq!(status.provider_id, crate::asr::local::foundry::PROVIDER_ID); + assert_eq!(status.active_model, "whisper-small"); + assert_eq!(status.loaded_model_id, None); + assert_eq!(status.endpoint, None); + } + + #[tokio::test] + async fn new_runtime_release_now_has_real_async_unload_contract() { + let runtime = FoundryLocalRuntime::new(); + + runtime.release_now().await.unwrap(); + + let status = runtime.status_snapshot("whisper-small"); + assert_eq!(status.loaded_model_id, None); + } +} diff --git a/openless-all/app/src-tauri/src/asr/local/mod.rs b/openless-all/app/src-tauri/src/asr/local/mod.rs index b88edd52..c4736290 100644 --- a/openless-all/app/src-tauri/src/asr/local/mod.rs +++ b/openless-all/app/src-tauri/src/asr/local/mod.rs @@ -6,11 +6,14 @@ pub mod cache; pub mod download; pub mod foundry; +pub mod foundry_runtime; mod local_provider; pub mod models; pub mod test_run; pub use cache::LocalAsrCache; +#[allow(unused_imports)] +pub use foundry_runtime::FoundryLocalRuntime; #[cfg(target_os = "macos")] mod qwen_engine; From 28ffe03cbfdfa222785d5e1951835f3e2e6850d4 Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 16:06:26 +0800 Subject: [PATCH 15/37] feat(asr): add Foundry local Whisper provider --- .../src/asr/local/foundry_provider.rs | 295 ++++++++++++++++++ .../app/src-tauri/src/asr/local/mod.rs | 3 + 2 files changed, 298 insertions(+) create mode 100644 openless-all/app/src-tauri/src/asr/local/foundry_provider.rs diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs b/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs new file mode 100644 index 00000000..a8b942d8 --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs @@ -0,0 +1,295 @@ +#![allow(dead_code)] // Task 6 接入 coordinator 后这些路径会变成运行时路径。 + +#[cfg(target_os = "windows")] +use std::fs::{self, OpenOptions}; +#[cfg(target_os = "windows")] +use std::io::Write; +#[cfg(target_os = "windows")] +use std::path::{Path, PathBuf}; +#[cfg(target_os = "windows")] +use std::sync::Arc; + +#[cfg(target_os = "windows")] +use anyhow::Context; +use anyhow::Result; +use parking_lot::Mutex; +#[cfg(target_os = "windows")] +use uuid::Uuid; + +use crate::asr::wav::encode_wav_16k_mono; +use crate::asr::RawTranscript; + +#[cfg(target_os = "windows")] +use super::foundry_runtime::FoundryLocalRuntime; + +pub struct FoundryLocalWhisperAsr { + #[cfg(target_os = "windows")] + runtime: Arc, + model_alias: String, + language_hint: Option, + buffer: Mutex>, +} + +impl FoundryLocalWhisperAsr { + #[cfg(target_os = "windows")] + pub fn new( + runtime: Arc, + model_alias: String, + language_hint: Option, + ) -> Self { + Self { + runtime, + model_alias, + language_hint: normalize_language_hint(language_hint), + buffer: Mutex::new(Vec::new()), + } + } + + #[cfg(not(target_os = "windows"))] + pub fn new(model_alias: String, language_hint: Option) -> Self { + Self { + model_alias, + language_hint: normalize_language_hint(language_hint), + buffer: Mutex::new(Vec::new()), + } + } + + pub fn model_alias(&self) -> &str { + &self.model_alias + } + + pub fn language_hint(&self) -> Option<&str> { + self.language_hint.as_deref() + } + + pub async fn transcribe(&self) -> Result { + let pcm = self.buffer.lock().clone(); + if pcm.is_empty() { + return Ok(RawTranscript { + text: String::new(), + duration_ms: 0, + }); + } + + let result = self.transcribe_inner(&pcm).await; + if result.is_ok() { + self.buffer.lock().clear(); + } + result + } + + async fn transcribe_inner(&self, pcm: &[u8]) -> Result { + let duration_ms = pcm_duration_ms(pcm); + + #[cfg(not(target_os = "windows"))] + { + let _ = pcm; + anyhow::bail!( + "Foundry Local Whisper is only available on Windows: {}", + self.model_alias + ); + } + + #[cfg(target_os = "windows")] + { + let wav_file = TempWavFile::create(pcm)?; + let text = self + .runtime + .transcribe_audio_file(&self.model_alias, wav_file.path()) + .await + .with_context(|| { + format!( + "transcribe audio file with Foundry Local Whisper model {}", + self.model_alias + ) + })?; + + Ok(RawTranscript { + text: trim_transcript_text(&text), + duration_ms, + }) + } + } + + pub fn cancel(&self) { + self.buffer.lock().clear(); + } +} + +impl crate::recorder::AudioConsumer for FoundryLocalWhisperAsr { + fn consume_pcm_chunk(&self, pcm: &[u8]) { + self.buffer.lock().extend_from_slice(pcm); + } +} + +fn pcm_duration_ms(pcm: &[u8]) -> u64 { + (pcm.len() as u64 / 2) * 1000 / 16_000 +} + +fn pcm_to_wav(pcm: &[u8]) -> Vec { + let samples: Vec = pcm + .chunks_exact(2) + .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + encode_wav_16k_mono(&samples) +} + +#[cfg(target_os = "windows")] +struct TempWavFile { + path: PathBuf, +} + +#[cfg(target_os = "windows")] +impl TempWavFile { + fn create(pcm: &[u8]) -> Result { + let dir = foundry_temp_dir(); + fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; + let path = dir.join(format!("foundry-whisper-{}.wav", Uuid::new_v4())); + let wav = pcm_to_wav(pcm); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .with_context(|| format!("create {}", path.display()))?; + + if let Err(err) = file.write_all(&wav) { + drop(file); + remove_partial_temp_wav(&path); + return Err(err).with_context(|| format!("write {}", path.display())); + } + if let Err(err) = file.sync_all() { + drop(file); + remove_partial_temp_wav(&path); + return Err(err).with_context(|| format!("sync {}", path.display())); + } + + Ok(Self { path }) + } + + fn path(&self) -> &Path { + &self.path + } +} + +#[cfg(target_os = "windows")] +impl Drop for TempWavFile { + fn drop(&mut self) { + match fs::remove_file(&self.path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + log::warn!( + "[foundry-asr] 清理临时 WAV 失败 {}: {err}", + self.path.display() + ); + } + } + } +} + +#[cfg(target_os = "windows")] +fn remove_partial_temp_wav(path: &Path) { + match fs::remove_file(path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + log::warn!( + "[foundry-asr] 清理未完成的临时 WAV 失败 {}: {err}", + path.display() + ); + } + } +} + +#[cfg(target_os = "windows")] +fn foundry_temp_dir() -> PathBuf { + std::env::temp_dir() + .join("OpenLess") + .join("foundry-local-asr") +} + +fn normalize_language_hint(language_hint: Option) -> Option { + language_hint + .map(|hint| hint.trim().to_string()) + .filter(|hint| !hint.is_empty()) +} + +fn trim_transcript_text(text: &str) -> String { + text.trim().to_string() +} + +#[cfg(test)] +mod tests { + use crate::recorder::AudioConsumer; + + #[cfg(target_os = "windows")] + fn test_provider() -> super::FoundryLocalWhisperAsr { + use std::sync::Arc; + + super::FoundryLocalWhisperAsr::new( + Arc::new(super::FoundryLocalRuntime::new()), + "whisper-small".into(), + Some(" zh ".into()), + ) + } + + #[cfg(not(target_os = "windows"))] + fn test_provider() -> super::FoundryLocalWhisperAsr { + super::FoundryLocalWhisperAsr::new("whisper-small".into(), Some(" zh ".into())) + } + + #[test] + fn foundry_provider_duration_uses_16k_i16_pcm() { + let pcm = vec![0u8; 32_000]; + + assert_eq!(super::pcm_duration_ms(&pcm), 1000); + } + + #[test] + fn foundry_provider_wav_ignores_odd_trailing_byte() { + let pcm = [0x01, 0x00, 0xff, 0x7f, 0xee]; + let wav = super::pcm_to_wav(&pcm); + + assert_eq!(&wav[0..4], b"RIFF"); + assert_eq!(u32::from_le_bytes(wav[40..44].try_into().unwrap()), 4); + assert_eq!(&wav[44..], &[0x01, 0x00, 0xff, 0x7f]); + } + + #[cfg(target_os = "windows")] + #[test] + fn foundry_provider_temp_wav_drop_removes_file() { + let pcm = [0x01, 0x00, 0xff, 0x7f]; + let path = { + let temp = super::TempWavFile::create(&pcm).unwrap(); + let path = temp.path().to_path_buf(); + + assert!(path.exists()); + + path + }; + + assert!(!path.exists()); + } + + #[test] + fn foundry_provider_normalizes_language_hint_and_text() { + assert_eq!( + super::normalize_language_hint(Some(" zh ".into())), + Some("zh".into()) + ); + assert_eq!(super::normalize_language_hint(Some(" ".into())), None); + assert_eq!(super::trim_transcript_text(" hello\r\n"), "hello"); + } + + #[test] + fn foundry_provider_cancel_clears_buffer() { + let provider = test_provider(); + + provider.consume_pcm_chunk(&[1, 0, 2, 0]); + provider.cancel(); + + assert!(provider.buffer.lock().is_empty()); + assert_eq!(provider.model_alias(), "whisper-small"); + assert_eq!(provider.language_hint(), Some("zh")); + } +} diff --git a/openless-all/app/src-tauri/src/asr/local/mod.rs b/openless-all/app/src-tauri/src/asr/local/mod.rs index c4736290..03493980 100644 --- a/openless-all/app/src-tauri/src/asr/local/mod.rs +++ b/openless-all/app/src-tauri/src/asr/local/mod.rs @@ -6,6 +6,7 @@ pub mod cache; pub mod download; pub mod foundry; +pub mod foundry_provider; pub mod foundry_runtime; mod local_provider; pub mod models; @@ -13,6 +14,8 @@ pub mod test_run; pub use cache::LocalAsrCache; #[allow(unused_imports)] +pub use foundry_provider::FoundryLocalWhisperAsr; +#[allow(unused_imports)] pub use foundry_runtime::FoundryLocalRuntime; #[cfg(target_os = "macos")] From 702e09405e752bf90187d461f438c7757e595200 Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 16:33:47 +0800 Subject: [PATCH 16/37] feat(asr): expose Foundry local ASR status --- openless-all/app/src-tauri/src/commands.rs | 165 +++++++++++++++++++-- openless-all/app/src-tauri/src/lib.rs | 6 + 2 files changed, 160 insertions(+), 11 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 17b243f3..a8e3d885 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -7,6 +7,11 @@ use serde::Serialize; use serde_json::Value; use tauri::{AppHandle, Emitter, State}; +use crate::asr::local::foundry::{ + model_alias_is_known, FoundryRuntimeStatus, DEFAULT_MODEL_ALIAS, + PROVIDER_ID as FOUNDRY_LOCAL_PROVIDER_ID, +}; +use crate::asr::local::FoundryLocalRuntime; use crate::coordinator::Coordinator; use crate::permissions::{self, PermissionStatus}; use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVault}; @@ -124,9 +129,7 @@ fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bo if provider == "volcengine" { return volcengine_configured(snap); } - if provider == crate::asr::local::PROVIDER_ID - || crate::asr::local::foundry::is_foundry_local_whisper(provider) - { + if provider == crate::asr::local::PROVIDER_ID || provider == FOUNDRY_LOCAL_PROVIDER_ID { // 本地 ASR 不依赖云端凭据。 return true; } @@ -155,7 +158,10 @@ pub fn set_credential(account: String, value: String) -> Result<(), String> { } #[tauri::command] -pub fn set_active_asr_provider(coord: CoordinatorState<'_>, provider: String) -> Result<(), String> { +pub fn set_active_asr_provider( + coord: CoordinatorState<'_>, + provider: String, +) -> Result<(), String> { CredentialsVault::set_active_asr_provider(&provider).map_err(|e| e.to_string())?; if provider == crate::asr::local::PROVIDER_ID { // 切到本地 ASR → 后台预加载模型,下次按 hotkey 时不必等数秒。 @@ -282,6 +288,11 @@ async fn validate_llm_provider() -> Result<(), String> { } async fn validate_asr_provider() -> Result<(), String> { + let active_asr = CredentialsVault::get_active_asr(); + if active_asr_is_keyless_for_validation(&active_asr) { + return Ok(()); + } + let config = read_openai_provider_config("asr")?; let model = CredentialsVault::get(CredentialAccount::AsrModel) .map_err(|e| e.to_string())? @@ -290,6 +301,10 @@ async fn validate_asr_provider() -> Result<(), String> { validate_asr_transcription(&config, model.trim()).await } +fn active_asr_is_keyless_for_validation(provider: &str) -> bool { + provider == crate::asr::local::PROVIDER_ID || provider == FOUNDRY_LOCAL_PROVIDER_ID +} + async fn validate_asr_transcription(config: &ProviderConfig, model: &str) -> Result<(), String> { const MAX_ASR_VALIDATE_BODY_BYTES: usize = 1024 * 1024; let url = asr_transcriptions_url(&config.base_url)?; @@ -769,7 +784,10 @@ pub fn local_asr_get_settings(coord: CoordinatorState<'_>) -> LocalAsrSettings { } #[tauri::command] -pub fn local_asr_set_active_model(coord: CoordinatorState<'_>, model_id: String) -> Result<(), String> { +pub fn local_asr_set_active_model( + coord: CoordinatorState<'_>, + model_id: String, +) -> Result<(), String> { if ModelId::from_str(&model_id).is_none() { return Err(format!("unknown model id: {model_id}")); } @@ -827,10 +845,7 @@ pub fn local_asr_cancel_download( } #[tauri::command] -pub fn local_asr_delete_model( - coord: CoordinatorState<'_>, - model_id: String, -) -> Result<(), String> { +pub fn local_asr_delete_model(coord: CoordinatorState<'_>, model_id: String) -> Result<(), String> { let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?; // 如果内存里加载的就是要删的这个模型,先释放:否则 mmap 残留指向已 unlink 的文件, // 且 RAM 直到下次切模型 / 用户手动按"释放"才回收。 @@ -888,6 +903,76 @@ pub fn local_asr_set_keep_loaded_secs( coord.prefs().set(prefs).map_err(|e| e.to_string()) } +// ───────────────────── Windows local ASR (Foundry Local Whisper) ───────────────────── + +fn active_foundry_model_from_prefs(prefs: &UserPreferences) -> String { + if model_alias_is_known(&prefs.foundry_local_asr_model) { + prefs.foundry_local_asr_model.clone() + } else { + DEFAULT_MODEL_ALIAS.to_string() + } +} + +fn validate_foundry_model_alias(model_alias: &str) -> Result<(), String> { + if model_alias_is_known(model_alias) { + Ok(()) + } else { + Err(format!( + "unknown Foundry Whisper model alias: {model_alias}" + )) + } +} + +fn normalize_foundry_language_hint(language_hint: &str) -> Result { + let normalized = language_hint.trim().to_string(); + if normalized.is_empty() + || (normalized.len() == 2 && normalized.bytes().all(|b| b.is_ascii_lowercase())) + { + Ok(normalized) + } else { + Err("language hint must be empty or ISO 639-1 lowercase code".to_string()) + } +} + +#[tauri::command] +pub fn foundry_local_asr_status( + coord: CoordinatorState<'_>, + runtime: State<'_, Arc>, +) -> FoundryRuntimeStatus { + let prefs = coord.prefs().get(); + let active_model = active_foundry_model_from_prefs(&prefs); + runtime.status_snapshot(&active_model) +} + +#[tauri::command] +pub fn foundry_local_asr_set_model( + coord: CoordinatorState<'_>, + model_alias: String, +) -> Result<(), String> { + validate_foundry_model_alias(&model_alias)?; + let mut prefs = coord.prefs().get(); + prefs.foundry_local_asr_model = model_alias; + coord.prefs().set(prefs).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn foundry_local_asr_set_language_hint( + coord: CoordinatorState<'_>, + language_hint: String, +) -> Result<(), String> { + let normalized = normalize_foundry_language_hint(&language_hint)?; + let mut prefs = coord.prefs().get(); + prefs.foundry_local_asr_language_hint = normalized; + coord.prefs().set(prefs).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn foundry_local_asr_release( + runtime: State<'_, Arc>, +) -> Result<(), String> { + runtime.release_now().await.map_err(|e| format!("{e:#}")) +} + // ─────────────────────────── unused but exported (silences dead_code) ─────────────────────────── #[allow(dead_code)] @@ -896,9 +981,11 @@ fn _ensure_snapshot_used(_: CredentialsSnapshot) {} #[cfg(test)] mod tests { use super::{ + active_asr_is_keyless_for_validation, active_foundry_model_from_prefs, asr_configured_for_provider, asr_transcriptions_url, fetch_provider_models, - llm_configured_for_snapshot, models_url, parse_model_ids, persist_settings, - ProviderConfig, SettingsWriter, + llm_configured_for_snapshot, models_url, + normalize_foundry_language_hint, parse_model_ids, persist_settings, + validate_foundry_model_alias, ProviderConfig, SettingsWriter, }; use crate::persistence::CredentialsSnapshot; use crate::types::{ @@ -956,6 +1043,62 @@ mod tests { )); } + #[test] + fn credentials_status_treats_foundry_local_asr_as_configured() { + assert!(asr_configured_for_provider( + crate::asr::local::foundry::PROVIDER_ID, + &CredentialsSnapshot::default() + )); + } + + #[test] + fn local_asr_providers_skip_external_validation() { + assert!(active_asr_is_keyless_for_validation( + crate::asr::local::PROVIDER_ID + )); + assert!(active_asr_is_keyless_for_validation( + crate::asr::local::foundry::PROVIDER_ID + )); + assert!(!active_asr_is_keyless_for_validation("volcengine")); + assert!(!active_asr_is_keyless_for_validation("whisper")); + } + + #[test] + fn foundry_language_hint_accepts_empty_and_lowercase_iso_639_1() { + assert_eq!(normalize_foundry_language_hint("").unwrap(), ""); + assert_eq!(normalize_foundry_language_hint(" ").unwrap(), ""); + assert_eq!(normalize_foundry_language_hint("zh").unwrap(), "zh"); + assert_eq!(normalize_foundry_language_hint(" en ").unwrap(), "en"); + } + + #[test] + fn foundry_language_hint_rejects_non_lowercase_iso_639_1() { + assert!(normalize_foundry_language_hint("ZH").is_err()); + assert!(normalize_foundry_language_hint("zho").is_err()); + assert!(normalize_foundry_language_hint("z1").is_err()); + } + + #[test] + fn foundry_model_alias_validation_rejects_unknown_alias() { + assert!( + validate_foundry_model_alias(crate::asr::local::foundry::DEFAULT_MODEL_ALIAS).is_ok() + ); + assert!(validate_foundry_model_alias("whisper-large").is_err()); + } + + #[test] + fn foundry_active_model_pref_falls_back_to_default_for_unknown_alias() { + let prefs = UserPreferences { + foundry_local_asr_model: "whisper-large".to_string(), + ..Default::default() + }; + + assert_eq!( + active_foundry_model_from_prefs(&prefs), + crate::asr::local::foundry::DEFAULT_MODEL_ALIAS + ); + } + #[test] fn credentials_status_accepts_keyless_llm_with_endpoint_and_model() { let keyless_ready = CredentialsSnapshot { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index fd18b20b..bec4af5e 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -49,6 +49,7 @@ pub fn run() { let coordinator = Arc::new(coordinator::Coordinator::new()); let local_asr_download_manager = Arc::new(asr::local::DownloadManager::new()); + let foundry_local_runtime = Arc::new(asr::local::FoundryLocalRuntime::new()); tauri::Builder::default() // 单实例锁:第二个进程启动时立即退出,激活信号转给已运行实例的主窗口。 @@ -71,6 +72,7 @@ pub fn run() { )) .manage(coordinator.clone()) .manage(local_asr_download_manager.clone()) + .manage(foundry_local_runtime.clone()) .setup(move |app| { // Capsule 启动时定位到屏幕底部居中并隐藏;coordinator 按需显示。 // 与 Swift `CapsuleWindowController.repositionToBottomCenter` 同语义。 @@ -244,6 +246,10 @@ pub fn run() { commands::local_asr_release_engine, commands::local_asr_preload, commands::local_asr_set_keep_loaded_secs, + commands::foundry_local_asr_status, + commands::foundry_local_asr_set_model, + commands::foundry_local_asr_set_language_hint, + commands::foundry_local_asr_release, restart_app, ]) .build(tauri::generate_context!()) From 05e45b41fb8a39c7df4f894c6e77ceb42e5cca73 Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 17:02:16 +0800 Subject: [PATCH 17/37] feat(asr): route dictation through Foundry local Whisper --- openless-all/app/src-tauri/src/coordinator.rs | 152 +++++++++++++++--- openless-all/app/src-tauri/src/lib.rs | 7 +- 2 files changed, 140 insertions(+), 19 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index e8919e51..9b9b8aa2 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -20,6 +20,8 @@ use crate::asr::{ DictionaryHotword, RawTranscript, VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR, }; +#[cfg(target_os = "windows")] +use crate::asr::local::{foundry, FoundryLocalRuntime, FoundryLocalWhisperAsr}; use crate::hotkey::{HotkeyEvent, HotkeyMonitor}; use crate::insertion::TextInserter; use crate::persistence::{ @@ -56,6 +58,8 @@ enum SessionPhase { enum ActiveAsr { Volcengine(Arc), Whisper(Arc), + #[cfg(target_os = "windows")] + FoundryLocalWhisper(Arc), /// 本地 Qwen3-ASR;只在 macOS + 模型已下载时可达。 #[cfg(target_os = "macos")] Local(Arc), @@ -145,6 +149,8 @@ struct Inner { /// 本地 Qwen3-ASR 引擎缓存。跨会话复用,避免每次重加载 1.2GB+ 模型。 /// 释放时机由 prefs.local_asr_keep_loaded_secs 决定。 local_asr_cache: Arc, + #[cfg(target_os = "windows")] + foundry_local_runtime: Arc, recorder: Mutex>>, recording_mute: Mutex, hotkey: Mutex>, @@ -221,6 +227,49 @@ struct PreparedWindowsImeSessionSlot { impl Coordinator { pub fn new() -> Self { + #[cfg(target_os = "windows")] + { + Self::new_with_foundry_runtime(Arc::new(FoundryLocalRuntime::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 vocab = DictionaryStore::new().expect("dictionary store init"); + + Self { + inner: Arc::new(Inner { + app: Mutex::new(None), + history, + prefs, + vocab, + inserter: TextInserter::new(), + state: Mutex::new(SessionState::default()), + asr: Mutex::new(None), + recorder: Mutex::new(None), + recording_mute: Mutex::new(SharedRecordingMuteState::new()), + hotkey: Mutex::new(None), + hotkey_status: Mutex::new(HotkeyStatus::default()), + hotkey_trigger_held: AtomicBool::new(false), + 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()), + }), + } + } + } + + #[cfg(target_os = "windows")] + pub fn new_with_foundry_runtime(foundry_local_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") @@ -235,9 +284,7 @@ impl Coordinator { prefs, vocab, inserter: TextInserter::new(), - #[cfg(target_os = "windows")] windows_ime: WindowsImeSessionController::new(), - #[cfg(target_os = "windows")] prepared_windows_ime_session: Arc::new(Mutex::new(Vec::new())), state: Mutex::new(SessionState::default()), asr: Mutex::new(None), @@ -254,6 +301,7 @@ impl Coordinator { 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, }), } } @@ -874,6 +922,8 @@ fn cancel_active_asr(asr: ActiveAsr) { match asr { ActiveAsr::Volcengine(v) => v.cancel(), ActiveAsr::Whisper(w) => w.cancel(), + #[cfg(target_os = "windows")] + ActiveAsr::FoundryLocalWhisper(local) => local.cancel(), #[cfg(target_os = "macos")] ActiveAsr::Local(local) => local.cancel(), } @@ -1110,22 +1160,6 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { } let active_asr = CredentialsVault::get_active_asr(); - if crate::asr::local::foundry::is_foundry_local_whisper(&active_asr) { - let message = "Windows 本地 Whisper 尚未接入听写流程,请先切换到其他 ASR 供应商".to_string(); - log::warn!("[coord] Foundry Local Whisper selected before provider wiring"); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - 0, - Some(message.clone()), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err(message); - } if let Err(message) = ensure_microphone_permission(inner) { log::warn!("[coord] microphone permission gate failed: {message}"); @@ -1145,6 +1179,36 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); + #[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, + language_hint, + )); + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::FoundryLocalWhisper(Arc::clone(&local)), + ); + let consumer: Arc = local; + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + .await?; + return Ok(()); + } + #[cfg(target_os = "macos")] if crate::asr::local::is_local_qwen3(&active_asr) { let local = match build_local_qwen3(inner).await { @@ -1616,6 +1680,46 @@ async fn end_session(inner: &Arc) -> Result<(), String> { } } } + #[cfg(target_os = "windows")] + ActiveAsr::FoundryLocalWhisper(local) => { + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, local.transcribe()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] Foundry Local Whisper transcribe failed: {e:#}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(format!("本地识别失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + Err(_) => { + log::error!( + "[coord] Foundry Local Whisper 全局超时 {} 秒", + COORDINATOR_GLOBAL_TIMEOUT_SECS + ); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some("识别超时".to_string()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("foundry local global timeout".to_string()); + } + } + } #[cfg(target_os = "macos")] ActiveAsr::Local(local) => { // 与 Volcengine/Whisper 一致包一层 global timeout(来自 origin/main)。 @@ -3048,6 +3152,18 @@ mod tests { )); } + #[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 + )); + } + #[test] fn resolve_ark_endpoint_rejects_blank_key_without_custom_endpoint() { assert_eq!( diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index bec4af5e..56d610b1 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -47,9 +47,14 @@ pub fn run() { init_file_logger(); log::info!("=== OpenLess 启动 ==="); + let foundry_local_runtime = Arc::new(asr::local::FoundryLocalRuntime::new()); + #[cfg(target_os = "windows")] + let coordinator = Arc::new(coordinator::Coordinator::new_with_foundry_runtime(Arc::clone( + &foundry_local_runtime, + ))); + #[cfg(not(target_os = "windows"))] let coordinator = Arc::new(coordinator::Coordinator::new()); let local_asr_download_manager = Arc::new(asr::local::DownloadManager::new()); - let foundry_local_runtime = Arc::new(asr::local::FoundryLocalRuntime::new()); tauri::Builder::default() // 单实例锁:第二个进程启动时立即退出,激活信号转给已运行实例的主窗口。 From 71240e2c3d68e5c7482dfe80a2f6fe157916d9f1 Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 17:14:10 +0800 Subject: [PATCH 18/37] feat(ui): add Foundry local ASR provider --- openless-all/app/src/i18n/en.ts | 2 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/i18n/zh-TW.ts | 2 + openless-all/app/src/lib/localAsr.ts | 36 ++++++++++ openless-all/app/src/pages/Settings.tsx | 89 ++++++++++++++++++++++--- 5 files changed, 122 insertions(+), 9 deletions(-) diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 0d8bfd98..7b3be613 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -305,6 +305,7 @@ export const en: typeof zhCN = { asrZhipu: 'Zhipu GLM-ASR', asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper (compatible)', + asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)', asrLocalQwen3: 'Local Qwen3-ASR', }, volcengineAppKeyLabel: 'APP ID', @@ -312,6 +313,7 @@ export const en: typeof zhCN = { volcengineResourceIdLabel: 'Resource ID', volcengineMappingNote: 'Secret Key is not required right now. Resource ID defaults to volc.bigasr.sauc.duration.', localAsrHint: 'Local Qwen3-ASR runs entirely on this machine. No API key needed — just download the model from HuggingFace.', + foundryLocalAsrHint: 'Windows local Whisper runs on this device and does not need an ASR API key. First use downloads Foundry Local runtime components and a Whisper model; LLM polishing still uses your configured LLM provider.', localAsrPerformanceWarning: 'Local inference runs on CPU + Apple Silicon Accelerate; each transcription takes **several seconds longer than cloud ASR**, and Chinese / dialect accuracy is **typically lower** than Volcengine or Whisper turbo. Use it for offline, privacy-sensitive, or no-cloud-API scenarios.', localAsrReady: '{{model}} downloaded', localAsrNotReady: '{{model}} not downloaded', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 93743926..fd1a32d8 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -303,6 +303,7 @@ export const zhCN = { asrZhipu: '智谱 GLM-ASR', asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper(兼容)', + asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)', asrLocalQwen3: '本地 Qwen3-ASR', }, volcengineAppKeyLabel: 'APP ID', @@ -310,6 +311,7 @@ export const zhCN = { volcengineResourceIdLabel: 'Resource ID', volcengineMappingNote: 'Secret Key 当前无需填写。Resource ID 默认使用 volc.bigasr.sauc.duration。', localAsrHint: '本地 Qwen3-ASR 在本机运行,无需 API Key。模型从 HuggingFace 下载到本地后即可使用。', + foundryLocalAsrHint: 'Windows 本地 Whisper 在本机运行,无需 ASR API Key。首次使用会下载 Foundry Local 运行组件和 Whisper 模型;LLM 润色仍按你配置的 LLM 提供商调用。', localAsrPerformanceWarning: '本地推理跑在 CPU + Apple Silicon Accelerate 上,单次转写时间会**比云端 ASR 长几秒**;中文识别准确率与方言/口音表现也**通常不如**火山引擎 / Whisper turbo。请按需取舍:网络受限或对隐私敏感时再用本地。', localAsrReady: '{{model}} 已下载', localAsrNotReady: '{{model}} 未下载', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 63b0ca84..270db07f 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -305,6 +305,7 @@ export const zhTW: typeof zhCN = { asrZhipu: '智譜 GLM-ASR', asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper(兼容)', + asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)', asrLocalQwen3: '本地 Qwen3-ASR', }, volcengineAppKeyLabel: 'APP ID', @@ -312,6 +313,7 @@ export const zhTW: typeof zhCN = { volcengineResourceIdLabel: 'Resource ID', volcengineMappingNote: 'Secret Key 當前無需填寫。Resource ID 默認使用 volc.bigasr.sauc.duration。', localAsrHint: '本地 Qwen3-ASR 在本機運行,無需 API Key。模型從 HuggingFace 下載到本地後即可使用。', + foundryLocalAsrHint: 'Windows 本地 Whisper 在本機運行,無需 ASR API Key。首次使用會下載 Foundry Local 運行組件和 Whisper 模型;LLM 潤色仍按你配置的 LLM 提供商調用。', localAsrPerformanceWarning: '本地推理跑在 CPU + Apple Silicon Accelerate 上,**首次轉寫需要加載模型(數秒)**,之後單次轉寫也會比雲端 ASR 慢若干秒;中文識別準確率與方言/口音表現通常不如火山引擎 / Whisper turbo。適用場景:離線 / 隱私敏感 / 不願付費雲 API。', localAsrReady: '{{model}} 已下載', localAsrNotReady: '{{model}} 未下載', diff --git a/openless-all/app/src/lib/localAsr.ts b/openless-all/app/src/lib/localAsr.ts index ceacc19c..741323c7 100644 --- a/openless-all/app/src/lib/localAsr.ts +++ b/openless-all/app/src/lib/localAsr.ts @@ -55,6 +55,15 @@ export interface LocalAsrDownloadProgress { error: string | null; } +export interface FoundryLocalAsrStatus { + providerId: string; + available: boolean; + activeModel: string; + loadedModelId: string | null; + endpoint: string | null; + error: string | null; +} + const MOCK_SETTINGS: LocalAsrSettings = { providerId: 'local-qwen3', activeModel: 'qwen3-asr-0.6b', @@ -175,3 +184,30 @@ export function preloadLocalAsr(): Promise { export function setLocalAsrKeepLoadedSecs(seconds: number): Promise { return invokeOrMock('local_asr_set_keep_loaded_secs', { seconds }, () => undefined); } + +export function getFoundryLocalAsrStatus(): Promise { + return invokeOrMock('foundry_local_asr_status', undefined, () => ({ + providerId: 'foundry-local-whisper', + available: true, + activeModel: 'whisper-small', + loadedModelId: null, + endpoint: null, + error: null, + })); +} + +export function setFoundryLocalAsrModel(modelAlias: string): Promise { + return invokeOrMock('foundry_local_asr_set_model', { modelAlias }, () => undefined); +} + +export function setFoundryLocalAsrLanguageHint(languageHint: string): Promise { + return invokeOrMock( + 'foundry_local_asr_set_language_hint', + { languageHint }, + () => undefined, + ); +} + +export function releaseFoundryLocalAsr(): Promise { + return invokeOrMock('foundry_local_asr_release', undefined, () => undefined); +} diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 2fdd0a82..946e15d4 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -402,6 +402,7 @@ const ASR_PRESETS = [ { id: 'zhipu', nameKey: 'asrZhipu', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-asr-2512' }, { id: 'groq', nameKey: 'asrGroq', baseUrl: 'https://api.groq.com/openai/v1', model: 'whisper-large-v3-turbo' }, { id: 'whisper', nameKey: 'asrWhisper', baseUrl: 'https://api.openai.com/v1', model: 'whisper-1' }, + { id: 'foundry-local-whisper', nameKey: 'asrFoundryLocalWhisper', baseUrl: '', model: '' }, // 本地 Qwen3-ASR:无 baseUrl/model 配置,模型在「模型设置」页下载与切换。 { id: 'local-qwen3', nameKey: 'asrLocalQwen3', baseUrl: '', model: '' }, ] as const; @@ -576,8 +577,8 @@ function ProvidersSection() { {t('settings.providers.volcengineMappingNote')}
- ) : committedAsrProvider === 'local-qwen3' ? ( - + ) : committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper' ? ( + ) : ( <> @@ -1268,45 +1269,100 @@ function adapterDisplayName(adapter: HotkeyCapability['adapter'] | HotkeyStatus[ /// 本地 Qwen3-ASR 在 Settings → 服务商区里**不**让用户填空——展示当前激活模型 /// 是否已下载、列出所有已下载模型 + 删除按钮,并提示性能/质量预期,引导跳到 /// 「模型设置」页做下载。 -function LocalAsrProviderHint() { +function LocalAsrProviderHint({ + provider, + selectedProvider, +}: { + provider: 'local-qwen3' | 'foundry-local-whisper'; + selectedProvider: AsrPresetId; +}) { const { t } = useTranslation(); const [settings, setSettings] = useState(null); const [models, setModels] = useState([]); const [loading, setLoading] = useState(true); const [deletingId, setDeletingId] = useState(null); + const refreshSeqRef = useRef(0); + const providerStateRef = useRef({ provider, selectedProvider }); + providerStateRef.current = { provider, selectedProvider }; - const refresh = async () => { + const qwenReadyForFetch = () => { + const state = providerStateRef.current; + return state.provider === 'local-qwen3' && state.selectedProvider === 'local-qwen3'; + }; + + const refresh = async (seq: number) => { try { const [s, list] = await Promise.all([getLocalAsrSettings(), listLocalAsrModels()]); + if (seq !== refreshSeqRef.current) { + return; + } setSettings(s); setModels(list); } catch (err) { + if (seq !== refreshSeqRef.current) { + return; + } console.warn('[settings] load local asr status failed', err); } finally { + if (seq === refreshSeqRef.current) { + setLoading(false); + } + } + }; + + const beginRefresh = () => { + const seq = ++refreshSeqRef.current; + setSettings(null); + setModels([]); + setDeletingId(null); + if (provider !== selectedProvider) { + setLoading(true); + return; + } + if (provider === 'foundry-local-whisper') { setLoading(false); + return; } + setLoading(true); + void refresh(seq); }; useEffect(() => { - void refresh(); - }, []); + beginRefresh(); + return () => { + refreshSeqRef.current += 1; + }; + }, [provider, selectedProvider]); const goToLocalAsr = () => { window.dispatchEvent(new CustomEvent(NAVIGATE_LOCAL_ASR_EVENT)); }; const handleDelete = async (modelId: string) => { + const seq = refreshSeqRef.current; + if (!qwenReadyForFetch()) { + return; + } setDeletingId(modelId); try { await deleteLocalAsrModel(modelId); - await refresh(); + if (seq !== refreshSeqRef.current || !qwenReadyForFetch()) { + return; + } + beginRefresh(); } catch (err) { console.warn('[settings] delete local model failed', err); } finally { - setDeletingId(null); + if (seq === refreshSeqRef.current && provider === 'local-qwen3') { + setDeletingId(null); + } } }; + const hintKey = provider === 'foundry-local-whisper' + ? 'settings.providers.foundryLocalAsrHint' + : 'settings.providers.localAsrHint'; + if (loading) { return (
@@ -1319,6 +1375,21 @@ function LocalAsrProviderHint() { const isReady = active?.isDownloaded ?? false; const downloaded = models.filter(m => m.isDownloaded); + if (provider === 'foundry-local-whisper') { + return ( +
+
+ {t(hintKey)} +
+
+ + {t('settings.providers.localAsrManage')} + +
+
+ ); + } + return (
{/* 性能/质量预期警告 —— 用户硬要求要写清楚 */} @@ -1335,7 +1406,7 @@ function LocalAsrProviderHint() {
- {t('settings.providers.localAsrHint')} + {t(hintKey)}
{/* 当前激活模型状态 + 跳转按钮 */} From a2f56840e5d006b3e6a221f24cc4a8cabd206d3c Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 17:43:47 +0800 Subject: [PATCH 19/37] feat(ui): add Foundry local ASR model controls --- openless-all/app/src-tauri/src/commands.rs | 12 ++ openless-all/app/src-tauri/src/lib.rs | 1 + openless-all/app/src/i18n/en.ts | 33 +++- openless-all/app/src/i18n/zh-CN.ts | 33 +++- openless-all/app/src/i18n/zh-TW.ts | 33 +++- openless-all/app/src/lib/localAsr.ts | 32 +++- openless-all/app/src/pages/LocalAsr.tsx | 191 ++++++++++++++++++++- openless-all/app/src/pages/Overview.tsx | 1 + 8 files changed, 316 insertions(+), 20 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index a8e3d885..f963948a 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -966,6 +966,18 @@ pub fn foundry_local_asr_set_language_hint( coord.prefs().set(prefs).map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn foundry_local_asr_prepare( + runtime: State<'_, Arc>, + model_alias: String, +) -> Result { + validate_foundry_model_alias(&model_alias)?; + runtime + .ensure_loaded(&model_alias) + .await + .map_err(|e| format!("{e:#}")) +} + #[tauri::command] pub async fn foundry_local_asr_release( runtime: State<'_, Arc>, diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 56d610b1..3781c32e 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -254,6 +254,7 @@ pub fn run() { commands::foundry_local_asr_status, commands::foundry_local_asr_set_model, commands::foundry_local_asr_set_language_hint, + commands::foundry_local_asr_prepare, commands::foundry_local_asr_release, restart_app, ]) diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 7b3be613..d6d358d7 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -531,8 +531,29 @@ export const en: typeof zhCN = { localAsr: { kicker: 'LOCAL ASR', title: 'Models', - desc: 'Local Qwen3-ASR engine and model manager. Models download from HuggingFace and run fully offline. Streaming on Windows is tracked in repo issues.', - engineUnavailable: 'The local engine is not bundled on this platform yet (macOS only; Windows tracked in issue #256). You can still download models, but they cannot be activated here.', + desc: 'Manage on-device ASR models. Windows can use Microsoft Foundry Local Whisper; Qwen3-ASR model management stays separate.', + qwenTitle: 'Qwen3-ASR model manager', + engineUnavailable: 'The Qwen3-ASR inference engine is not bundled on this platform. You can still download models, but Qwen3-ASR cannot be activated here yet.', + foundryTitle: 'Windows Foundry Local Whisper', + foundryDesc: 'Windows uses Microsoft Foundry Local Whisper to recognize speech on this device with no ASR API key. First prepare downloads local runtime components and a model, then loads it. LLM polishing still uses your configured LLM provider; if none is configured, the existing raw transcript fallback still applies.', + foundryAvailable: 'Runtime available', + foundryUnavailable: 'Runtime unavailable', + foundrySelectedModel: 'Selected model', + foundryActiveModel: 'Current default alias', + foundryLoadedModel: 'Loaded model', + foundryNotLoaded: 'Not loaded', + foundryError: 'Foundry status', + foundrySetDefault: 'Set default / Enable Windows local ASR', + foundryEnabling: 'Enabling…', + foundryPrepare: 'Prepare / Download / Load', + foundryPreparing: 'Preparing…', + foundryReleasing: 'Releasing…', + foundryModelSmall: 'Whisper Small (default / balanced)', + foundryModelSmallDesc: 'Default balanced option for quality and resource use.', + foundryModelBase: 'Whisper Base (faster / lower resource)', + foundryModelBaseDesc: 'Faster with lower resource use for lightweight daily dictation.', + foundryModelTiny: 'Whisper Tiny (fastest / smoke test)', + foundryModelTinyDesc: 'Fastest check option for confirming the Foundry path works.', mirrorLabel: 'Download mirror', mirrorDesc: 'huggingface.co is the official source; hf-mirror.com is a community mirror friendlier to Mainland China networks.', mirrorHuggingface: 'HuggingFace official (huggingface.co)', @@ -549,7 +570,7 @@ export const en: typeof zhCN = { files: 'files', sizeLoading: 'Fetching size…', sizeUnknown: 'Size unknown', - performanceWarning: 'Local inference runs on CPU + Apple Silicon Accelerate. **First transcription loads the model (a few seconds)**, and each subsequent one is several seconds slower than cloud ASR. Chinese / dialect accuracy is typically lower than Volcengine / Whisper turbo. Best for offline, privacy-sensitive, or no-cloud-API scenarios.', + performanceWarning: 'Local ASR is best for offline, privacy-sensitive, or no-cloud-ASR-API scenarios. First use may take time because runtime and model downloads plus loading all happen locally.', test: 'Load & Test', testRunning: 'Testing…', testHeading: 'Built-in audio test', @@ -558,12 +579,12 @@ export const en: typeof zhCN = { testStats: 'Audio {{audio}}s · Load {{load}}s · Transcribe {{transcribe}}s · Backend {{backend}}', testFailed: 'Test failed', engineStatusLabel: 'Engine in memory', - engineLoaded: 'Loaded: {{model}} (~1.2-3.4 GB RAM)', - engineUnloaded: 'Not loaded (first transcription will load it, ~3-5 s)', + engineLoaded: 'Loaded: {{model}}', + engineUnloaded: 'Not loaded (first transcription must load the model)', loadNow: 'Load now', releaseNow: 'Release now', keepLoadedLabel: 'Keep loaded for', - keepLoadedDesc: 'How long the engine stays in memory after the last use, before being freed.', + keepLoadedDesc: 'How long Qwen3-ASR stays in memory after the last use, before being freed.', keepImmediate: 'Release immediately', keep1min: '1 minute after last use', keep5min: '5 minutes after last use (default)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index fd1a32d8..fa745c30 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -529,8 +529,29 @@ export const zhCN = { localAsr: { kicker: '本地 ASR', title: '模型设置', - desc: '本地 Qwen3-ASR 引擎与模型管理。模型从 HuggingFace 下载到本机,无需联网即可识别。Windows 端流式推理跟踪见仓库 issue。', - engineUnavailable: '当前平台暂未集成本地推理引擎(仅 macOS 已支持,Windows 端跟踪 issue #256)。可下载模型,但暂时无法启用。', + desc: '管理本机 ASR 模型。Windows 可使用 Microsoft Foundry Local Whisper;Qwen3-ASR 模型管理保持独立。', + qwenTitle: 'Qwen3-ASR 模型管理', + engineUnavailable: '当前平台暂未集成 Qwen3-ASR 推理引擎。可下载模型,但暂时无法启用 Qwen3-ASR。', + foundryTitle: 'Windows Foundry Local Whisper', + foundryDesc: 'Windows 使用 Microsoft Foundry Local Whisper 在本机识别语音,无需 ASR API Key。首次准备会在本机下载运行组件和模型并加载;LLM 润色仍使用你已配置的 LLM 提供商,未配置时沿用原始转写回退。', + foundryAvailable: '运行时可用', + foundryUnavailable: '运行时不可用', + foundrySelectedModel: '选择模型', + foundryActiveModel: '当前默认 alias', + foundryLoadedModel: '已加载模型', + foundryNotLoaded: '未加载', + foundryError: 'Foundry 状态', + foundrySetDefault: '设为默认 / 启用 Windows 本地 ASR', + foundryEnabling: '正在启用…', + foundryPrepare: '准备 / 下载 / 加载', + foundryPreparing: '正在准备…', + foundryReleasing: '正在释放…', + foundryModelSmall: 'Whisper Small(默认 / 平衡)', + foundryModelSmallDesc: '默认平衡选项,兼顾质量与资源占用。', + foundryModelBase: 'Whisper Base(更快 / 更省资源)', + foundryModelBaseDesc: '更快、资源占用更低,适合日常轻量使用。', + foundryModelTiny: 'Whisper Tiny(最快 / 冒烟测试)', + foundryModelTinyDesc: '最快的检查选项,适合确认 Foundry 路径可用。', mirrorLabel: '下载镜像源', mirrorDesc: '官方源在国外网络更稳;hf-mirror.com 是国内社区维护的镜像。', mirrorHuggingface: 'HuggingFace 官方 (huggingface.co)', @@ -547,7 +568,7 @@ export const zhCN = { files: '文件', sizeLoading: '正在查询尺寸…', sizeUnknown: '尺寸未知', - performanceWarning: '本地推理跑在 CPU + Apple Silicon Accelerate 上,**首次转写需要加载模型(数秒)**,之后单次转写也会比云端 ASR 慢若干秒;中文识别准确率与方言/口音表现通常不如火山引擎 / Whisper turbo。适用场景:离线 / 隐私敏感 / 不愿付费云 API。', + performanceWarning: '本地 ASR 适合离线、隐私敏感或不想使用云端 ASR API 的场景。首次使用可能需要较长时间,因为运行时、模型下载和加载都在本机完成。', test: '加载并测试', testRunning: '测试中…', testHeading: '内置音频测试', @@ -556,12 +577,12 @@ export const zhCN = { testStats: '音频时长 {{audio}}s · 加载 {{load}}s · 推理 {{transcribe}}s · 后端 {{backend}}', testFailed: '测试失败', engineStatusLabel: '内存中的引擎', - engineLoaded: '已加载:{{model}}(约占 1.2-3.4 GB 内存)', - engineUnloaded: '未加载(首次听写需先加载,约 3-5 秒)', + engineLoaded: '已加载:{{model}}', + engineUnloaded: '未加载(首次听写需先加载模型)', loadNow: '立即加载', releaseNow: '立即释放', keepLoadedLabel: '保持加载多久', - keepLoadedDesc: '决定本地 ASR 用完后多久从内存释放,避免长期占用 1+ GB RAM。', + keepLoadedDesc: '决定 Qwen3-ASR 用完后多久从内存释放,避免长期占用内存。', keepImmediate: '说完话立即释放', keep1min: '上次使用后 1 分钟', keep5min: '上次使用后 5 分钟(默认)', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 270db07f..2dd54346 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -531,8 +531,29 @@ export const zhTW: typeof zhCN = { localAsr: { kicker: '本地 ASR', title: '模型設置', - desc: '本地 Qwen3-ASR 引擎與模型管理。模型從 HuggingFace 下載到本機,無需聯網即可識別。Windows 端流式推理跟蹤見倉庫 issue。', - engineUnavailable: '當前平臺暫未集成本地推理引擎(僅 macOS 已支持,Windows 端跟蹤 issue #256)。可下載模型,但暫時無法啟用。', + desc: '管理本機 ASR 模型。Windows 可使用 Microsoft Foundry Local Whisper;Qwen3-ASR 模型管理保持獨立。', + qwenTitle: 'Qwen3-ASR 模型管理', + engineUnavailable: '當前平臺暫未集成 Qwen3-ASR 推理引擎。可下載模型,但暫時無法啟用 Qwen3-ASR。', + foundryTitle: 'Windows Foundry Local Whisper', + foundryDesc: 'Windows 使用 Microsoft Foundry Local Whisper 在本機識別語音,無需 ASR API Key。首次準備會在本機下載運行組件和模型並加載;LLM 潤色仍使用你已配置的 LLM 提供商,未配置時沿用原始轉寫回退。', + foundryAvailable: '運行時可用', + foundryUnavailable: '運行時不可用', + foundrySelectedModel: '選擇模型', + foundryActiveModel: '當前默認 alias', + foundryLoadedModel: '已加載模型', + foundryNotLoaded: '未加載', + foundryError: 'Foundry 狀態', + foundrySetDefault: '設為默認 / 啟用 Windows 本地 ASR', + foundryEnabling: '正在啟用…', + foundryPrepare: '準備 / 下載 / 加載', + foundryPreparing: '正在準備…', + foundryReleasing: '正在釋放…', + foundryModelSmall: 'Whisper Small(默認 / 平衡)', + foundryModelSmallDesc: '默認平衡選項,兼顧質量與資源佔用。', + foundryModelBase: 'Whisper Base(更快 / 更省資源)', + foundryModelBaseDesc: '更快、資源佔用更低,適合日常輕量使用。', + foundryModelTiny: 'Whisper Tiny(最快 / 冒煙測試)', + foundryModelTinyDesc: '最快的檢查選項,適合確認 Foundry 路徑可用。', mirrorLabel: '下載鏡像源', mirrorDesc: '官方源在國外網絡更穩;hf-mirror.com 是國內社區維護的鏡像。', mirrorHuggingface: 'HuggingFace 官方 (huggingface.co)', @@ -549,7 +570,7 @@ export const zhTW: typeof zhCN = { files: '文件', sizeLoading: '正在查詢尺寸…', sizeUnknown: '尺寸未知', - performanceWarning: '本地推理跑在 CPU + Apple Silicon Accelerate 上,**首次轉寫需要加載模型(數秒)**,之後單次轉寫也會比雲端 ASR 慢若干秒;中文識別準確率與方言/口音表現通常不如火山引擎 / Whisper turbo。適用場景:離線 / 隱私敏感 / 不願付費雲 API。', + performanceWarning: '本地 ASR 適合離線、隱私敏感或不想使用雲端 ASR API 的場景。首次使用可能需要較長時間,因為運行時、模型下載和加載都在本機完成。', test: '加載並測試', testRunning: '測試中…', testHeading: '內置音頻測試', @@ -558,12 +579,12 @@ export const zhTW: typeof zhCN = { testStats: '音頻時長 {{audio}}s · 加載 {{load}}s · 推理 {{transcribe}}s · 後端 {{backend}}', testFailed: '測試失敗', engineStatusLabel: '內存中的引擎', - engineLoaded: '已加載:{{model}}(約佔 1.2-3.4 GB 內存)', - engineUnloaded: '未加載(首次聽寫需先加載,約 3-5 秒)', + engineLoaded: '已加載:{{model}}', + engineUnloaded: '未加載(首次聽寫需先加載模型)', loadNow: '立即加載', releaseNow: '立即釋放', keepLoadedLabel: '保持加載多久', - keepLoadedDesc: '決定本地 ASR 用完後多久從內存釋放,避免長期佔用 1+ GB RAM。', + keepLoadedDesc: '決定 Qwen3-ASR 用完後多久從內存釋放,避免長期佔用內存。', keepImmediate: '說完話立即釋放', keep1min: '上次使用後 1 分鐘', keep5min: '上次使用後 5 分鐘(默認)', diff --git a/openless-all/app/src/lib/localAsr.ts b/openless-all/app/src/lib/localAsr.ts index 741323c7..1486eac0 100644 --- a/openless-all/app/src/lib/localAsr.ts +++ b/openless-all/app/src/lib/localAsr.ts @@ -1,4 +1,4 @@ -// localAsr.ts — IPC + 事件类型 for 本地 Qwen3-ASR 引擎与模型管理。 +// localAsr.ts — IPC + 事件类型 for 本地 ASR 引擎与模型管理。 // // 后端命令定义:openless-all/app/src-tauri/src/commands.rs `local_asr_*` // 事件:local-asr-download-progress / local-asr-token @@ -64,6 +64,32 @@ export interface FoundryLocalAsrStatus { error: string | null; } +export type FoundryLocalAsrModelAlias = 'whisper-small' | 'whisper-base' | 'whisper-tiny'; + +export interface FoundryLocalAsrModelOption { + alias: FoundryLocalAsrModelAlias; + labelKey: string; + descKey: string; +} + +export const FOUNDRY_LOCAL_ASR_MODELS: FoundryLocalAsrModelOption[] = [ + { + alias: 'whisper-small', + labelKey: 'localAsr.foundryModelSmall', + descKey: 'localAsr.foundryModelSmallDesc', + }, + { + alias: 'whisper-base', + labelKey: 'localAsr.foundryModelBase', + descKey: 'localAsr.foundryModelBaseDesc', + }, + { + alias: 'whisper-tiny', + labelKey: 'localAsr.foundryModelTiny', + descKey: 'localAsr.foundryModelTinyDesc', + }, +]; + const MOCK_SETTINGS: LocalAsrSettings = { providerId: 'local-qwen3', activeModel: 'qwen3-asr-0.6b', @@ -208,6 +234,10 @@ export function setFoundryLocalAsrLanguageHint(languageHint: string): Promise { + return invokeOrMock('foundry_local_asr_prepare', { modelAlias }, () => `mock-${modelAlias}`); +} + export function releaseFoundryLocalAsr(): Promise { return invokeOrMock('foundry_local_asr_release', undefined, () => undefined); } diff --git a/openless-all/app/src/pages/LocalAsr.tsx b/openless-all/app/src/pages/LocalAsr.tsx index 9b166a40..5fee1bf1 100644 --- a/openless-all/app/src/pages/LocalAsr.tsx +++ b/openless-all/app/src/pages/LocalAsr.tsx @@ -1,4 +1,4 @@ -// LocalAsr.tsx — 本地 Qwen3-ASR 模型管理页。 +// LocalAsr.tsx — 本地 ASR 模型管理页。 // // 功能: // - 顶部:当前激活模型 + 镜像源切换 @@ -11,25 +11,33 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { isTauri, setActiveAsrProvider } from '../lib/ipc'; import { + FOUNDRY_LOCAL_ASR_MODELS, cancelLocalAsrDownload, deleteLocalAsrModel, downloadLocalAsrModel, fetchLocalAsrRemoteInfo, + getFoundryLocalAsrStatus, getLocalAsrEngineStatus, getLocalAsrSettings, listLocalAsrModels, + prepareFoundryLocalAsr, preloadLocalAsr, + releaseFoundryLocalAsr, releaseLocalAsrEngine, + setFoundryLocalAsrModel, setLocalAsrActiveModel, setLocalAsrKeepLoadedSecs, setLocalAsrMirror, testLocalAsrModel, + type FoundryLocalAsrModelAlias, + type FoundryLocalAsrStatus, type LocalAsrDownloadProgress, type LocalAsrEngineStatus, type LocalAsrModelStatus, type LocalAsrSettings, type LocalAsrTestResult, } from '../lib/localAsr'; +import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; interface RemoteSize { @@ -41,12 +49,16 @@ interface RemoteSize { export function LocalAsr() { const { t } = useTranslation(); + const { prefs, updatePrefs } = useHotkeySettings(); const [settings, setSettings] = useState(null); const [models, setModels] = useState([]); const [progress, setProgress] = useState>({}); const [remoteSizes, setRemoteSizes] = useState>({}); const [error, setError] = useState(null); const [busyModelId, setBusyModelId] = useState(null); + const [foundryStatus, setFoundryStatus] = useState(null); + const [selectedFoundryAlias, setSelectedFoundryAlias] = useState('whisper-small'); + const [foundryBusy, setFoundryBusy] = useState<'enable' | 'prepare' | 'release' | null>(null); const [testingModelId, setTestingModelId] = useState(null); const [testResults, setTestResults] = useState>({}); const [engineStatus, setEngineStatus] = useState(null); @@ -62,6 +74,26 @@ export function LocalAsr() { } }; + const refreshFoundryStatus = async () => { + try { + const status = await getFoundryLocalAsrStatus(); + setFoundryStatus(status); + if (isFoundryAlias(status.activeModel)) { + setSelectedFoundryAlias(status.activeModel); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setFoundryStatus({ + providerId: 'foundry-local-whisper', + available: false, + activeModel: selectedFoundryAlias, + loadedModelId: null, + endpoint: null, + error: message, + }); + } + }; + const refresh = async () => { try { setError(null); @@ -69,6 +101,7 @@ export function LocalAsr() { setSettings(s); setModels(list); void refreshEngineStatus(); + void refreshFoundryStatus(); // 拉远端真实尺寸(每个模型一次,结果留缓存) void Promise.all( list.map(async m => { @@ -183,6 +216,60 @@ export function LocalAsr() { } }; + const syncFoundryPrefs = async (modelAlias: FoundryLocalAsrModelAlias, enableProvider: boolean) => { + await updatePrefs(current => ({ + ...current, + activeAsrProvider: enableProvider ? 'foundry-local-whisper' : current.activeAsrProvider, + foundryLocalAsrModel: modelAlias, + })); + }; + + const handleEnableFoundry = async () => { + if (!foundryAvailable) return; + setFoundryBusy('enable'); + try { + setError(null); + await setFoundryLocalAsrModel(selectedFoundryAlias); + await setActiveAsrProvider('foundry-local-whisper'); + await syncFoundryPrefs(selectedFoundryAlias, true); + await refreshFoundryStatus(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setFoundryBusy(null); + } + }; + + const handlePrepareFoundry = async () => { + if (!foundryAvailable) return; + setFoundryBusy('prepare'); + try { + setError(null); + await setFoundryLocalAsrModel(selectedFoundryAlias); + await syncFoundryPrefs(selectedFoundryAlias, false); + await prepareFoundryLocalAsr(selectedFoundryAlias); + await refreshFoundryStatus(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + await refreshFoundryStatus(); + } finally { + setFoundryBusy(null); + } + }; + + const handleReleaseFoundry = async () => { + setFoundryBusy('release'); + try { + setError(null); + await releaseFoundryLocalAsr(); + await refreshFoundryStatus(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setFoundryBusy(null); + } + }; + const handleDownload = async (modelId: string) => { setBusyModelId(modelId); try { @@ -275,6 +362,11 @@ export function LocalAsr() { }; const engineAvailable = settings?.engineAvailable ?? false; + const foundryAvailable = foundryStatus?.available === true; + const foundryDefault = prefs?.activeAsrProvider === 'foundry-local-whisper'; + const selectedFoundryModel = FOUNDRY_LOCAL_ASR_MODELS.find( + model => model.alias === selectedFoundryAlias, + ) ?? FOUNDRY_LOCAL_ASR_MODELS[0]; return (
@@ -291,6 +383,95 @@ export function LocalAsr() {
+ +
+
+
+
+
+ {t('localAsr.foundryTitle')} +
+ {foundryDefault && {t('localAsr.activeBadge')}} + + {foundryStatus?.available + ? t('localAsr.foundryAvailable') + : t('localAsr.foundryUnavailable')} + +
+
+ {t('localAsr.foundryDesc')} +
+
+ +
+ +
+
+ {t('localAsr.foundrySelectedModel')}: + {t(selectedFoundryModel.labelKey)} + · {t(selectedFoundryModel.descKey)} +
+
+ {t('localAsr.foundryActiveModel')}: + {foundryStatus?.activeModel ?? 'whisper-small'} +
+
+ {t('localAsr.foundryLoadedModel')}: + {foundryStatus?.loadedModelId ?? t('localAsr.foundryNotLoaded')} +
+ {foundryStatus?.error && ( +
+ {t('localAsr.foundryError')}: + {foundryStatus.error} +
+ )} +
+ +
+ void handleEnableFoundry()}> + {foundryBusy === 'enable' ? t('localAsr.foundryEnabling') : t('localAsr.foundrySetDefault')} + + void handlePrepareFoundry()}> + {foundryBusy === 'prepare' ? t('localAsr.foundryPreparing') : t('localAsr.foundryPrepare')} + + void handleReleaseFoundry()}> + {foundryBusy === 'release' ? t('localAsr.foundryReleasing') : t('localAsr.releaseNow')} + +
+
+
+ {!engineAvailable && (
@@ -299,6 +480,10 @@ export function LocalAsr() { )} +
+ {t('localAsr.qwenTitle')} +
+
@@ -610,6 +795,10 @@ function TestResultBlock({ result }: { result: LocalAsrTestResult | { error: str ); } +function isFoundryAlias(value: string): value is FoundryLocalAsrModelAlias { + return FOUNDRY_LOCAL_ASR_MODELS.some(model => model.alias === value); +} + function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 34f320dc..a6f84381 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -29,6 +29,7 @@ const ASR_NAME_KEY_BY_ID: Record = { zhipu: 'asrZhipu', groq: 'asrGroq', whisper: 'asrWhisper', + 'foundry-local-whisper': 'asrFoundryLocalWhisper', 'local-qwen3': 'asrLocalQwen3', }; From 19977bc52ee0d96311a2d2f50828dff8af7bff4f Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 18:01:52 +0800 Subject: [PATCH 20/37] test(windows): add local ASR smoke mode --- .../windows-real-asr-insertion-smoke.ps1 | 180 ++++++++++++++---- 1 file changed, 140 insertions(+), 40 deletions(-) diff --git a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 index 1c11ca18..d420c6f2 100644 --- a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 +++ b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 @@ -2,6 +2,8 @@ param( [string]$ExePath = "", [ValidateSet("notepad", "browser", "wt-cmd", "wt-powershell", "win32edit")] [string]$Target = "notepad", + [ValidateSet("volcengine", "foundry-local-whisper")] + [string]$AsrProvider = "volcengine", [string]$Phrase = "OpenLess Windows real regression", [int]$TimeoutSeconds = 120, [int]$VirtualKey = 0xA3, @@ -118,7 +120,11 @@ function Set-HoldHotkeyPreference($Path) { if ($null -eq $prefs.enabledModes) { $prefs | Add-Member -NotePropertyName enabledModes -NotePropertyValue @("light", "structured", "formal", "raw") } if ($null -eq $prefs.launchAtLogin) { $prefs | Add-Member -NotePropertyName launchAtLogin -NotePropertyValue $false } if ($null -eq $prefs.showCapsule) { $prefs | Add-Member -NotePropertyName showCapsule -NotePropertyValue $true } - if ($null -eq $prefs.activeAsrProvider) { $prefs | Add-Member -NotePropertyName activeAsrProvider -NotePropertyValue "volcengine" } + if ($null -eq $prefs.PSObject.Properties["activeAsrProvider"]) { + $prefs | Add-Member -NotePropertyName activeAsrProvider -NotePropertyValue $AsrProvider + } else { + $prefs.activeAsrProvider = $AsrProvider + } if ($null -eq $prefs.activeLlmProvider) { $prefs | Add-Member -NotePropertyName activeLlmProvider -NotePropertyValue "ark" } if ($null -eq $prefs.restoreClipboardAfterPaste) { $prefs | Add-Member -NotePropertyName restoreClipboardAfterPaste -NotePropertyValue $true @@ -129,6 +135,55 @@ function Set-HoldHotkeyPreference($Path) { return $previous } +function Set-ActiveAsrCredential($Path) { + $previous = Read-TextUtf8 $Path + if ([string]::IsNullOrWhiteSpace($previous)) { + $credentials = [pscustomobject]@{ + version = 1 + active = [pscustomobject]@{ + asr = $AsrProvider + llm = "ark" + } + providers = [pscustomobject]@{ + asr = [pscustomobject]@{} + llm = [pscustomobject]@{} + } + } + } else { + $credentials = $previous | ConvertFrom-Json + if ($null -eq $credentials.PSObject.Properties["active"]) { + $credentials | Add-Member -NotePropertyName active -NotePropertyValue ([pscustomobject]@{}) + } elseif ($null -eq $credentials.active) { + $credentials.active = [pscustomobject]@{} + } + if ($null -eq $credentials.active.PSObject.Properties["asr"]) { + $credentials.active | Add-Member -NotePropertyName asr -NotePropertyValue $AsrProvider + } else { + $credentials.active.asr = $AsrProvider + } + if ($null -eq $credentials.active.PSObject.Properties["llm"]) { + $credentials.active | Add-Member -NotePropertyName llm -NotePropertyValue "ark" + } + if ($null -eq $credentials.PSObject.Properties["providers"]) { + $credentials | Add-Member -NotePropertyName providers -NotePropertyValue ([pscustomobject]@{}) + } elseif ($null -eq $credentials.providers) { + $credentials.providers = [pscustomobject]@{} + } + if ($null -eq $credentials.providers.PSObject.Properties["asr"]) { + $credentials.providers | Add-Member -NotePropertyName asr -NotePropertyValue ([pscustomobject]@{}) + } elseif ($null -eq $credentials.providers.asr) { + $credentials.providers.asr = [pscustomobject]@{} + } + if ($null -eq $credentials.providers.PSObject.Properties["llm"]) { + $credentials.providers | Add-Member -NotePropertyName llm -NotePropertyValue ([pscustomobject]@{}) + } elseif ($null -eq $credentials.providers.llm) { + $credentials.providers.llm = [pscustomobject]@{} + } + } + Write-TextUtf8 $Path ($credentials | ConvertTo-Json -Depth 12) + return $previous +} + function Wait-LogPattern($Path, $Pattern, $TimeoutSeconds) { $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { @@ -593,50 +648,76 @@ function Speak-TestPhrase($Text) { } $credentialStatus = Get-OpenLessCredentialStatus -if ($RequireJsonCredentials -and (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured)) { - throw "Real ASR regression requires configured Volcengine ASR and Ark LLM credentials." +if ($RequireJsonCredentials) { + if ($AsrProvider -eq "volcengine" -and (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured)) { + throw "Real ASR regression requires configured Volcengine ASR and Ark LLM credentials when ASR=volcengine." + } + if ($AsrProvider -eq "foundry-local-whisper" -and -not $credentialStatus.ArkConfigured) { + Write-Warning "Ark LLM credentials are not configured; local ASR smoke accepts the existing raw transcript fallback when LLM is unconfigured." + } } if (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured) { - Write-Warning "Legacy credentials.json is incomplete; continuing because the app may use the OS credential vault." + $missingCredentialParts = @() + if (-not $credentialStatus.VolcengineConfigured) { $missingCredentialParts += "Volcengine ASR" } + if (-not $credentialStatus.ArkConfigured) { $missingCredentialParts += "Ark LLM" } + $providerCredentialNote = if ($AsrProvider -eq "volcengine") { + "ASR=volcengine needs Volcengine ASR and Ark LLM credentials unless the app resolves them from the OS credential vault." + } else { + "ASR=foundry-local-whisper does not require Volcengine credentials; Ark LLM is optional because raw transcript fallback is accepted." + } + Write-Warning "Legacy credentials.json is incomplete ($($missingCredentialParts -join ', ')); $providerCredentialNote Continuing because the app may use the OS credential vault." } $logPath = Join-Path $env:LOCALAPPDATA "OpenLess\Logs\openless.log" $historyPath = Join-Path $env:APPDATA "OpenLess\history.json" $preferencesPath = Join-Path $env:APPDATA "OpenLess\preferences.json" -$baselineCount = Get-HistoryCount $historyPath -$previousPreferences = Set-HoldHotkeyPreference $preferencesPath -$previousClipboard = Get-Clipboard -Raw -ErrorAction SilentlyContinue -$clipboardSentinel = "OPENLESS_OLD_CLIPBOARD_SENTINEL_$(Get-Date -Format 'yyyyMMddHHmmssfff')" -Restore-ClipboardValue $clipboardSentinel +$credentialsPath = Join-Path $env:APPDATA "OpenLess\credentials.json" +$inputTarget = $null +$openless = $null +$previousPreferences = $null +$previousCredentials = $null +$previousClipboard = $null $debugTranscriptPath = $null -if (-not [string]::IsNullOrWhiteSpace($InjectedTranscriptText)) { - $debugTranscriptPath = Join-Path $env:TEMP "openless-debug-transcript.txt" - Write-TextUtf8 $debugTranscriptPath $InjectedTranscriptText -} +$preferencesRewritten = $false +$credentialsRewritten = $false +$clipboardCaptured = $false -Get-Process openless -ErrorAction SilentlyContinue | Stop-Process -Force -Remove-Item -LiteralPath $logPath -Force -ErrorAction SilentlyContinue - -Write-Host "== Real ASR + direct insertion smoke ($Target) ==" -$env:OPENLESS_SHOW_MAIN_ON_START = "1" -$env:OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS = "1" -if ($DebugHotkeyEvents) { - $env:OPENLESS_DEBUG_HOTKEY_EVENTS = "1" -} -if ($debugTranscriptPath) { - $env:OPENLESS_DEBUG_TRANSCRIPT_FILE = $debugTranscriptPath -} try { - $openless = Start-Process -FilePath $ExePath -WorkingDirectory (Split-Path $ExePath -Parent) -PassThru -} finally { - Remove-Item Env:OPENLESS_SHOW_MAIN_ON_START -ErrorAction SilentlyContinue - Remove-Item Env:OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS -ErrorAction SilentlyContinue - Remove-Item Env:OPENLESS_DEBUG_HOTKEY_EVENTS -ErrorAction SilentlyContinue - Remove-Item Env:OPENLESS_DEBUG_TRANSCRIPT_FILE -ErrorAction SilentlyContinue -} + $baselineCount = Get-HistoryCount $historyPath + $previousClipboard = Get-Clipboard -Raw -ErrorAction SilentlyContinue + $clipboardCaptured = $true + $previousPreferences = Set-HoldHotkeyPreference $preferencesPath + $preferencesRewritten = $true + $previousCredentials = Set-ActiveAsrCredential $credentialsPath + $credentialsRewritten = $true + $clipboardSentinel = "OPENLESS_OLD_CLIPBOARD_SENTINEL_$(Get-Date -Format 'yyyyMMddHHmmssfff')" + Restore-ClipboardValue $clipboardSentinel + if (-not [string]::IsNullOrWhiteSpace($InjectedTranscriptText)) { + $debugTranscriptPath = Join-Path $env:TEMP "openless-debug-transcript.txt" + Write-TextUtf8 $debugTranscriptPath $InjectedTranscriptText + } + + Get-Process openless -ErrorAction SilentlyContinue | Stop-Process -Force + Remove-Item -LiteralPath $logPath -Force -ErrorAction SilentlyContinue + + Write-Host "== Real ASR + direct insertion smoke ($Target, ASR=$AsrProvider) ==" + $env:OPENLESS_SHOW_MAIN_ON_START = "1" + $env:OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS = "1" + if ($DebugHotkeyEvents) { + $env:OPENLESS_DEBUG_HOTKEY_EVENTS = "1" + } + if ($debugTranscriptPath) { + $env:OPENLESS_DEBUG_TRANSCRIPT_FILE = $debugTranscriptPath + } + try { + $openless = Start-Process -FilePath $ExePath -WorkingDirectory (Split-Path $ExePath -Parent) -PassThru + } finally { + Remove-Item Env:OPENLESS_SHOW_MAIN_ON_START -ErrorAction SilentlyContinue + Remove-Item Env:OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS -ErrorAction SilentlyContinue + Remove-Item Env:OPENLESS_DEBUG_HOTKEY_EVENTS -ErrorAction SilentlyContinue + Remove-Item Env:OPENLESS_DEBUG_TRANSCRIPT_FILE -ErrorAction SilentlyContinue + } -$inputTarget = $null -try { if (-not (Wait-LogPattern $logPath "hotkey listener installed|Windows low-level keyboard hook" 20)) { throw "Windows low-level keyboard hook was not installed." } @@ -705,6 +786,14 @@ try { Write-Host "[ok] History updated. raw='$($latest.rawTranscript)'" Write-Host "[ok] Final text length=$($latest.finalText.Length), insertStatus=$($latest.insertStatus)" Write-Host "[ok] $Target readback length=$($targetText.Length)" + + if (Test-Path $logPath) { + $logText = Get-Content -Raw -Encoding UTF8 $logPath + $forbiddenNativeDictationPattern = "Win\+H|Voice Typing|Windows\.Media\.SpeechRecognition|SpeechRecognizer|SAPI" + if ($logText -match $forbiddenNativeDictationPattern) { + throw "OpenLess log contains a native Windows dictation route marker; this smoke must use the OpenLess pipeline." + } + } } finally { Release-Hotkey if ($null -ne $inputTarget) { @@ -721,15 +810,26 @@ try { } } Get-Process openless -ErrorAction SilentlyContinue | Stop-Process -Force - if ($null -eq $previousPreferences) { - Remove-Item -LiteralPath $preferencesPath -Force -ErrorAction SilentlyContinue - } else { - Write-TextUtf8 $preferencesPath $previousPreferences + if ($preferencesRewritten) { + if ($null -eq $previousPreferences) { + Remove-Item -LiteralPath $preferencesPath -Force -ErrorAction SilentlyContinue + } else { + Write-TextUtf8 $preferencesPath $previousPreferences + } + } + if ($credentialsRewritten) { + if ($null -eq $previousCredentials) { + Remove-Item -LiteralPath $credentialsPath -Force -ErrorAction SilentlyContinue + } else { + Write-TextUtf8 $credentialsPath $previousCredentials + } + } + if ($clipboardCaptured) { + Restore-ClipboardValue $previousClipboard } - Restore-ClipboardValue $previousClipboard if ($debugTranscriptPath) { Remove-Item -LiteralPath $debugTranscriptPath -Force -ErrorAction SilentlyContinue } } -Write-Host "Real ASR + direct insertion smoke ($Target) passed." +Write-Host "Real ASR + direct insertion smoke ($Target, ASR=$AsrProvider) passed." From 05f4fce895c17430c3f5d14ab1bf51e2cda7b811 Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 18:34:11 +0800 Subject: [PATCH 21/37] fix: gate Foundry local ASR to Windows --- openless-all/app/src-tauri/src/commands.rs | 48 ++++++++++++++++--- openless-all/app/src-tauri/src/coordinator.rs | 23 ++++++++- openless-all/app/src/pages/Settings.tsx | 13 +++-- 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index f963948a..96ce08e1 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -129,7 +129,7 @@ fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bo if provider == "volcengine" { return volcengine_configured(snap); } - if provider == crate::asr::local::PROVIDER_ID || provider == FOUNDRY_LOCAL_PROVIDER_ID { + if provider == crate::asr::local::PROVIDER_ID || active_foundry_asr_is_supported(provider) { // 本地 ASR 不依赖云端凭据。 return true; } @@ -162,6 +162,9 @@ pub fn set_active_asr_provider( coord: CoordinatorState<'_>, provider: String, ) -> Result<(), String> { + if provider == FOUNDRY_LOCAL_PROVIDER_ID && !active_foundry_asr_is_supported(&provider) { + return Err("Foundry Local Whisper is only available on Windows".to_string()); + } CredentialsVault::set_active_asr_provider(&provider).map_err(|e| e.to_string())?; if provider == crate::asr::local::PROVIDER_ID { // 切到本地 ASR → 后台预加载模型,下次按 hotkey 时不必等数秒。 @@ -302,7 +305,19 @@ async fn validate_asr_provider() -> Result<(), String> { } fn active_asr_is_keyless_for_validation(provider: &str) -> bool { - provider == crate::asr::local::PROVIDER_ID || provider == FOUNDRY_LOCAL_PROVIDER_ID + provider == crate::asr::local::PROVIDER_ID || active_foundry_asr_is_supported(provider) +} + +fn active_foundry_asr_is_supported(provider: &str) -> bool { + #[cfg(target_os = "windows")] + { + provider == FOUNDRY_LOCAL_PROVIDER_ID + } + #[cfg(not(target_os = "windows"))] + { + let _ = provider; + false + } } async fn validate_asr_transcription(config: &ProviderConfig, model: &str) -> Result<(), String> { @@ -1049,18 +1064,34 @@ mod tests { crate::asr::local::PROVIDER_ID, &snapshot() )); + #[cfg(target_os = "windows")] assert!(asr_configured_for_provider( crate::asr::local::foundry::PROVIDER_ID, &snapshot() )); + #[cfg(not(target_os = "windows"))] + assert!(!asr_configured_for_provider( + crate::asr::local::foundry::PROVIDER_ID, + &snapshot() + )); } #[test] fn credentials_status_treats_foundry_local_asr_as_configured() { - assert!(asr_configured_for_provider( - crate::asr::local::foundry::PROVIDER_ID, - &CredentialsSnapshot::default() - )); + #[cfg(target_os = "windows")] + { + assert!(asr_configured_for_provider( + crate::asr::local::foundry::PROVIDER_ID, + &CredentialsSnapshot::default() + )); + } + #[cfg(not(target_os = "windows"))] + { + assert!(!asr_configured_for_provider( + crate::asr::local::foundry::PROVIDER_ID, + &CredentialsSnapshot::default() + )); + } } #[test] @@ -1068,9 +1099,14 @@ mod tests { assert!(active_asr_is_keyless_for_validation( crate::asr::local::PROVIDER_ID )); + #[cfg(target_os = "windows")] assert!(active_asr_is_keyless_for_validation( crate::asr::local::foundry::PROVIDER_ID )); + #[cfg(not(target_os = "windows"))] + assert!(!active_asr_is_keyless_for_validation( + crate::asr::local::foundry::PROVIDER_ID + )); assert!(!active_asr_is_keyless_for_validation("volcengine")); assert!(!active_asr_is_keyless_for_validation("whisper")); } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 9b9b8aa2..39558c5d 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2277,6 +2277,10 @@ fn ensure_asr_credentials() -> Result<(), String> { } if crate::asr::local::foundry::is_foundry_local_whisper(&active_asr) { + #[cfg(not(target_os = "windows"))] + { + return Err("Foundry Local Whisper 当前仅支持 Windows".to_string()); + } return Ok(()); } @@ -2301,8 +2305,18 @@ fn ensure_asr_credentials() -> Result<(), String> { #[cfg(test)] fn is_keyless_local_asr_provider(id: &str) -> bool { - crate::asr::local::is_local_qwen3(id) - || crate::asr::local::foundry::is_foundry_local_whisper(id) + if crate::asr::local::is_local_qwen3(id) { + return true; + } + #[cfg(target_os = "windows")] + { + crate::asr::local::foundry::is_foundry_local_whisper(id) + } + #[cfg(not(target_os = "windows"))] + { + let _ = id; + false + } } #[cfg(target_os = "macos")] @@ -3144,9 +3158,14 @@ mod tests { #[test] fn foundry_local_provider_is_keyless_and_not_whisper_compatible() { + #[cfg(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::foundry::PROVIDER_ID + )); assert!(!is_whisper_compatible_provider( crate::asr::local::foundry::PROVIDER_ID )); diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 946e15d4..c84b40ed 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -411,7 +411,7 @@ type AsrPresetId = typeof ASR_PRESETS[number]['id']; function ProvidersSection() { const { t } = useTranslation(); - const { prefs, updatePrefs } = useHotkeySettings(); + const { prefs, updatePrefs, capability } = useHotkeySettings(); // `*Provider` 立即跟随 必须反映用户最新选择。 @@ -505,7 +508,7 @@ function ProvidersSection() { // 否则受控 From f03499576eea8f89047af9f2e4d6c0f39c4cab0a Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 18:49:23 +0800 Subject: [PATCH 22/37] fix: show Foundry ASR option on Windows --- openless-all/app/src/pages/Settings.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index c84b40ed..10516780 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -6,6 +6,7 @@ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoUpdate'; +import { detectOS } from '../components/WindowChrome'; import { APP_VERSION_LABEL } from '../lib/appVersion'; import { isHotkeyModeMigrationNoticeActive } from '../lib/hotkeyMigration'; import { getHotkeyStartStopLabel, getHotkeyTriggerLabel } from '../lib/hotkey'; @@ -411,7 +412,7 @@ type AsrPresetId = typeof ASR_PRESETS[number]['id']; function ProvidersSection() { const { t } = useTranslation(); - const { prefs, updatePrefs, capability } = useHotkeySettings(); + const { prefs, updatePrefs } = useHotkeySettings(); // `*Provider` 立即跟随 必须反映用户最新选择。 From 2d07493d40d38d745bdb7c977ed8a0377a6e01a6 Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 20:02:28 +0800 Subject: [PATCH 23/37] feat: improve Foundry local ASR setup --- .../app/src-tauri/src/asr/local/foundry.rs | 162 ++++++++++ .../src/asr/local/foundry_provider.rs | 2 +- .../src/asr/local/foundry_runtime.rs | 217 ++++++++++++- openless-all/app/src-tauri/src/commands.rs | 59 +++- openless-all/app/src-tauri/src/lib.rs | 2 + openless-all/app/src/i18n/en.ts | 18 ++ openless-all/app/src/i18n/zh-CN.ts | 18 ++ openless-all/app/src/i18n/zh-TW.ts | 18 ++ openless-all/app/src/lib/localAsr.ts | 52 +++ openless-all/app/src/pages/LocalAsr.tsx | 301 ++++++++++++++++-- 10 files changed, 807 insertions(+), 42 deletions(-) diff --git a/openless-all/app/src-tauri/src/asr/local/foundry.rs b/openless-all/app/src-tauri/src/asr/local/foundry.rs index 4da401d7..4415f102 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry.rs @@ -41,11 +41,141 @@ pub fn model_alias_is_known(alias: &str) -> bool { MODELS.iter().any(|model| model.alias == alias) } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct FoundryCatalogModel { + pub alias: String, + pub display_name: String, + pub cached: bool, + pub file_size_mb: Option, +} + +impl FoundryCatalogModel { + #[allow(dead_code)] + pub fn from_static(model: &FoundryWhisperModel) -> Self { + Self { + alias: model.alias.to_string(), + display_name: model.display_name.to_string(), + cached: false, + file_size_mb: None, + } + } +} + +#[allow(dead_code)] +pub fn static_catalog_models() -> Vec { + MODELS + .iter() + .map(FoundryCatalogModel::from_static) + .collect() +} + #[allow(dead_code)] pub fn default_language_hint() -> Option { None } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub enum FoundryPreparePhase { + Runtime, + Model, + Load, + Finished, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct FoundryPrepareProgressPayload { + pub phase: FoundryPreparePhase, + pub model_alias: String, + pub label: String, + pub percent: Option, + pub error: Option, +} + +impl FoundryPrepareProgressPayload { + #[allow(dead_code)] + pub fn new( + phase: FoundryPreparePhase, + model_alias: impl Into, + label: impl Into, + percent: Option, + error: Option, + ) -> Self { + Self { + phase, + model_alias: model_alias.into(), + label: label.into(), + percent: percent.map(|value| value.clamp(0.0, 100.0)), + error, + } + } + + #[allow(dead_code)] + pub fn runtime(model_alias: impl Into, label: impl Into, percent: f64) -> Self { + Self::new( + FoundryPreparePhase::Runtime, + model_alias, + label, + Some(percent), + None, + ) + } + + #[allow(dead_code)] + pub fn model(model_alias: impl Into, label: impl Into, percent: f64) -> Self { + Self::new( + FoundryPreparePhase::Model, + model_alias, + label, + Some(percent), + None, + ) + } + + #[allow(dead_code)] + pub fn load(model_alias: impl Into, label: impl Into, percent: f64) -> Self { + Self::new( + FoundryPreparePhase::Load, + model_alias, + label, + Some(percent), + None, + ) + } + + #[allow(dead_code)] + pub fn finished(model_alias: impl Into, label: impl Into) -> Self { + Self::new( + FoundryPreparePhase::Finished, + model_alias, + label, + Some(100.0), + None, + ) + } + + #[allow(dead_code)] + pub fn failed( + model_alias: impl Into, + label: impl Into, + error: impl Into, + ) -> Self { + Self::new( + FoundryPreparePhase::Failed, + model_alias, + label, + None, + Some(error.into()), + ) + } +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] #[allow(dead_code)] @@ -98,4 +228,36 @@ mod tests { assert_eq!(status.endpoint, None); assert_eq!(status.error.as_deref(), Some("not ready")); } + + #[test] + fn static_foundry_catalog_preserves_ui_order() { + let catalog = static_catalog_models(); + + assert_eq!( + catalog + .iter() + .map(|model| model.alias.as_str()) + .collect::>(), + vec!["whisper-small", "whisper-base", "whisper-tiny"] + ); + assert!(catalog.iter().all(|model| !model.cached)); + } + + #[test] + fn foundry_prepare_progress_payload_uses_expected_event_shape() { + let payload = FoundryPrepareProgressPayload::new( + FoundryPreparePhase::Model, + "whisper-small", + "download model", + Some(42.4), + None, + ); + let value = serde_json::to_value(payload).unwrap(); + + assert_eq!(value["phase"], "model"); + assert_eq!(value["modelAlias"], "whisper-small"); + assert_eq!(value["label"], "download model"); + assert_eq!(value["percent"], 42.4); + assert_eq!(value["error"], serde_json::Value::Null); + } } diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs b/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs index a8b942d8..747ea997 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs @@ -95,7 +95,7 @@ impl FoundryLocalWhisperAsr { let wav_file = TempWavFile::create(pcm)?; let text = self .runtime - .transcribe_audio_file(&self.model_alias, wav_file.path()) + .transcribe_audio_file(&self.model_alias, self.language_hint(), wav_file.path()) .await .with_context(|| { format!( diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs index d12bee5c..d3a7e0ae 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs @@ -2,14 +2,23 @@ #[allow(dead_code)] mod imp { use std::path::Path; - use std::sync::Arc; + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }; use anyhow::{Context, Result}; use foundry_local_sdk::{FoundryLocalConfig, FoundryLocalManager, Model}; use parking_lot::Mutex; use tokio::sync::Mutex as AsyncMutex; - use crate::asr::local::foundry::{FoundryRuntimeStatus, PROVIDER_ID}; + use crate::asr::local::foundry::{ + FoundryCatalogModel, FoundryPrepareProgressPayload, FoundryRuntimeStatus, MODELS, + PROVIDER_ID, + }; + + type FoundryPrepareProgressCallback = + Arc; #[derive(Clone)] struct LoadedModel { @@ -26,6 +35,7 @@ mod imp { pub struct FoundryLocalRuntime { lifecycle: AsyncMutex<()>, + cancel_prepare: AtomicBool, state: Mutex, } @@ -39,6 +49,7 @@ mod imp { pub fn new() -> Self { Self { lifecycle: AsyncMutex::new(()), + cancel_prepare: AtomicBool::new(false), state: Mutex::new(RuntimeState::default()), } } @@ -56,19 +67,69 @@ mod imp { } pub async fn ensure_loaded(&self, alias: &str) -> Result { + self.ensure_loaded_with_progress(alias, |_| {}).await + } + + pub async fn ensure_loaded_with_progress( + &self, + alias: &str, + progress: F, + ) -> Result + where + F: Fn(FoundryPrepareProgressPayload) + Send + Sync + 'static, + { + let _lifecycle = self.lifecycle.lock().await; + self.cancel_prepare.store(false, Ordering::SeqCst); + let progress: FoundryPrepareProgressCallback = Arc::new(progress); + Ok(self.ensure_loaded_locked(alias, progress).await?.model_id) + } + + pub fn request_cancel_prepare(&self) { + self.cancel_prepare.store(true, Ordering::SeqCst); + } + + pub async fn catalog_snapshot(&self) -> Result> { let _lifecycle = self.lifecycle.lock().await; - Ok(self.ensure_loaded_locked(alias).await?.model_id) + let manager = self.manager()?; + let mut catalog = Vec::with_capacity(MODELS.len()); + for known in MODELS { + let model = manager + .catalog() + .get_model(known.alias) + .await + .with_context(|| format!("get Foundry catalog model {}", known.alias))?; + let info = model.info(); + let cached = model.is_cached().await.unwrap_or(info.cached); + catalog.push(FoundryCatalogModel { + alias: known.alias.to_string(), + display_name: info + .display_name + .clone() + .unwrap_or_else(|| known.display_name.to_string()), + cached, + file_size_mb: info.file_size_mb, + }); + } + Ok(catalog) } pub async fn transcribe_audio_file( &self, alias: &str, + language_hint: Option<&str>, audio_path: &Path, ) -> Result { let _lifecycle = self.lifecycle.lock().await; - let model = self.ensure_loaded_locked(alias).await?.model; - let result = model - .create_audio_client() + self.cancel_prepare.store(false, Ordering::SeqCst); + let model = self + .ensure_loaded_locked(alias, Arc::new(|_| {})) + .await? + .model; + let mut client = model.create_audio_client(); + if let Some(language_hint) = normalized_language_hint(language_hint) { + client = client.language(language_hint); + } + let result = client .transcribe(audio_path) .await .with_context(|| format!("transcribe audio with Foundry model {alias}"))?; @@ -80,8 +141,16 @@ mod imp { self.release_now_locked().await } - async fn ensure_loaded_locked(&self, alias: &str) -> Result { + async fn ensure_loaded_locked( + &self, + alias: &str, + progress: FoundryPrepareProgressCallback, + ) -> Result { if let Some(loaded) = self.cached_loaded_model(alias) { + progress.as_ref()(FoundryPrepareProgressPayload::finished( + alias, + "Foundry model already loaded", + )); return Ok(loaded); } @@ -90,11 +159,39 @@ mod imp { self.clear_loaded_if_model_id(&previous.model_id); } + self.check_prepare_cancelled()?; let manager = self.manager()?; + progress.as_ref()(FoundryPrepareProgressPayload::runtime( + alias, + "Foundry Local runtime components", + 0.0, + )); + let runtime_progress = Arc::clone(&progress); + let runtime_alias = alias.to_string(); manager - .download_and_register_eps_with_progress(None, |_ep_name: &str, _percent: f64| {}) + .download_and_register_eps_with_progress( + None, + move |ep_name: &str, percent: f64| { + let label = if ep_name.trim().is_empty() { + "Foundry Local runtime components".to_string() + } else { + format!("Foundry Local runtime component: {ep_name}") + }; + runtime_progress.as_ref()(FoundryPrepareProgressPayload::runtime( + runtime_alias.clone(), + label, + percent, + )); + }, + ) .await .context("download/register Foundry execution providers")?; + progress.as_ref()(FoundryPrepareProgressPayload::runtime( + alias, + "Foundry Local runtime components", + 100.0, + )); + self.check_prepare_cancelled()?; let model = manager .catalog() @@ -102,21 +199,65 @@ mod imp { .await .with_context(|| format!("get Foundry model {alias}"))?; + let model_label = model_display_label(alias); if !model .is_cached() .await .context("check Foundry model cache")? { + progress.as_ref()(FoundryPrepareProgressPayload::model( + alias, + model_label.clone(), + 0.0, + )); + let model_progress = Arc::clone(&progress); + let model_alias = alias.to_string(); + let model_label_for_progress = model_label.clone(); model - .download(Some(|_progress: f64| {})) + .download(Some(move |percent: f64| { + model_progress.as_ref()(FoundryPrepareProgressPayload::model( + model_alias.clone(), + model_label_for_progress.clone(), + percent, + )); + })) .await .with_context(|| format!("download Foundry model {alias}"))?; + progress.as_ref()(FoundryPrepareProgressPayload::model( + alias, + model_label.clone(), + 100.0, + )); + } else { + progress.as_ref()(FoundryPrepareProgressPayload::model( + alias, + format!("{model_label} already downloaded"), + 100.0, + )); } + self.check_prepare_cancelled()?; + progress.as_ref()(FoundryPrepareProgressPayload::load( + alias, + model_label.clone(), + 0.0, + )); model .load() .await .with_context(|| format!("load Foundry model {alias}"))?; + if self.cancel_prepare.load(Ordering::SeqCst) { + model + .unload() + .await + .with_context(|| format!("unload cancelled Foundry model {alias}"))?; + anyhow::bail!("Foundry Local Whisper prepare cancelled"); + } + progress.as_ref()(FoundryPrepareProgressPayload::load( + alias, + model_label.clone(), + 100.0, + )); let loaded = LoadedModel { alias: alias.to_string(), @@ -127,6 +268,10 @@ mod imp { manager: Some(manager), loaded: Some(loaded.clone()), }; + progress.as_ref()(FoundryPrepareProgressPayload::finished( + alias, + format!("{model_label} ready"), + )); Ok(loaded) } @@ -191,11 +336,33 @@ mod imp { .with_context(|| format!("unload Foundry model {}", loaded.model_id))?; Ok(()) } + + fn check_prepare_cancelled(&self) -> Result<()> { + if self.cancel_prepare.load(Ordering::SeqCst) { + anyhow::bail!("Foundry Local Whisper prepare cancelled"); + } + Ok(()) + } + } + + fn model_display_label(alias: &str) -> String { + MODELS + .iter() + .find(|model| model.alias == alias) + .map(|model| model.display_name.to_string()) + .unwrap_or_else(|| alias.to_string()) + } + + fn normalized_language_hint(language_hint: Option<&str>) -> Option { + language_hint + .map(str::trim) + .filter(|hint| !hint.is_empty()) + .map(str::to_string) } #[cfg(test)] mod lifecycle_tests { - use super::FoundryLocalRuntime; + use super::{normalized_language_hint, FoundryLocalRuntime}; #[test] fn runtime_has_async_lifecycle_gate() { @@ -203,6 +370,16 @@ mod imp { assert!(runtime.lifecycle.try_lock().is_ok()); } + + #[test] + fn runtime_normalizes_language_hint_before_audio_client() { + assert_eq!( + normalized_language_hint(Some(" zh ")), + Some("zh".to_string()) + ); + assert_eq!(normalized_language_hint(Some("")), None); + assert_eq!(normalized_language_hint(None), None); + } } } @@ -236,9 +413,29 @@ impl FoundryLocalRuntime { anyhow::bail!("Foundry Local Whisper is only available on Windows: {alias}"); } + pub async fn ensure_loaded_with_progress( + &self, + alias: &str, + _progress: F, + ) -> anyhow::Result + where + F: Fn(super::foundry::FoundryPrepareProgressPayload) + Send + Sync + 'static, + { + anyhow::bail!("Foundry Local Whisper is only available on Windows: {alias}"); + } + + pub fn request_cancel_prepare(&self) {} + + pub async fn catalog_snapshot( + &self, + ) -> anyhow::Result> { + Ok(super::foundry::static_catalog_models()) + } + pub async fn transcribe_audio_file( &self, alias: &str, + _language_hint: Option<&str>, _audio_path: &std::path::Path, ) -> anyhow::Result { anyhow::bail!("Foundry Local Whisper is only available on Windows: {alias}"); diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 96ce08e1..3c17409f 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -8,8 +8,8 @@ use serde_json::Value; use tauri::{AppHandle, Emitter, State}; use crate::asr::local::foundry::{ - model_alias_is_known, FoundryRuntimeStatus, DEFAULT_MODEL_ALIAS, - PROVIDER_ID as FOUNDRY_LOCAL_PROVIDER_ID, + model_alias_is_known, FoundryCatalogModel, FoundryPrepareProgressPayload, FoundryRuntimeStatus, + DEFAULT_MODEL_ALIAS, PROVIDER_ID as FOUNDRY_LOCAL_PROVIDER_ID, }; use crate::asr::local::FoundryLocalRuntime; use crate::coordinator::Coordinator; @@ -959,6 +959,16 @@ pub fn foundry_local_asr_status( runtime.status_snapshot(&active_model) } +#[tauri::command] +pub async fn foundry_local_asr_catalog( + runtime: State<'_, Arc>, +) -> Result, String> { + runtime + .catalog_snapshot() + .await + .map_err(|e| format!("{e:#}")) +} + #[tauri::command] pub fn foundry_local_asr_set_model( coord: CoordinatorState<'_>, @@ -983,14 +993,40 @@ pub fn foundry_local_asr_set_language_hint( #[tauri::command] pub async fn foundry_local_asr_prepare( + app: AppHandle, runtime: State<'_, Arc>, model_alias: String, ) -> Result { validate_foundry_model_alias(&model_alias)?; - runtime - .ensure_loaded(&model_alias) - .await - .map_err(|e| format!("{e:#}")) + let progress_app = app.clone(); + let result = runtime + .ensure_loaded_with_progress(&model_alias, move |payload| { + emit_foundry_prepare_progress(&progress_app, payload); + }) + .await; + match result { + Ok(model_id) => Ok(model_id), + Err(error) => { + let message = format!("{error:#}"); + emit_foundry_prepare_progress( + &app, + FoundryPrepareProgressPayload::failed( + model_alias, + "Foundry Local Whisper prepare failed", + message.clone(), + ), + ); + Err(message) + } + } +} + +#[tauri::command] +pub fn foundry_local_asr_cancel_prepare( + runtime: State<'_, Arc>, +) -> Result<(), String> { + runtime.request_cancel_prepare(); + Ok(()) } #[tauri::command] @@ -1000,6 +1036,12 @@ pub async fn foundry_local_asr_release( runtime.release_now().await.map_err(|e| format!("{e:#}")) } +fn emit_foundry_prepare_progress(app: &AppHandle, payload: FoundryPrepareProgressPayload) { + if let Err(error) = app.emit("foundry-local-asr-prepare-progress", payload) { + log::warn!("[foundry-asr] emit prepare progress failed: {error}"); + } +} + // ─────────────────────────── unused but exported (silences dead_code) ─────────────────────────── #[allow(dead_code)] @@ -1010,9 +1052,8 @@ mod tests { use super::{ active_asr_is_keyless_for_validation, active_foundry_model_from_prefs, asr_configured_for_provider, asr_transcriptions_url, fetch_provider_models, - llm_configured_for_snapshot, models_url, - normalize_foundry_language_hint, parse_model_ids, persist_settings, - validate_foundry_model_alias, ProviderConfig, SettingsWriter, + llm_configured_for_snapshot, models_url, normalize_foundry_language_hint, parse_model_ids, + persist_settings, validate_foundry_model_alias, ProviderConfig, SettingsWriter, }; use crate::persistence::CredentialsSnapshot; use crate::types::{ diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 3781c32e..02cb6760 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -252,9 +252,11 @@ pub fn run() { commands::local_asr_preload, commands::local_asr_set_keep_loaded_secs, commands::foundry_local_asr_status, + commands::foundry_local_asr_catalog, commands::foundry_local_asr_set_model, commands::foundry_local_asr_set_language_hint, commands::foundry_local_asr_prepare, + commands::foundry_local_asr_cancel_prepare, commands::foundry_local_asr_release, restart_app, ]) diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index d6d358d7..56216b35 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -548,6 +548,23 @@ export const en: typeof zhCN = { foundryPrepare: 'Prepare / Download / Load', foundryPreparing: 'Preparing…', foundryReleasing: 'Releasing…', + foundryRetryPrepare: 'Continue / Retry prepare', + foundryCancelPrepare: 'Cancel prepare', + foundryCancelRequested: 'Cancel requested', + foundryCancelling: 'Cancelling…', + foundryCancelBestEffort: 'The Foundry SDK does not expose a download cancel token yet. OpenLess has requested cancellation and will stop the next load step after the current SDK step returns. You can continue / retry prepare later.', + foundryPrepareRuntime: 'Prepare runtime components', + foundryPrepareModel: 'Download model', + foundryPrepareLoad: 'Load model', + foundryPrepareModelSkipped: 'Model already downloaded; download skipped', + foundryPrepareDone: 'Done', + foundryPrepareWaiting: 'Waiting', + foundryApproxSizeMb: 'about {{mb}} MB', + foundryLanguageLabel: 'Recognition language', + foundryLanguageAuto: 'Auto', + foundryLanguageZh: 'Chinese zh', + foundryLanguageEn: 'English en', + foundryLanguageDesc: 'For Chinese dictation, choose Chinese. For mixed Chinese and English, try Auto first; choose Chinese if Chinese speech is recognized as English.', foundryModelSmall: 'Whisper Small (default / balanced)', foundryModelSmallDesc: 'Default balanced option for quality and resource use.', foundryModelBase: 'Whisper Base (faster / lower resource)', @@ -560,6 +577,7 @@ export const en: typeof zhCN = { mirrorHfMirror: 'Mainland mirror (hf-mirror.com)', activeBadge: 'In use', downloadedBadge: 'Downloaded', + notDownloadedBadge: 'Not downloaded', download: 'Download', resume: 'Resume', cancel: 'Cancel', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index fa745c30..13dc638f 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -546,6 +546,23 @@ export const zhCN = { foundryPrepare: '准备 / 下载 / 加载', foundryPreparing: '正在准备…', foundryReleasing: '正在释放…', + foundryRetryPrepare: '继续准备 / 重试', + foundryCancelPrepare: '取消准备', + foundryCancelRequested: '已请求取消', + foundryCancelling: '正在取消…', + foundryCancelBestEffort: 'Foundry SDK 当前未暴露下载取消令牌;OpenLess 已请求取消,会在当前 SDK 步骤返回后停止后续加载。可稍后继续准备 / 重试。', + foundryPrepareRuntime: '准备运行时组件', + foundryPrepareModel: '下载模型', + foundryPrepareLoad: '加载模型', + foundryPrepareModelSkipped: '模型已下载,跳过下载阶段', + foundryPrepareDone: '已完成', + foundryPrepareWaiting: '等待中', + foundryApproxSizeMb: '约 {{mb}} MB', + foundryLanguageLabel: '识别语言', + foundryLanguageAuto: '自动', + foundryLanguageZh: '中文 zh', + foundryLanguageEn: '英文 en', + foundryLanguageDesc: '中文听写建议选中文;中英混输可先用自动,若中文被识别成英文再选中文。', foundryModelSmall: 'Whisper Small(默认 / 平衡)', foundryModelSmallDesc: '默认平衡选项,兼顾质量与资源占用。', foundryModelBase: 'Whisper Base(更快 / 更省资源)', @@ -558,6 +575,7 @@ export const zhCN = { mirrorHfMirror: '国内镜像 (hf-mirror.com)', activeBadge: '当前使用', downloadedBadge: '已下载', + notDownloadedBadge: '未下载', download: '下载', resume: '继续下载', cancel: '取消', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 2dd54346..ab324c9f 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -548,6 +548,23 @@ export const zhTW: typeof zhCN = { foundryPrepare: '準備 / 下載 / 加載', foundryPreparing: '正在準備…', foundryReleasing: '正在釋放…', + foundryRetryPrepare: '繼續準備 / 重試', + foundryCancelPrepare: '取消準備', + foundryCancelRequested: '已請求取消', + foundryCancelling: '正在取消…', + foundryCancelBestEffort: 'Foundry SDK 目前未暴露下載取消令牌;OpenLess 已請求取消,會在當前 SDK 步驟返回後停止後續加載。可稍後繼續準備 / 重試。', + foundryPrepareRuntime: '準備運行時組件', + foundryPrepareModel: '下載模型', + foundryPrepareLoad: '加載模型', + foundryPrepareModelSkipped: '模型已下載,跳過下載階段', + foundryPrepareDone: '已完成', + foundryPrepareWaiting: '等待中', + foundryApproxSizeMb: '約 {{mb}} MB', + foundryLanguageLabel: '識別語言', + foundryLanguageAuto: '自動', + foundryLanguageZh: '中文 zh', + foundryLanguageEn: '英文 en', + foundryLanguageDesc: '中文聽寫建議選中文;中英混輸可先用自動,若中文被識別成英文再選中文。', foundryModelSmall: 'Whisper Small(默認 / 平衡)', foundryModelSmallDesc: '默認平衡選項,兼顧質量與資源佔用。', foundryModelBase: 'Whisper Base(更快 / 更省資源)', @@ -560,6 +577,7 @@ export const zhTW: typeof zhCN = { mirrorHfMirror: '國內鏡像 (hf-mirror.com)', activeBadge: '當前使用', downloadedBadge: '已下載', + notDownloadedBadge: '未下載', download: '下載', resume: '繼續下載', cancel: '取消', diff --git a/openless-all/app/src/lib/localAsr.ts b/openless-all/app/src/lib/localAsr.ts index 1486eac0..24741dd2 100644 --- a/openless-all/app/src/lib/localAsr.ts +++ b/openless-all/app/src/lib/localAsr.ts @@ -65,6 +65,29 @@ export interface FoundryLocalAsrStatus { } export type FoundryLocalAsrModelAlias = 'whisper-small' | 'whisper-base' | 'whisper-tiny'; +export type FoundryLocalAsrLanguageHint = '' | 'zh' | 'en'; + +export interface FoundryLocalAsrCatalogModel { + alias: FoundryLocalAsrModelAlias; + displayName: string; + cached: boolean; + fileSizeMb: number | null; +} + +export type FoundryPreparePhase = + | 'runtime' + | 'model' + | 'load' + | 'finished' + | 'failed'; + +export interface FoundryPrepareProgress { + phase: FoundryPreparePhase; + modelAlias: string; + label: string; + percent: number | null; + error: string | null; +} export interface FoundryLocalAsrModelOption { alias: FoundryLocalAsrModelAlias; @@ -90,6 +113,27 @@ export const FOUNDRY_LOCAL_ASR_MODELS: FoundryLocalAsrModelOption[] = [ }, ]; +const MOCK_FOUNDRY_CATALOG: FoundryLocalAsrCatalogModel[] = [ + { + alias: 'whisper-small', + displayName: 'Whisper Small', + cached: false, + fileSizeMb: 967, + }, + { + alias: 'whisper-base', + displayName: 'Whisper Base', + cached: true, + fileSizeMb: 291, + }, + { + alias: 'whisper-tiny', + displayName: 'Whisper Tiny', + cached: false, + fileSizeMb: 151, + }, +]; + const MOCK_SETTINGS: LocalAsrSettings = { providerId: 'local-qwen3', activeModel: 'qwen3-asr-0.6b', @@ -222,6 +266,10 @@ export function getFoundryLocalAsrStatus(): Promise { })); } +export function getFoundryLocalAsrCatalog(): Promise { + return invokeOrMock('foundry_local_asr_catalog', undefined, () => MOCK_FOUNDRY_CATALOG); +} + export function setFoundryLocalAsrModel(modelAlias: string): Promise { return invokeOrMock('foundry_local_asr_set_model', { modelAlias }, () => undefined); } @@ -238,6 +286,10 @@ export function prepareFoundryLocalAsr(modelAlias: string): Promise { return invokeOrMock('foundry_local_asr_prepare', { modelAlias }, () => `mock-${modelAlias}`); } +export function cancelFoundryLocalAsrPrepare(): Promise { + return invokeOrMock('foundry_local_asr_cancel_prepare', undefined, () => undefined); +} + export function releaseFoundryLocalAsr(): Promise { return invokeOrMock('foundry_local_asr_release', undefined, () => undefined); } diff --git a/openless-all/app/src/pages/LocalAsr.tsx b/openless-all/app/src/pages/LocalAsr.tsx index 5fee1bf1..73b9d0a2 100644 --- a/openless-all/app/src/pages/LocalAsr.tsx +++ b/openless-all/app/src/pages/LocalAsr.tsx @@ -12,10 +12,12 @@ import { useTranslation } from 'react-i18next'; import { isTauri, setActiveAsrProvider } from '../lib/ipc'; import { FOUNDRY_LOCAL_ASR_MODELS, + cancelFoundryLocalAsrPrepare, cancelLocalAsrDownload, deleteLocalAsrModel, downloadLocalAsrModel, fetchLocalAsrRemoteInfo, + getFoundryLocalAsrCatalog, getFoundryLocalAsrStatus, getLocalAsrEngineStatus, getLocalAsrSettings, @@ -24,13 +26,17 @@ import { preloadLocalAsr, releaseFoundryLocalAsr, releaseLocalAsrEngine, + setFoundryLocalAsrLanguageHint, setFoundryLocalAsrModel, setLocalAsrActiveModel, setLocalAsrKeepLoadedSecs, setLocalAsrMirror, testLocalAsrModel, + type FoundryLocalAsrCatalogModel, + type FoundryLocalAsrLanguageHint, type FoundryLocalAsrModelAlias, type FoundryLocalAsrStatus, + type FoundryPrepareProgress, type LocalAsrDownloadProgress, type LocalAsrEngineStatus, type LocalAsrModelStatus, @@ -57,12 +63,16 @@ export function LocalAsr() { const [error, setError] = useState(null); const [busyModelId, setBusyModelId] = useState(null); const [foundryStatus, setFoundryStatus] = useState(null); + const [foundryCatalog, setFoundryCatalog] = useState([]); const [selectedFoundryAlias, setSelectedFoundryAlias] = useState('whisper-small'); const [foundryBusy, setFoundryBusy] = useState<'enable' | 'prepare' | 'release' | null>(null); + const [foundryProgress, setFoundryProgress] = useState(null); + const [foundryCancelRequested, setFoundryCancelRequested] = useState(false); const [testingModelId, setTestingModelId] = useState(null); const [testResults, setTestResults] = useState>({}); const [engineStatus, setEngineStatus] = useState(null); const refreshTimer = useRef(null); + const foundryRefreshTimer = useRef(null); const engineStatusTimer = useRef(null); const refreshEngineStatus = async () => { @@ -94,6 +104,15 @@ export function LocalAsr() { } }; + const refreshFoundryCatalog = async () => { + try { + const catalog = await getFoundryLocalAsrCatalog(); + setFoundryCatalog(catalog); + } catch (err) { + console.warn('[localAsr] Foundry catalog query failed', err); + } + }; + const refresh = async () => { try { setError(null); @@ -102,6 +121,7 @@ export function LocalAsr() { setModels(list); void refreshEngineStatus(); void refreshFoundryStatus(); + void refreshFoundryCatalog(); // 拉远端真实尺寸(每个模型一次,结果留缓存) void Promise.all( list.map(async m => { @@ -202,6 +222,37 @@ export function LocalAsr() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (!isTauri) return; + let unlisten: undefined | (() => void); + let cancelled = false; + (async () => { + const { listen } = await import('@tauri-apps/api/event'); + const off = await listen('foundry-local-asr-prepare-progress', e => { + const payload = e.payload; + setFoundryProgress(payload); + if (payload.phase === 'finished' || payload.phase === 'failed') { + if (foundryRefreshTimer.current) window.clearTimeout(foundryRefreshTimer.current); + foundryRefreshTimer.current = window.setTimeout(() => { + void refreshFoundryStatus(); + void refreshFoundryCatalog(); + }, 200); + } + }); + if (cancelled) { + off(); + } else { + unlisten = off; + } + })().catch(err => console.warn('[localAsr] Foundry prepare subscribe failed', err)); + return () => { + cancelled = true; + if (unlisten) unlisten(); + if (foundryRefreshTimer.current) window.clearTimeout(foundryRefreshTimer.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const handleSetActiveModel = async (modelId: string) => { setBusyModelId(modelId); try { @@ -224,6 +275,19 @@ export function LocalAsr() { })); }; + const handleFoundryLanguageChange = async (languageHint: FoundryLocalAsrLanguageHint) => { + try { + setError(null); + await setFoundryLocalAsrLanguageHint(languageHint); + await updatePrefs(current => ({ + ...current, + foundryLocalAsrLanguageHint: languageHint, + })); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + const handleEnableFoundry = async () => { if (!foundryAvailable) return; setFoundryBusy('enable'); @@ -243,17 +307,38 @@ export function LocalAsr() { const handlePrepareFoundry = async () => { if (!foundryAvailable) return; setFoundryBusy('prepare'); + setFoundryCancelRequested(false); + setFoundryProgress({ + phase: 'runtime', + modelAlias: selectedFoundryAlias, + label: t('localAsr.foundryPrepareRuntime'), + percent: 0, + error: null, + }); try { setError(null); await setFoundryLocalAsrModel(selectedFoundryAlias); await syncFoundryPrefs(selectedFoundryAlias, false); await prepareFoundryLocalAsr(selectedFoundryAlias); await refreshFoundryStatus(); + await refreshFoundryCatalog(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); await refreshFoundryStatus(); + await refreshFoundryCatalog(); } finally { setFoundryBusy(null); + setFoundryCancelRequested(false); + } + }; + + const handleCancelFoundryPrepare = async () => { + if (foundryBusy !== 'prepare') return; + setFoundryCancelRequested(true); + try { + await cancelFoundryLocalAsrPrepare(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); } }; @@ -367,6 +452,26 @@ export function LocalAsr() { const selectedFoundryModel = FOUNDRY_LOCAL_ASR_MODELS.find( model => model.alias === selectedFoundryAlias, ) ?? FOUNDRY_LOCAL_ASR_MODELS[0]; + const selectedFoundryCatalog = foundryCatalog.find(model => model.alias === selectedFoundryAlias); + const selectedFoundryDisplayName = selectedFoundryCatalog?.displayName ?? t(selectedFoundryModel.labelKey); + const selectedFoundrySizeMb = formatFoundrySizeMb(selectedFoundryCatalog?.fileSizeMb); + const selectedFoundrySizeLabel = selectedFoundrySizeMb + ? t('localAsr.foundryApproxSizeMb', { mb: selectedFoundrySizeMb }) + : t('localAsr.sizeUnknown'); + const selectedFoundryDownloadLabel = selectedFoundryCatalog?.cached + ? t('localAsr.downloadedBadge') + : t('localAsr.notDownloadedBadge'); + const selectedFoundryLanguageHint = normalizeFoundryLanguageHintForUi( + prefs?.foundryLocalAsrLanguageHint ?? '', + ); + const foundryPrepareLabel = + foundryBusy === 'prepare' + ? foundryCancelRequested + ? t('localAsr.foundryCancelling') + : t('localAsr.foundryPreparing') + : foundryProgress?.phase === 'failed' + ? t('localAsr.foundryRetryPrepare') + : t('localAsr.foundryPrepare'); return (
@@ -402,34 +507,71 @@ export function LocalAsr() { {t('localAsr.foundryDesc')}
- +
+ + +
{t('localAsr.foundrySelectedModel')}: - {t(selectedFoundryModel.labelKey)} + {selectedFoundryDisplayName} + · {selectedFoundrySizeLabel} · {selectedFoundryDownloadLabel} · {t(selectedFoundryModel.descKey)}
+
+ {t('localAsr.foundryLanguageLabel')}: + {selectedFoundryLanguageHint + ? t(`localAsr.foundryLanguage${selectedFoundryLanguageHint === 'zh' ? 'Zh' : 'En'}`) + : t('localAsr.foundryLanguageAuto')} + · {t('localAsr.foundryLanguageDesc')} +
{t('localAsr.foundryActiveModel')}: {foundryStatus?.activeModel ?? 'whisper-small'} @@ -446,6 +588,14 @@ export function LocalAsr() { )}
+ {(foundryBusy === 'prepare' || foundryProgress) && ( + + )} +
void handlePrepareFoundry()}> - {foundryBusy === 'prepare' ? t('localAsr.foundryPreparing') : t('localAsr.foundryPrepare')} + {foundryPrepareLabel} + {foundryBusy === 'prepare' && ( + void handleCancelFoundryPrepare()}> + {foundryCancelRequested + ? t('localAsr.foundryCancelRequested') + : t('localAsr.foundryCancelPrepare')} + + )} stage.phase === progress.phase) : -1; + + return ( +
+ {stages.map((stage, index) => { + const finished = progress?.phase === 'finished' || currentIndex > index; + const skippedCachedModel = + stage.phase === 'model' && + modelCached && + (progress?.phase === 'load' || progress?.phase === 'finished'); + const active = progress?.phase === stage.phase; + const failed = progress?.phase === 'failed'; + const percent = finished || skippedCachedModel + ? 100 + : active + ? Math.max(0, Math.min(100, progress?.percent ?? 0)) + : 0; + const detail = skippedCachedModel + ? t('localAsr.foundryPrepareModelSkipped') + : active + ? progress?.label + : finished + ? t('localAsr.foundryPrepareDone') + : t('localAsr.foundryPrepareWaiting'); + return ( +
+
+ + {stage.label} + + + {failed ? t('localAsr.failed') : `${Math.round(percent)}%`} + +
+
+
+
+
+ {detail} +
+
+ ); + })} + {cancelRequested && ( +
+ {t('localAsr.foundryCancelBestEffort')} +
+ )} + {progress?.phase === 'failed' && progress.error && ( +
+ {progress.error} +
+ )} +
+ ); +} + interface ModelRowProps { model: LocalAsrModelStatus; remoteSize?: RemoteSize; @@ -799,6 +1047,15 @@ function isFoundryAlias(value: string): value is FoundryLocalAsrModelAlias { return FOUNDRY_LOCAL_ASR_MODELS.some(model => model.alias === value); } +function normalizeFoundryLanguageHintForUi(value: string): FoundryLocalAsrLanguageHint { + return value === 'zh' || value === 'en' ? value : ''; +} + +function formatFoundrySizeMb(fileSizeMb: number | null | undefined): string | null { + if (typeof fileSizeMb !== 'number' || fileSizeMb <= 0) return null; + return Math.round(fileSizeMb).toLocaleString(); +} + function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; From 018cacad4f9a3dc92fc57a611d42b134a43453c8 Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 20:48:26 +0800 Subject: [PATCH 24/37] fix: skip Foundry transcription timeout --- openless-all/app/src-tauri/src/coordinator.rs | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index de3bf87b..9d683bec 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -65,6 +65,14 @@ enum ActiveAsr { Local(Arc), } +fn asr_transcribe_uses_global_timeout(asr: &ActiveAsr) -> bool { + match asr { + #[cfg(target_os = "windows")] + ActiveAsr::FoundryLocalWhisper(_) => false, + _ => true, + } +} + struct SessionResource { session_id: u64, resource: T, @@ -1615,8 +1623,10 @@ async fn end_session(inner: &Arc) -> Result<(), String> { } }; + let uses_global_timeout = asr_transcribe_uses_global_timeout(&asr); let raw = match asr { ActiveAsr::Volcengine(asr) => { + debug_assert!(uses_global_timeout); if let Err(e) = asr.send_last_frame().await { log::error!("[coord] send last frame failed: {e}"); } @@ -1663,6 +1673,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { } } ActiveAsr::Whisper(w) => { + debug_assert!(uses_global_timeout); // Whisper 也添加类似的超时保护 let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); match tokio::time::timeout(timeout_duration, w.transcribe()).await { @@ -1704,10 +1715,10 @@ async fn end_session(inner: &Arc) -> Result<(), String> { } #[cfg(target_os = "windows")] ActiveAsr::FoundryLocalWhisper(local) => { - let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); - match tokio::time::timeout(timeout_duration, local.transcribe()).await { - Ok(Ok(r)) => r, - Ok(Err(e)) => { + debug_assert!(!uses_global_timeout); + match local.transcribe().await { + Ok(r) => r, + Err(e) => { log::error!("[coord] Foundry Local Whisper transcribe failed: {e:#}"); emit_capsule( inner, @@ -1722,28 +1733,11 @@ async fn end_session(inner: &Arc) -> Result<(), String> { schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(e.to_string()); } - Err(_) => { - log::error!( - "[coord] Foundry Local Whisper 全局超时 {} 秒", - COORDINATOR_GLOBAL_TIMEOUT_SECS - ); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - elapsed, - Some("识别超时".to_string()), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err("foundry local global timeout".to_string()); - } } } #[cfg(target_os = "macos")] ActiveAsr::Local(local) => { + debug_assert!(uses_global_timeout); // 与 Volcengine/Whisper 一致包一层 global timeout(来自 origin/main)。 // 注:缓存命中时 transcribe 不含 load 时间;冷启动 load 已在 build_local_qwen3 // 提前完成,所以 15s 给 transcribe 本身足够。 @@ -3205,6 +3199,19 @@ mod tests { )); } + #[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(), + None, + )); + let active_asr = ActiveAsr::FoundryLocalWhisper(provider); + + assert!(!asr_transcribe_uses_global_timeout(&active_asr)); + } + #[test] fn resolve_ark_endpoint_rejects_blank_key_without_custom_endpoint() { assert_eq!( From 091bbdc2f37d6c52e72340ea511a9ef25398434f Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 21:00:29 +0800 Subject: [PATCH 25/37] fix: release inactive Foundry runtime --- openless-all/app/src-tauri/src/commands.rs | 45 ++++++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index e264e9f3..322392c1 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -147,6 +147,19 @@ fn configured(field: &Option) -> bool { .unwrap_or(false) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct LocalAsrReleasePlan { + qwen: bool, + foundry: bool, +} + +fn local_asr_release_plan_for_provider(provider: &str) -> LocalAsrReleasePlan { + LocalAsrReleasePlan { + qwen: provider != crate::asr::local::PROVIDER_ID, + foundry: provider != FOUNDRY_LOCAL_PROVIDER_ID, + } +} + #[tauri::command] pub fn set_credential(window: Window, account: String, value: String) -> Result<(), String> { ensure_main_window(&window)?; @@ -159,23 +172,31 @@ pub fn set_credential(window: Window, account: String, value: String) -> Result< } #[tauri::command] -pub fn set_active_asr_provider( +pub async fn set_active_asr_provider( coord: CoordinatorState<'_>, + runtime: State<'_, Arc>, provider: String, ) -> Result<(), String> { if provider == FOUNDRY_LOCAL_PROVIDER_ID && !active_foundry_asr_is_supported(&provider) { return Err("Foundry Local Whisper is only available on Windows".to_string()); } CredentialsVault::set_active_asr_provider(&provider).map_err(|e| e.to_string())?; + let release_plan = local_asr_release_plan_for_provider(&provider); if provider == crate::asr::local::PROVIDER_ID { // 切到本地 ASR → 后台预加载模型,下次按 hotkey 时不必等数秒。 coord.preload_local_asr_in_background(); - } else { + } + if release_plan.qwen { // 切回云端 → 用户已不需要本地引擎,立刻释放 1.2GB+ RAM;不释放的话只会等到 // schedule_local_asr_release 的下一次 dictation 才触发,而切回云端后根本不会 // 再走 local 路径,引擎会驻留到进程退出。 coord.release_local_asr_engine(); } + if release_plan.foundry { + if let Err(error) = runtime.release_now().await { + log::warn!("[foundry-asr] release inactive runtime failed: {error:#}"); + } + } Ok(()) } @@ -1076,8 +1097,9 @@ mod tests { use super::{ active_asr_is_keyless_for_validation, active_foundry_model_from_prefs, asr_configured_for_provider, asr_transcriptions_url, fetch_provider_models, - llm_configured_for_snapshot, models_url, normalize_foundry_language_hint, parse_model_ids, - persist_settings, validate_foundry_model_alias, ProviderConfig, SettingsWriter, + llm_configured_for_snapshot, local_asr_release_plan_for_provider, models_url, + normalize_foundry_language_hint, parse_model_ids, persist_settings, + validate_foundry_model_alias, ProviderConfig, SettingsWriter, }; use crate::persistence::CredentialsSnapshot; use crate::types::{ @@ -1176,6 +1198,21 @@ mod tests { assert!(!active_asr_is_keyless_for_validation("whisper")); } + #[test] + fn provider_switch_release_plan_covers_inactive_local_runtimes() { + let qwen = local_asr_release_plan_for_provider(crate::asr::local::PROVIDER_ID); + assert!(!qwen.qwen); + assert!(qwen.foundry); + + let foundry = local_asr_release_plan_for_provider(crate::asr::local::foundry::PROVIDER_ID); + assert!(foundry.qwen); + assert!(!foundry.foundry); + + let cloud = local_asr_release_plan_for_provider("volcengine"); + assert!(cloud.qwen); + assert!(cloud.foundry); + } + #[test] fn foundry_language_hint_accepts_empty_and_lowercase_iso_639_1() { assert_eq!(normalize_foundry_language_hint("").unwrap(), ""); From a1d6e59a1c44a75069ac383823b53be8dda0aefd Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 21:18:15 +0800 Subject: [PATCH 26/37] fix: address Foundry ASR review feedback --- .../windows-real-asr-insertion-smoke.ps1 | 258 +++++++++++++++++- .../src/asr/local/foundry_runtime.rs | 32 ++- 2 files changed, 272 insertions(+), 18 deletions(-) diff --git a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 index d420c6f2..5f76f934 100644 --- a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 +++ b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 @@ -135,9 +135,240 @@ function Set-HoldHotkeyPreference($Path) { return $previous } +function Ensure-OpenLessCredentialNative { + if ("OpenLessCredentialNative" -as [type]) { + return + } + + Add-Type @" +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +public static class OpenLessCredentialNative { + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool CredRead(string target, UInt32 type, UInt32 reservedFlag, out IntPtr credentialPtr); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool CredWrite(ref OpenLessCredentialNativeCredential credential, UInt32 flags); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool CredDelete(string target, UInt32 type, UInt32 flags); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern void CredFree(IntPtr buffer); +} + +[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] +public struct OpenLessCredentialNativeCredential { + public UInt32 Flags; + public UInt32 Type; + public string TargetName; + public string Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public UInt32 CredentialBlobSize; + public IntPtr CredentialBlob; + public UInt32 Persist; + public UInt32 AttributeCount; + public IntPtr Attributes; + public string TargetAlias; + public string UserName; +} +"@ +} + +function Get-OpenLessCredentialTarget($Account) { + return "$Account.com.openless.app" +} + +function Get-OpenLessKeyringPassword($Account) { + Ensure-OpenLessCredentialNative + $target = Get-OpenLessCredentialTarget $Account + $ptr = [IntPtr]::Zero + $ok = [OpenLessCredentialNative]::CredRead($target, 1, 0, [ref]$ptr) + if (-not $ok) { + $errorCode = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + if ($errorCode -eq 1168) { + return $null + } + throw (New-Object ComponentModel.Win32Exception($errorCode, "Read Windows Credential Manager entry $target failed")) + } + + try { + $credential = [Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [type][OpenLessCredentialNativeCredential]) + if ($credential.CredentialBlobSize -eq 0) { + return "" + } + $bytes = New-Object byte[] $credential.CredentialBlobSize + [Runtime.InteropServices.Marshal]::Copy($credential.CredentialBlob, $bytes, 0, $bytes.Length) + return [Text.Encoding]::Unicode.GetString($bytes) + } finally { + [OpenLessCredentialNative]::CredFree($ptr) + } +} + +function Set-OpenLessKeyringPassword($Account, $Password) { + Ensure-OpenLessCredentialNative + $target = Get-OpenLessCredentialTarget $Account + $bytes = [Text.Encoding]::Unicode.GetBytes($Password) + $blob = [IntPtr]::Zero + if ($bytes.Length -gt 0) { + $blob = [Runtime.InteropServices.Marshal]::AllocHGlobal($bytes.Length) + [Runtime.InteropServices.Marshal]::Copy($bytes, 0, $blob, $bytes.Length) + } + + try { + $credential = [OpenLessCredentialNativeCredential]::new() + $credential.Flags = 0 + $credential.Type = 1 + $credential.TargetName = $target + $credential.Comment = "keyring v3.6.3" + $credential.CredentialBlobSize = $bytes.Length + $credential.CredentialBlob = $blob + $credential.Persist = 3 + $credential.AttributeCount = 0 + $credential.Attributes = [IntPtr]::Zero + $credential.TargetAlias = "" + $credential.UserName = $Account + $ok = [OpenLessCredentialNative]::CredWrite([ref]$credential, 0) + if (-not $ok) { + $errorCode = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + throw (New-Object ComponentModel.Win32Exception($errorCode, "Write Windows Credential Manager entry $target failed")) + } + } finally { + if ($blob -ne [IntPtr]::Zero) { + [Runtime.InteropServices.Marshal]::FreeHGlobal($blob) + } + } +} + +function Remove-OpenLessKeyringPassword($Account) { + Ensure-OpenLessCredentialNative + $target = Get-OpenLessCredentialTarget $Account + $ok = [OpenLessCredentialNative]::CredDelete($target, 1, 0) + if (-not $ok) { + $errorCode = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + if ($errorCode -ne 1168) { + throw (New-Object ComponentModel.Win32Exception($errorCode, "Delete Windows Credential Manager entry $target failed")) + } + } +} + +function Split-OpenLessCredentialJson($Json) { + $chunks = @() + for ($start = 0; $start -lt $Json.Length; $start += 1000) { + $len = [Math]::Min(1000, $Json.Length - $start) + $chunks += $Json.Substring($start, $len) + } + if ($chunks.Count -eq 0) { + $chunks += "" + } + return $chunks +} + +function Get-OpenLessVaultCredentials { + $manifestText = Get-OpenLessKeyringPassword "credentials.v1" + if ([string]::IsNullOrWhiteSpace($manifestText)) { + return $null + } + $manifest = $manifestText | ConvertFrom-Json + if ($manifest.openless_credentials_storage -ne "chunked" -or $manifest.version -ne 1) { + throw "Unsupported OpenLess credential vault manifest." + } + + $json = "" + for ($i = 0; $i -lt [int]$manifest.chunks; $i++) { + $chunkAccount = if ($null -ne $manifest.PSObject.Properties["generation"] -and -not [string]::IsNullOrWhiteSpace($manifest.generation)) { + "credentials.v1.chunk.$($manifest.generation).$i" + } else { + "credentials.v1.chunk.$i" + } + $chunk = Get-OpenLessKeyringPassword $chunkAccount + if ($null -eq $chunk) { + throw "Missing OpenLess credential vault chunk $i." + } + $json += $chunk + } + return $json +} + +function Set-OpenLessVaultCredentials($Json, $PreviousManifestJson) { + $previousManifest = $null + if (-not [string]::IsNullOrWhiteSpace($PreviousManifestJson)) { + $previousManifest = $PreviousManifestJson | ConvertFrom-Json + } + + $chunks = Split-OpenLessCredentialJson $Json + for ($i = 0; $i -lt $chunks.Count; $i++) { + Set-OpenLessKeyringPassword "credentials.v1.chunk.$i" $chunks[$i] + } + + $manifest = [pscustomobject]@{ + openless_credentials_storage = "chunked" + version = 1 + chunks = $chunks.Count + } + Set-OpenLessKeyringPassword "credentials.v1" ($manifest | ConvertTo-Json -Compress) + + if ($null -ne $previousManifest -and $null -ne $previousManifest.PSObject.Properties["chunks"]) { + if ($null -ne $previousManifest.PSObject.Properties["generation"] -and -not [string]::IsNullOrWhiteSpace($previousManifest.generation)) { + for ($i = 0; $i -lt [int]$previousManifest.chunks; $i++) { + Remove-OpenLessKeyringPassword "credentials.v1.chunk.$($previousManifest.generation).$i" + } + } else { + for ($i = $chunks.Count; $i -lt [int]$previousManifest.chunks; $i++) { + Remove-OpenLessKeyringPassword "credentials.v1.chunk.$i" + } + } + } +} + +function Restore-ActiveAsrCredential($Snapshot, $Path) { + if ($null -eq $Snapshot) { + return + } + if ($Snapshot.HadVault) { + $manifest = $Snapshot.VaultManifestJson | ConvertFrom-Json + $chunks = Split-OpenLessCredentialJson $Snapshot.VaultJson + $usesGeneratedChunks = $null -ne $manifest.PSObject.Properties["generation"] -and -not [string]::IsNullOrWhiteSpace($manifest.generation) + for ($i = 0; $i -lt $chunks.Count; $i++) { + $account = if ($usesGeneratedChunks) { + "credentials.v1.chunk.$($manifest.generation).$i" + } else { + "credentials.v1.chunk.$i" + } + Set-OpenLessKeyringPassword $account $chunks[$i] + } + Set-OpenLessKeyringPassword "credentials.v1" $Snapshot.VaultManifestJson + if ($usesGeneratedChunks) { + for ($i = 0; $i -lt $Snapshot.WrittenVaultChunks; $i++) { + Remove-OpenLessKeyringPassword "credentials.v1.chunk.$i" + } + } else { + for ($i = $chunks.Count; $i -lt $Snapshot.WrittenVaultChunks; $i++) { + Remove-OpenLessKeyringPassword "credentials.v1.chunk.$i" + } + } + } else { + Remove-OpenLessKeyringPassword "credentials.v1" + for ($i = 0; $i -lt $Snapshot.WrittenVaultChunks; $i++) { + Remove-OpenLessKeyringPassword "credentials.v1.chunk.$i" + } + } + + if ($null -eq $Snapshot.LegacyJson) { + Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue + } else { + Write-TextUtf8 $Path $Snapshot.LegacyJson + } +} + function Set-ActiveAsrCredential($Path) { - $previous = Read-TextUtf8 $Path - if ([string]::IsNullOrWhiteSpace($previous)) { + $previousLegacy = Read-TextUtf8 $Path + $previousManifest = Get-OpenLessKeyringPassword "credentials.v1" + $previousVault = Get-OpenLessVaultCredentials + $source = if (-not [string]::IsNullOrWhiteSpace($previousVault)) { $previousVault } else { $previousLegacy } + if ([string]::IsNullOrWhiteSpace($source)) { $credentials = [pscustomobject]@{ version = 1 active = [pscustomobject]@{ @@ -150,7 +381,7 @@ function Set-ActiveAsrCredential($Path) { } } } else { - $credentials = $previous | ConvertFrom-Json + $credentials = $source | ConvertFrom-Json if ($null -eq $credentials.PSObject.Properties["active"]) { $credentials | Add-Member -NotePropertyName active -NotePropertyValue ([pscustomobject]@{}) } elseif ($null -eq $credentials.active) { @@ -180,8 +411,19 @@ function Set-ActiveAsrCredential($Path) { $credentials.providers.llm = [pscustomobject]@{} } } - Write-TextUtf8 $Path ($credentials | ConvertTo-Json -Depth 12) - return $previous + $json = $credentials | ConvertTo-Json -Depth 12 -Compress + $chunks = Split-OpenLessCredentialJson $json + Set-OpenLessVaultCredentials $json $previousManifest + if ([string]::IsNullOrWhiteSpace($previousVault)) { + Write-TextUtf8 $Path ($credentials | ConvertTo-Json -Depth 12) + } + return [pscustomobject]@{ + LegacyJson = $previousLegacy + VaultJson = $previousVault + VaultManifestJson = $previousManifest + HadVault = -not [string]::IsNullOrWhiteSpace($previousVault) + WrittenVaultChunks = $chunks.Count + } } function Wait-LogPattern($Path, $Pattern, $TimeoutSeconds) { @@ -818,11 +1060,7 @@ try { } } if ($credentialsRewritten) { - if ($null -eq $previousCredentials) { - Remove-Item -LiteralPath $credentialsPath -Force -ErrorAction SilentlyContinue - } else { - Write-TextUtf8 $credentialsPath $previousCredentials - } + Restore-ActiveAsrCredential $previousCredentials $credentialsPath } if ($clipboardCaptured) { Restore-ClipboardValue $previousClipboard diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs index d3a7e0ae..367fd5ce 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs @@ -55,14 +55,25 @@ mod imp { } pub fn status_snapshot(&self, active_model: &str) -> FoundryRuntimeStatus { - let state = self.state.lock(); - FoundryRuntimeStatus { - provider_id: PROVIDER_ID.into(), - available: true, - active_model: active_model.to_string(), - loaded_model_id: state.loaded.as_ref().map(|loaded| loaded.model_id.clone()), - endpoint: None, - error: None, + match self.manager() { + Ok(_) => { + let state = self.state.lock(); + FoundryRuntimeStatus { + provider_id: PROVIDER_ID.into(), + available: true, + active_model: active_model.to_string(), + loaded_model_id: state + .loaded + .as_ref() + .map(|loaded| loaded.model_id.clone()), + endpoint: None, + error: None, + } + } + Err(error) => FoundryRuntimeStatus::unavailable( + active_model.to_string(), + format!("Foundry Local runtime unavailable: {error:#}"), + ), } } @@ -459,6 +470,11 @@ mod tests { assert_eq!(status.active_model, "whisper-small"); assert_eq!(status.loaded_model_id, None); assert_eq!(status.endpoint, None); + if status.available { + assert_eq!(status.error, None); + } else { + assert!(status.error.is_some()); + } } #[tokio::test] From 4dc0246022d123e36c9e85848ab4cc1819f98b62 Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 23:01:32 +0800 Subject: [PATCH 27/37] fix: bound Foundry ASR transcription wait --- openless-all/app/src-tauri/src/coordinator.rs | 65 ++++++++++++++++++- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 9d683bec..1bf48b88 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1716,9 +1716,13 @@ async fn end_session(inner: &Arc) -> Result<(), String> { #[cfg(target_os = "windows")] ActiveAsr::FoundryLocalWhisper(local) => { debug_assert!(!uses_global_timeout); - match local.transcribe().await { - Ok(r) => r, - Err(e) => { + let timeout_duration = foundry_transcribe_timeout_duration(); + match tokio::time::timeout(timeout_duration, local.transcribe()).await { + Ok(Ok(r)) => { + schedule_foundry_local_asr_release(inner, current_session_id); + r + } + Ok(Err(e)) => { log::error!("[coord] Foundry Local Whisper transcribe failed: {e:#}"); emit_capsule( inner, @@ -1733,6 +1737,26 @@ async fn end_session(inner: &Arc) -> Result<(), String> { schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(e.to_string()); } + Err(_) => { + log::error!( + "[coord] Foundry Local Whisper timeout after {} seconds", + FOUNDRY_LOCAL_TRANSCRIBE_TIMEOUT_SECS + ); + local.cancel(); + schedule_foundry_local_asr_release(inner, current_session_id); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some("本地识别超时".to_string()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("foundry local timeout".to_string()); + } } } #[cfg(target_os = "macos")] @@ -2371,6 +2395,24 @@ fn schedule_local_asr_release(inner: &Arc) { }); } +#[cfg(target_os = "windows")] +fn schedule_foundry_local_asr_release(inner: &Arc, session_id: u64) { + let keep_secs = inner.prefs.get().local_asr_keep_loaded_secs; + 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 inner.state.lock().session_id != session_id { + return; + } + } + if let Err(error) = runtime.release_now().await { + log::warn!("[foundry-asr] scheduled release failed: {error:#}"); + } + }); +} + #[cfg(target_os = "macos")] async fn build_local_qwen3(inner: &Arc) -> anyhow::Result> { let prefs = inner.prefs.get(); @@ -3212,6 +3254,15 @@ mod tests { assert!(!asr_transcribe_uses_global_timeout(&active_asr)); } + #[cfg(target_os = "windows")] + #[test] + fn foundry_transcribe_has_finite_extended_timeout() { + let timeout = foundry_transcribe_timeout_duration(); + + assert!(timeout > std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)); + assert!(timeout <= std::time::Duration::from_secs(15 * 60)); + } + #[test] fn resolve_ark_endpoint_rejects_blank_key_without_custom_endpoint() { assert_eq!( @@ -3527,6 +3578,14 @@ const CAPSULE_AUTO_HIDE_DELAY_MS: u64 = 2000; /// 只在 ASR 超时机制失效时作为最后的防线触发。 const COORDINATOR_GLOBAL_TIMEOUT_SECS: u64 = 15; +#[cfg(target_os = "windows")] +const FOUNDRY_LOCAL_TRANSCRIBE_TIMEOUT_SECS: u64 = 10 * 60; + +#[cfg(target_os = "windows")] +fn foundry_transcribe_timeout_duration() -> std::time::Duration { + std::time::Duration::from_secs(FOUNDRY_LOCAL_TRANSCRIBE_TIMEOUT_SECS) +} + /// begin_session 中各 await 之间的 cancel race 检查结果。 enum BeginOutcome { /// 启动 continuation 属于旧 session;不能改动当前 session 状态。 From 0421ced5bee19634bf7f922e60bbf8840a8b3dd3 Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 23:06:38 +0800 Subject: [PATCH 28/37] fix: split Foundry prepare and transcription timeouts --- .../src/asr/local/foundry_provider.rs | 17 +++++-- .../src/asr/local/foundry_runtime.rs | 11 ++++- openless-all/app/src-tauri/src/coordinator.rs | 47 ++++++------------- 3 files changed, 36 insertions(+), 39 deletions(-) diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs b/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs index 747ea997..a951d978 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs @@ -62,7 +62,7 @@ impl FoundryLocalWhisperAsr { self.language_hint.as_deref() } - pub async fn transcribe(&self) -> Result { + pub async fn transcribe(&self, audio_timeout: std::time::Duration) -> Result { let pcm = self.buffer.lock().clone(); if pcm.is_empty() { return Ok(RawTranscript { @@ -71,14 +71,18 @@ impl FoundryLocalWhisperAsr { }); } - let result = self.transcribe_inner(&pcm).await; + let result = self.transcribe_inner(&pcm, audio_timeout).await; if result.is_ok() { self.buffer.lock().clear(); } result } - async fn transcribe_inner(&self, pcm: &[u8]) -> Result { + async fn transcribe_inner( + &self, + pcm: &[u8], + audio_timeout: std::time::Duration, + ) -> Result { let duration_ms = pcm_duration_ms(pcm); #[cfg(not(target_os = "windows"))] @@ -95,7 +99,12 @@ impl FoundryLocalWhisperAsr { let wav_file = TempWavFile::create(pcm)?; let text = self .runtime - .transcribe_audio_file(&self.model_alias, self.language_hint(), wav_file.path()) + .transcribe_audio_file( + &self.model_alias, + self.language_hint(), + wav_file.path(), + audio_timeout, + ) .await .with_context(|| { format!( diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs index 367fd5ce..de4d0777 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs @@ -129,6 +129,7 @@ mod imp { alias: &str, language_hint: Option<&str>, audio_path: &Path, + audio_timeout: std::time::Duration, ) -> Result { let _lifecycle = self.lifecycle.lock().await; self.cancel_prepare.store(false, Ordering::SeqCst); @@ -140,9 +141,14 @@ mod imp { if let Some(language_hint) = normalized_language_hint(language_hint) { client = client.language(language_hint); } - let result = client - .transcribe(audio_path) + let result = tokio::time::timeout(audio_timeout, client.transcribe(audio_path)) .await + .with_context(|| { + format!( + "transcribe audio with Foundry model {alias} timed out after {} seconds", + audio_timeout.as_secs() + ) + })? .with_context(|| format!("transcribe audio with Foundry model {alias}"))?; Ok(result.text) } @@ -448,6 +454,7 @@ impl FoundryLocalRuntime { alias: &str, _language_hint: Option<&str>, _audio_path: &std::path::Path, + _audio_timeout: std::time::Duration, ) -> anyhow::Result { anyhow::bail!("Foundry Local Whisper is only available on Windows: {alias}"); } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 1bf48b88..c67fdb34 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1716,13 +1716,15 @@ async fn end_session(inner: &Arc) -> Result<(), String> { #[cfg(target_os = "windows")] ActiveAsr::FoundryLocalWhisper(local) => { debug_assert!(!uses_global_timeout); - let timeout_duration = foundry_transcribe_timeout_duration(); - match tokio::time::timeout(timeout_duration, local.transcribe()).await { - Ok(Ok(r)) => { + match local + .transcribe(foundry_audio_transcribe_timeout_duration()) + .await + { + Ok(r) => { schedule_foundry_local_asr_release(inner, current_session_id); r } - Ok(Err(e)) => { + Err(e) => { log::error!("[coord] Foundry Local Whisper transcribe failed: {e:#}"); emit_capsule( inner, @@ -1737,26 +1739,6 @@ async fn end_session(inner: &Arc) -> Result<(), String> { schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(e.to_string()); } - Err(_) => { - log::error!( - "[coord] Foundry Local Whisper timeout after {} seconds", - FOUNDRY_LOCAL_TRANSCRIBE_TIMEOUT_SECS - ); - local.cancel(); - schedule_foundry_local_asr_release(inner, current_session_id); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - elapsed, - Some("本地识别超时".to_string()), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err("foundry local timeout".to_string()); - } } } #[cfg(target_os = "macos")] @@ -3256,11 +3238,13 @@ mod tests { #[cfg(target_os = "windows")] #[test] - fn foundry_transcribe_has_finite_extended_timeout() { - let timeout = foundry_transcribe_timeout_duration(); + fn foundry_audio_transcribe_timeout_is_separate_from_prepare() { + let timeout = foundry_audio_transcribe_timeout_duration(); - assert!(timeout > std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)); - assert!(timeout <= std::time::Duration::from_secs(15 * 60)); + assert_eq!( + timeout, + std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) + ); } #[test] @@ -3579,11 +3563,8 @@ const CAPSULE_AUTO_HIDE_DELAY_MS: u64 = 2000; const COORDINATOR_GLOBAL_TIMEOUT_SECS: u64 = 15; #[cfg(target_os = "windows")] -const FOUNDRY_LOCAL_TRANSCRIBE_TIMEOUT_SECS: u64 = 10 * 60; - -#[cfg(target_os = "windows")] -fn foundry_transcribe_timeout_duration() -> std::time::Duration { - std::time::Duration::from_secs(FOUNDRY_LOCAL_TRANSCRIBE_TIMEOUT_SECS) +fn foundry_audio_transcribe_timeout_duration() -> std::time::Duration { + std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) } /// begin_session 中各 await 之间的 cancel race 检查结果。 From eaab08148fa10cfec48c0cdc425c95614f29b8a6 Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 23:38:54 +0800 Subject: [PATCH 29/37] fix: use Foundry ASR keep-loaded TTL --- openless-all/app/src-tauri/src/coordinator.rs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c67fdb34..4e4fc40b 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2377,9 +2377,14 @@ fn schedule_local_asr_release(inner: &Arc) { }); } +#[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")] fn schedule_foundry_local_asr_release(inner: &Arc, session_id: u64) { - let keep_secs = inner.prefs.get().local_asr_keep_loaded_secs; + 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 { @@ -3247,6 +3252,19 @@ mod tests { ); } + #[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); + } + #[test] fn resolve_ark_endpoint_rejects_blank_key_without_custom_endpoint() { assert_eq!( From 60b4d458234c8b72f1d0b3a0bd32eba73754d055 Mon Sep 17 00:00:00 2001 From: millionart Date: Thu, 7 May 2026 09:03:51 +0800 Subject: [PATCH 30/37] fix: restore Foundry model on prepare failure --- .../src/asr/local/foundry_runtime.rs | 105 ++++++++++++++++-- 1 file changed, 95 insertions(+), 10 deletions(-) diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs index de4d0777..30391ba4 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs @@ -171,10 +171,7 @@ mod imp { return Ok(loaded); } - if let Some(previous) = self.loaded_for_different_alias(alias) { - Self::unload_model(&previous).await?; - self.clear_loaded_if_model_id(&previous.model_id); - } + let previous_loaded = self.loaded_for_different_alias(alias); self.check_prepare_cancelled()?; let manager = self.manager()?; @@ -259,16 +256,67 @@ mod imp { model_label.clone(), 0.0, )); - model + let model_id = model.id().to_string(); + if previous_loaded + .as_ref() + .is_some_and(|previous| previous.model_id == model_id) + { + progress.as_ref()(FoundryPrepareProgressPayload::load( + alias, + model_label.clone(), + 100.0, + )); + let loaded = LoadedModel { + alias: alias.to_string(), + model_id, + model, + }; + *self.state.lock() = RuntimeState { + manager: Some(manager), + loaded: Some(loaded.clone()), + }; + progress.as_ref()(FoundryPrepareProgressPayload::finished( + alias, + format!("{model_label} ready"), + )); + return Ok(loaded); + } + + let unloaded_previous = if let Some(previous) = previous_loaded.as_ref() { + Self::unload_model(previous).await?; + self.clear_loaded_if_model_id(&previous.model_id); + Some(previous.clone()) + } else { + None + }; + if let Err(error) = self.check_prepare_cancelled() { + self.rollback_prepare_error(manager, unloaded_previous.as_ref(), alias, error) + .await?; + } + if let Err(error) = model .load() .await - .with_context(|| format!("load Foundry model {alias}"))?; + .with_context(|| format!("load Foundry model {alias}")) + { + self.rollback_prepare_error(manager, unloaded_previous.as_ref(), alias, error) + .await?; + } if self.cancel_prepare.load(Ordering::SeqCst) { - model + if let Err(error) = model .unload() .await - .with_context(|| format!("unload cancelled Foundry model {alias}"))?; - anyhow::bail!("Foundry Local Whisper prepare cancelled"); + .with_context(|| format!("unload cancelled Foundry model {alias}")) + { + self.rollback_prepare_error(manager, unloaded_previous.as_ref(), alias, error) + .await?; + } + self.rollback_prepare_error( + manager, + unloaded_previous.as_ref(), + alias, + anyhow::anyhow!("Foundry Local Whisper prepare cancelled"), + ) + .await?; } progress.as_ref()(FoundryPrepareProgressPayload::load( alias, @@ -278,7 +326,7 @@ mod imp { let loaded = LoadedModel { alias: alias.to_string(), - model_id: model.id().to_string(), + model_id, model, }; *self.state.lock() = RuntimeState { @@ -300,6 +348,43 @@ mod imp { Ok(()) } + async fn restore_loaded_model( + &self, + manager: &'static FoundryLocalManager, + loaded: &LoadedModel, + ) -> Result<()> { + loaded + .model + .load() + .await + .with_context(|| format!("restore Foundry model {}", loaded.model_id))?; + *self.state.lock() = RuntimeState { + manager: Some(manager), + loaded: Some(loaded.clone()), + }; + Ok(()) + } + + async fn rollback_prepare_error( + &self, + manager: &'static FoundryLocalManager, + previous: Option<&LoadedModel>, + alias: &str, + error: anyhow::Error, + ) -> Result<()> { + if let Some(previous) = previous { + if let Err(restore_error) = self.restore_loaded_model(manager, previous).await { + return Err(error).with_context(|| { + format!( + "prepare Foundry model {alias} failed; also failed to restore previous Foundry model {}: {restore_error:#}", + previous.model_id + ) + }); + } + } + Err(error) + } + fn cached_loaded_model(&self, alias: &str) -> Option { self.state .lock() From a54ca77e9b9c003568361f3fd08cfba5ce672b35 Mon Sep 17 00:00:00 2001 From: millionart Date: Thu, 7 May 2026 10:08:36 +0800 Subject: [PATCH 31/37] fix: guard Foundry release and cancel stale transcripts --- .../src/asr/local/foundry_provider.rs | 15 +++++++ openless-all/app/src-tauri/src/coordinator.rs | 40 +++++++++++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs b/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs index a951d978..77a5ff13 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs @@ -6,6 +6,7 @@ use std::fs::{self, OpenOptions}; use std::io::Write; #[cfg(target_os = "windows")] use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(target_os = "windows")] use std::sync::Arc; @@ -28,6 +29,7 @@ pub struct FoundryLocalWhisperAsr { model_alias: String, language_hint: Option, buffer: Mutex>, + cancel_generation: AtomicU64, } impl FoundryLocalWhisperAsr { @@ -42,6 +44,7 @@ impl FoundryLocalWhisperAsr { model_alias, language_hint: normalize_language_hint(language_hint), buffer: Mutex::new(Vec::new()), + cancel_generation: AtomicU64::new(0), } } @@ -51,6 +54,7 @@ impl FoundryLocalWhisperAsr { model_alias, language_hint: normalize_language_hint(language_hint), buffer: Mutex::new(Vec::new()), + cancel_generation: AtomicU64::new(0), } } @@ -63,6 +67,7 @@ impl FoundryLocalWhisperAsr { } pub async fn transcribe(&self, audio_timeout: std::time::Duration) -> Result { + let cancel_generation = self.cancel_generation.load(Ordering::SeqCst); let pcm = self.buffer.lock().clone(); if pcm.is_empty() { return Ok(RawTranscript { @@ -72,6 +77,9 @@ impl FoundryLocalWhisperAsr { } let result = self.transcribe_inner(&pcm, audio_timeout).await; + if self.cancel_generation.load(Ordering::SeqCst) != cancel_generation { + anyhow::bail!("Foundry Local Whisper transcription cancelled"); + } if result.is_ok() { self.buffer.lock().clear(); } @@ -121,6 +129,7 @@ impl FoundryLocalWhisperAsr { } pub fn cancel(&self) { + self.cancel_generation.fetch_add(1, Ordering::SeqCst); self.buffer.lock().clear(); } } @@ -298,6 +307,12 @@ mod tests { provider.cancel(); assert!(provider.buffer.lock().is_empty()); + assert_eq!( + provider + .cancel_generation + .load(std::sync::atomic::Ordering::SeqCst), + 1 + ); assert_eq!(provider.model_alias(), "whisper-small"); assert_eq!(provider.language_hint(), Some("zh")); } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 92b42676..7e84565b 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2522,6 +2522,15 @@ async fn end_session(inner: &Arc) -> Result<(), String> { r } Err(e) => { + if inner.state.lock().cancelled { + log::info!( + "[coord] Foundry Local Whisper transcribe cancelled — discarding transcript" + ); + schedule_foundry_local_asr_release(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } log::error!("[coord] Foundry Local Whisper transcribe failed: {e:#}"); emit_capsule( inner, @@ -3179,6 +3188,11 @@ fn foundry_local_asr_release_keep_secs(inner: &Arc) -> u32 { inner.prefs.get().foundry_local_asr_keep_loaded_secs } +#[cfg(target_os = "windows")] +fn foundry_release_session_is_current(inner: &Arc, session_id: u64) -> bool { + inner.state.lock().session_id == session_id +} + #[cfg(target_os = "windows")] fn schedule_foundry_local_asr_release(inner: &Arc, session_id: u64) { let keep_secs = foundry_local_asr_release_keep_secs(inner); @@ -3187,9 +3201,9 @@ fn schedule_foundry_local_asr_release(inner: &Arc, session_id: u64) { tauri::async_runtime::spawn(async move { if keep_secs > 0 { tokio::time::sleep(std::time::Duration::from_secs(keep_secs as u64)).await; - if inner.state.lock().session_id != session_id { - return; - } + } + if !foundry_release_session_is_current(&inner, session_id) { + return; } if let Err(error) = runtime.release_now().await { log::warn!("[foundry-asr] scheduled release failed: {error:#}"); @@ -4064,6 +4078,26 @@ mod tests { assert_eq!(foundry_local_asr_release_keep_secs(&coordinator.inner), 7); } + #[cfg(target_os = "windows")] + #[test] + fn foundry_release_guard_rejects_stale_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!(foundry_release_session_is_current( + &coordinator.inner, + old_session_id + )); + + coordinator.inner.state.lock().session_id = old_session_id.wrapping_add(1); + + assert!(!foundry_release_session_is_current( + &coordinator.inner, + old_session_id + )); + } + #[test] fn resolve_ark_endpoint_rejects_blank_key_without_custom_endpoint() { assert_eq!( From 3d4d82f23dafb2c05adb3189bb237121f6f2f41f Mon Sep 17 00:00:00 2001 From: millionart Date: Thu, 7 May 2026 10:38:12 +0800 Subject: [PATCH 32/37] fix: cancel Foundry prepare with provider --- .../src/asr/local/foundry_provider.rs | 32 ++++++++++++++++--- .../src/asr/local/foundry_runtime.rs | 5 +++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs b/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs index 77a5ff13..49a5919d 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs @@ -130,6 +130,8 @@ impl FoundryLocalWhisperAsr { pub fn cancel(&self) { self.cancel_generation.fetch_add(1, Ordering::SeqCst); + #[cfg(target_os = "windows")] + self.runtime.request_cancel_prepare(); self.buffer.lock().clear(); } } @@ -241,13 +243,20 @@ mod tests { use crate::recorder::AudioConsumer; #[cfg(target_os = "windows")] - fn test_provider() -> super::FoundryLocalWhisperAsr { + fn test_provider() -> ( + super::FoundryLocalWhisperAsr, + std::sync::Arc, + ) { use std::sync::Arc; - super::FoundryLocalWhisperAsr::new( - Arc::new(super::FoundryLocalRuntime::new()), - "whisper-small".into(), - Some(" zh ".into()), + let runtime = Arc::new(super::FoundryLocalRuntime::new()); + ( + super::FoundryLocalWhisperAsr::new( + Arc::clone(&runtime), + "whisper-small".into(), + Some(" zh ".into()), + ), + runtime, ) } @@ -301,6 +310,9 @@ mod tests { #[test] fn foundry_provider_cancel_clears_buffer() { + #[cfg(target_os = "windows")] + let (provider, _) = test_provider(); + #[cfg(not(target_os = "windows"))] let provider = test_provider(); provider.consume_pcm_chunk(&[1, 0, 2, 0]); @@ -316,4 +328,14 @@ mod tests { assert_eq!(provider.model_alias(), "whisper-small"); assert_eq!(provider.language_hint(), Some("zh")); } + + #[cfg(target_os = "windows")] + #[test] + fn foundry_provider_cancel_requests_runtime_prepare_cancel() { + let (provider, runtime) = test_provider(); + + provider.cancel(); + + assert!(runtime.cancel_prepare_requested_for_tests()); + } } diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs index 30391ba4..d3b4fece 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs @@ -99,6 +99,11 @@ mod imp { self.cancel_prepare.store(true, Ordering::SeqCst); } + #[cfg(test)] + pub(crate) fn cancel_prepare_requested_for_tests(&self) -> bool { + self.cancel_prepare.load(Ordering::SeqCst) + } + pub async fn catalog_snapshot(&self) -> Result> { let _lifecycle = self.lifecycle.lock().await; let manager = self.manager()?; From b1cf0d4b36e96b7718fc0844bb590da279609b60 Mon Sep 17 00:00:00 2001 From: millionart Date: Thu, 7 May 2026 10:39:51 +0800 Subject: [PATCH 33/37] fix: cancel Foundry prepare before release --- openless-all/app/src-tauri/src/commands.rs | 31 +++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 2907d539..1c464461 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -210,6 +210,18 @@ fn local_asr_release_plan_for_provider(provider: &str) -> LocalAsrReleasePlan { } } +async fn release_foundry_runtime_if_inactive( + runtime: &Arc, + release_foundry: bool, +) { + if release_foundry { + runtime.request_cancel_prepare(); + if let Err(error) = runtime.release_now().await { + log::warn!("[foundry-asr] release inactive runtime failed: {error:#}"); + } + } +} + #[tauri::command] pub fn set_credential(window: Window, account: String, value: String) -> Result<(), String> { ensure_main_window(&window)?; @@ -242,11 +254,7 @@ pub async fn set_active_asr_provider( // 再走 local 路径,引擎会驻留到进程退出。 coord.release_local_asr_engine(); } - if release_plan.foundry { - if let Err(error) = runtime.release_now().await { - log::warn!("[foundry-asr] release inactive runtime failed: {error:#}"); - } - } + release_foundry_runtime_if_inactive(runtime.inner(), release_plan.foundry).await; Ok(()) } @@ -1472,7 +1480,8 @@ mod tests { asr_configured_for_provider, asr_transcriptions_url, fetch_provider_models, llm_configured_for_snapshot, local_asr_release_plan_for_provider, models_url, normalize_foundry_language_hint, parse_model_ids, persist_settings, - validate_foundry_model_alias, ProviderConfig, SettingsWriter, + release_foundry_runtime_if_inactive, validate_foundry_model_alias, ProviderConfig, + SettingsWriter, }; use crate::persistence::CredentialsSnapshot; use crate::types::{ @@ -1587,6 +1596,16 @@ mod tests { assert!(cloud.foundry); } + #[cfg(target_os = "windows")] + #[tokio::test] + async fn provider_switch_release_requests_foundry_prepare_cancel_first() { + let runtime = std::sync::Arc::new(crate::asr::local::FoundryLocalRuntime::new()); + + release_foundry_runtime_if_inactive(&runtime, true).await; + + assert!(runtime.cancel_prepare_requested_for_tests()); + } + #[test] fn foundry_language_hint_accepts_empty_and_lowercase_iso_639_1() { assert_eq!(normalize_foundry_language_hint("").unwrap(), ""); From 5c92e87bc61b2d7f0fb1d5c005dc2d212f93a27d Mon Sep 17 00:00:00 2001 From: millionart Date: Thu, 7 May 2026 10:46:44 +0800 Subject: [PATCH 34/37] fix: initialize coordinator hotkey fields on macos --- openless-all/app/src-tauri/src/coordinator.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 7e84565b..f7e76590 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -277,6 +277,11 @@ impl Coordinator { hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), hotkey_trigger_held: AtomicBool::new(false), + 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()), From 593cf766a23a02802ec0c80941b031dbab71e392 Mon Sep 17 00:00:00 2001 From: millionart Date: Thu, 7 May 2026 11:15:12 +0800 Subject: [PATCH 35/37] fix: clean up Foundry transcribe failures --- .../src-tauri/src/asr/local/foundry_provider.rs | 14 +++++++++++++- openless-all/app/src-tauri/src/coordinator.rs | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs b/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs index 49a5919d..830b1ad6 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs @@ -80,7 +80,7 @@ impl FoundryLocalWhisperAsr { if self.cancel_generation.load(Ordering::SeqCst) != cancel_generation { anyhow::bail!("Foundry Local Whisper transcription cancelled"); } - if result.is_ok() { + if foundry_transcribe_attempt_consumes_buffer(&result) { self.buffer.lock().clear(); } result @@ -238,6 +238,11 @@ fn trim_transcript_text(text: &str) -> String { text.trim().to_string() } +fn foundry_transcribe_attempt_consumes_buffer(result: &Result) -> bool { + let _ = result; + true +} + #[cfg(test)] mod tests { use crate::recorder::AudioConsumer; @@ -308,6 +313,13 @@ mod tests { assert_eq!(super::trim_transcript_text(" hello\r\n"), "hello"); } + #[test] + fn foundry_transcribe_attempt_consumes_buffer_even_on_error() { + let result: anyhow::Result<()> = Err(anyhow::anyhow!("transient runtime error")); + + assert!(super::foundry_transcribe_attempt_consumes_buffer(&result)); + } + #[test] fn foundry_provider_cancel_clears_buffer() { #[cfg(target_os = "windows")] diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index f7e76590..a05a3476 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2537,6 +2537,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { return Ok(()); } log::error!("[coord] Foundry Local Whisper transcribe failed: {e:#}"); + schedule_foundry_local_asr_release(inner, current_session_id); emit_capsule( inner, CapsuleState::Error, From 33c253849e5d95d9062127ee0cb2b088b2d577aa Mon Sep 17 00:00:00 2001 From: millionart Date: Thu, 7 May 2026 12:19:46 +0800 Subject: [PATCH 36/37] fix: address Settings review feedback --- openless-all/app/src/pages/Settings.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 57ba40b2..bb5ef199 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -204,7 +204,6 @@ function RecordingSection() { const choices: Array<[HotkeyMode, string]> = [ ['toggle', t('settings.recording.modeToggle')], ['hold', t('settings.recording.modeHold')], - ['doubleClick', t('settings.recording.modeDoubleClick')], ]; const hotkeyDesc = capability.requiresAccessibilityPermission ? t('settings.recording.hotkeyDescAcc') @@ -897,7 +896,7 @@ function providerErrorMessage(error: unknown, t: ReturnType Date: Thu, 7 May 2026 12:41:24 +0800 Subject: [PATCH 37/37] fix: restore frontend Chinese encoding --- openless-all/app/src/pages/SelectionAsk.tsx | 18 ++--- openless-all/app/src/pages/Settings.tsx | 90 ++++++++++----------- 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/openless-all/app/src/pages/SelectionAsk.tsx b/openless-all/app/src/pages/SelectionAsk.tsx index 9f42d898..ce1b8c43 100644 --- a/openless-all/app/src/pages/SelectionAsk.tsx +++ b/openless-all/app/src/pages/SelectionAsk.tsx @@ -1,9 +1,9 @@ -// SelectionAsk.tsx 鈥?鐙珛鐨?鍒掕瘝杩介棶"椤碉紙issue #118 / PR #119 閰嶇疆 UI 鎷嗗垎鐗堬級銆? -// 鍔熻兘锛氱敤鎴峰湪浠绘剰 app 閫変腑涓€娈垫枃瀛?鈫?鎸?hotkey 鈫?娴獥寮瑰嚭 + 杩涘叆璇煶褰曢煶 鈫? -// 鐢ㄦ埛鍙h堪鎻愰棶 鈫?ASR + 閫夊尯 + 鎻愰棶 涓€璧烽€?LLM 鈫?绛旀浠?markdown 鏄剧ず鍦ㄦ诞绐椼€? +// SelectionAsk.tsx — 独立的"划词追问"页(issue #118 / PR #119 配置 UI 拆分版)。 +// 功能:用户在任意 app 选中一段文字 → 按 hotkey → 浮窗弹出 + 进入语音录音 → +// 用户口述提问 → ASR + 选区 + 提问 一起送 LLM → 答案以 markdown 显示在浮窗。 // -// 杩欎竴椤垫妸鍘熸湰鏁e湪 Settings 鈫?褰曢煶 閲岀殑涓ゆ潯閰嶇疆锛坔otkey 棰勮 / 淇濆瓨 Q&A 鍘嗗彶锛? -// 闆嗕腑璧锋潵 + 鍔犲畬鏁翠娇鐢ㄦ寚鍗楋紝璺?缈昏瘧"椤靛钩绾с€? +// 这一页把原本散在 Settings → 录音 里的两条配置(hotkey 预设 / 保存 Q&A 历史) +// 集中起来 + 加完整使用指南,跟"翻译"页平级。 import { useTranslation } from 'react-i18next'; import { Card, PageHeader } from './_atoms'; @@ -55,7 +55,7 @@ export function SelectionAsk() {
- {/* 1. 瑙﹀彂蹇嵎閿?*/} + {/* 1. 触发快捷键 */} - {/* 2. 鍘嗗彶淇濆瓨 */} + {/* 2. 历史保存 */} - {/* 3. 浣跨敤鏂规硶 */} + {/* 3. 使用方法 */}
{t('selectionAsk.howto.title')}
    @@ -138,7 +138,7 @@ export function SelectionAsk() { ); } -// 鍗$墖鏍囬琛屽彸渚у紑鍏筹細涓?Style 椤甸潰椤舵爮鐨?36脳20 toggle 鍚屾锛屼繚鎸佸叏灞€瑙嗚涓€鑷淬€? +// 卡片标题行右侧开关:与 Style 页面顶栏的 36×20 toggle 同款,保持全局视觉一致。 function CardHeaderToggle({ title, checked, diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 2051976b..14edcf9c 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -1,4 +1,4 @@ -// Settings.tsx 鈥?ported verbatim from design_handoff_openless/pages.jsx::Settings. +// Settings.tsx — ported verbatim from design_handoff_openless/pages.jsx::Settings. // Internal sub-sections (Recording / Providers / Shortcuts / Permissions / Language / About) // keep their inline-style literals 1:1 with the source JSX. @@ -69,15 +69,15 @@ import { type LocalAsrSettings, } from '../lib/localAsr'; -/// Settings 鈫?ASR 閫変簡 local-qwen3 鏃惰Е鍙戣烦鍒般€屾ā鍨嬭缃€嶉〉 + 鍏?Settings modal銆? -/// FloatingShell 鐩戝惉鍚屽悕浜嬩欢鍋?setCurrentTab('localAsr') + setSettingsOpen(false)銆? +/// Settings → ASR 选了 local-qwen3 时触发跳到「模型设置」页 + 关 Settings modal。 +/// FloatingShell 监听同名事件做 setCurrentTab('localAsr') + setSettingsOpen(false)。 export const NAVIGATE_LOCAL_ASR_EVENT = 'openless:navigate-local-asr'; interface SettingsProps { embedded?: boolean; initialSection?: SettingsSectionId; } -// "鍏充簬" tab 宸茬Щ闄わ紙鍐呭骞跺叆澶栧眰 SettingsModal 鐨?About 椤碉紝閬垮厤璁剧疆鍐呭閲嶅鍏ュ彛锛夈€? +// "关于" tab 已移除(内容并入外层 SettingsModal 的 About 页,避免设置内外重复入口)。 export type SettingsSectionId = 'recording' | 'providers' | 'shortcuts' | 'permissions' | 'language'; const SECTION_ORDER: SettingsSectionId[] = ['recording', 'providers', 'shortcuts', 'permissions', 'language']; @@ -114,10 +114,10 @@ export function Settings({ embedded = false, initialSection = 'recording' }: Set desc={t('settings.desc')} /> )} - {/* embedded锛堝湪 SettingsModal 閲岋級妯″紡涓嬶細mini-sidebar 鍥哄畾锛屼粎鍙虫爮 scroll銆? - 澶栧眰 flex:1 minHeight:0 璁?grid 鎷垮埌纭畾楂樺害锛沢ridTemplateRows: minmax(0, 1fr) - 寮哄埗琛岄珮绛変簬瀹瑰櫒楂樺害锛屽惁鍒?grid 榛樿 auto rows 浼氳窡鍐呭闀匡紝鍙虫爮 overflow:auto - 灏遍€€鍖栨垚"娌′笢瑗块渶瑕?scroll"锛屼簬鏄ぇ瀹剁収鏃т竴璧烽銆?*/} + {/* embedded(在 SettingsModal 里)模式下:mini-sidebar 固定,仅右栏 scroll。 + 外层 flex:1 minHeight:0 让 grid 拿到确定高度;gridTemplateRows: minmax(0, 1fr) + 强制行高等于容器高度,否则 grid 默认 auto rows 会跟内容长,右栏 overflow:auto + 就退化成"没东西需要 scroll",于是大家照旧一起飘。 */}
    (null); useEffect(() => { @@ -1044,11 +1044,11 @@ type LlmPresetId = typeof LLM_PRESETS[number]['id']; const ASR_DEFAULT_RESOURCE_ID = 'volc.seedasr.sauc.duration'; -// `volcengine` 璧拌嚜寤烘祦寮忓鎴风锛涘叾浣欒蛋 OpenAI 鍏煎 `/audio/transcriptions` -// 锛坄coordinator.rs::is_whisper_compatible_provider`锛夈€傛柊澧炲吋瀹瑰巶鍟嗭細 -// 1. 鍦ㄨ繖閲屽姞涓€椤?`{ id, nameKey, baseUrl, model }`锛? -// 2. `coordinator.rs::is_whisper_compatible_provider` 鍔犲悓鍚?id锛? -// 3. 鍦?i18n 鐨?`settings.providers.presets.` 鍔犳枃妗堛€? +// `volcengine` 走自建流式客户端;其余走 OpenAI 兼容 `/audio/transcriptions` +// (`coordinator.rs::is_whisper_compatible_provider`)。新增兼容厂商: +// 1. 在这里加一项 `{ id, nameKey, baseUrl, model }`; +// 2. `coordinator.rs::is_whisper_compatible_provider` 加同名 id; +// 3. 在 i18n 的 `settings.providers.presets.` 加文案。 const ASR_PRESETS = [ { id: 'volcengine', nameKey: 'asrVolcengine', baseUrl: '', model: '' }, { id: 'siliconflow', nameKey: 'asrSiliconflow', baseUrl: 'https://api.siliconflow.cn/v1', model: 'FunAudioLLM/SenseVoiceSmall' }, @@ -1056,7 +1056,7 @@ const ASR_PRESETS = [ { id: 'groq', nameKey: 'asrGroq', baseUrl: 'https://api.groq.com/openai/v1', model: 'whisper-large-v3-turbo' }, { id: 'whisper', nameKey: 'asrWhisper', baseUrl: 'https://api.openai.com/v1', model: 'whisper-1' }, { id: 'foundry-local-whisper', nameKey: 'asrFoundryLocalWhisper', baseUrl: '', model: '' }, - // 鏈湴 Qwen3-ASR锛氭棤 baseUrl/model 閰嶇疆锛屾ā鍨嬪湪銆屾ā鍨嬭缃€嶉〉涓嬭浇涓庡垏鎹€? + // 本地 Qwen3-ASR:无 baseUrl/model 配置,模型在「模型设置」页下载与切换。 { id: 'local-qwen3', nameKey: 'asrLocalQwen3', baseUrl: '', model: '' }, ] as const; @@ -1065,13 +1065,13 @@ type AsrPresetId = typeof ASR_PRESETS[number]['id']; function ProvidersSection() { const { t } = useTranslation(); const { prefs, updatePrefs } = useHotkeySettings(); - // `*Provider` 绔嬪嵆璺熼殢 绔嬪埢鏄剧ず鐢ㄦ埛鐨勯€夋嫨锛坕ssue #220 P2锛歝odex 鎸囧嚭鍙楁帶閫変笉搴旂瓑 await锛? - // - CredentialField 涓嶈鍦ㄥ悗绔?active 鍒囧畬鍓?remount锛坕ssue #219锛氶伩鍏嶈鍒版棫 entry锛? - // `*SwitchSeq` 鏄?stale-write 瀹堝崼锛氱敤鎴?100ms 鍐呰繛鐐逛袱娆℃椂锛屽厛鍙戠殑璇锋眰鏅氬埌涓? - // 浼氳鐩栧悗鍙戠殑 commit銆? + // `*Provider` 立即跟随 立刻显示用户的选择(issue #220 P2:codex 指出受控选不应等 await) + // - CredentialField 不要在后端 active 切完前 remount(issue #219:避免读到旧 entry) + // `*SwitchSeq` 是 stale-write 守卫:用户 100ms 内连点两次时,先发的请求晚到不 + // 会覆盖后发的 commit。 const [llmProvider, setLlmProvider] = useState('ark'); const [asrProvider, setAsrProvider] = useState('volcengine'); const [committedLlmProvider, setCommittedLlmProvider] = useState('ark'); @@ -1097,12 +1097,12 @@ function ProvidersSection() { setCommittedAsrProvider(asrId); }, [prefs, os]); - // issue #219 / #220 P2锛? - // 1. 绔嬪埢 setLlmProvider 鈥斺€?鍙楁帶 必须反映用户最新选择。 + // 2. 用 seq 守卫每个 await:用户连点两次时旧请求晚到也不会盖掉新选择。 + // 3. 仅 setCommittedLlmProvider 之后 CredentialField 才 remount 读新 entry, + // 此时后端 root.active.llm 已经是 id,lookup_account 落到正确 entry。 + // 4. endpoint/model 默认值仅在该 provider entry 该字段为空时才填,不覆盖用户自定义。 const onLlmProviderChange = async (id: LlmPresetId) => { setLlmProvider(id); const seq = ++llmSwitchSeqRef.current; @@ -1135,9 +1135,9 @@ function ProvidersSection() { await updatePrefs(next); if (seq !== asrSwitchSeqRef.current) return; } - // OpenAI 鍏煎鍘傚晢棣栨鍒囨崲鏃堕濉?baseUrl / model 榛樿鍊硷紝鐪佸緱鐢ㄦ埛蹇呰俯 - // 銆岃法鍘傚晢 model 鍚嶆牴鏈笉涓€鏍枫€嶇殑鍧戯紱浣嗙敤鎴峰凡鑷畾涔夊悗灏变笉鍐嶈鐩栥€? - // volcengine 璧板彟涓€濂楀嚟鎹紝璺宠繃銆? + // OpenAI 兼容厂商首次切换时预填 baseUrl / model 默认值,省得用户必踩 + // 「跨厂商 model 名根本不一样」的坑;但用户已自定义后就不再覆盖。 + // volcengine 走另一套凭据,跳过。 const preset = ASR_PRESETS.find(p => p.id === id); if (preset && preset.baseUrl) { const existing = await readCredential('asr.endpoint'); @@ -1158,9 +1158,9 @@ function ProvidersSection() { setCommittedAsrProvider(id); }; - // preset 鍐冲畾 placeholder 涓?default 鈥斺€?蹇呴』璺熺潃 committed*Provider 璧帮紝 - // 鍚﹀垯鍙楁帶 立刻切到新厂商,但凭据字段还在显示旧 entry,placeholder + // 会先于实际数据切换、视觉上对不上。 const preset = LLM_PRESETS.find(p => p.id === committedLlmProvider) ?? LLM_PRESETS[LLM_PRESETS.length - 1]; const asrPreset = visibleAsrPresets.find(p => p.id === committedAsrProvider); @@ -1779,7 +1779,7 @@ function PermissionsSection() { refreshHotkey(); refreshWindowsIme(); const hotkeyId = window.setInterval(refreshHotkey, 1000); - // 楹﹀厠椋庢鏌ヤ細鐭殏鎵撳紑杈撳叆娴侊紝閬垮厤姣忕鎺㈡祴瀵艰嚧闅愮鎸囩ず鍣ㄩ绻侀棯鐑併€? + // 麦克风检查会短暂打开输入流,避免每秒探测导致隐私指示器频繁闪烁。 const permissionId = window.setInterval(refreshPermissions, 10000); const onFocus = () => { refreshPermissions(); @@ -1942,7 +1942,7 @@ function LanguageSection() { ); } -// AboutSection 宸茬Щ闄わ細鍐呭骞跺叆 SettingsModal 鐨?AboutMini锛岄伩鍏嶈缃唴澶栦袱涓?鍏充簬"閲嶅鍏ュ彛銆? +// AboutSection 已移除:内容并入 SettingsModal 的 AboutMini,避免设置内外两个"关于"重复入口。 export function AboutUpdateControl({ tagline }: { tagline: string }) { const { t } = useTranslation(); @@ -2006,9 +2006,9 @@ function adapterDisplayName(adapter: HotkeyCapability['adapter'] | HotkeyStatus[ return i18n.t('hotkey.adapter.rdev'); } -/// 鏈湴 Qwen3-ASR 鍦?Settings 鈫?鏈嶅姟鍟嗗尯閲?*涓?*璁╃敤鎴峰~绌衡€斺€斿睍绀哄綋鍓嶆縺娲绘ā鍨? -/// 鏄惁宸蹭笅杞姐€佸垪鍑烘墍鏈夊凡涓嬭浇妯″瀷 + 鍒犻櫎鎸夐挳锛屽苟鎻愮ず鎬ц兘/璐ㄩ噺棰勬湡锛屽紩瀵艰烦鍒? -/// 銆屾ā鍨嬭缃€嶉〉鍋氫笅杞姐€? +/// 本地 Qwen3-ASR 在 Settings → 服务商区里**不**让用户填空——展示当前激活模型 +/// 是否已下载、列出所有已下载模型 + 删除按钮,并提示性能/质量预期,引导跳到 +/// 「模型设置」页做下载。 function LocalAsrProviderHint({ provider, selectedProvider, @@ -2132,7 +2132,7 @@ function LocalAsrProviderHint({ return (
    - {/* 鎬ц兘/璐ㄩ噺棰勬湡璀﹀憡 鈥斺€?鐢ㄦ埛纭姹傝鍐欐竻妤?*/} + {/* 性能/质量预期警告 —— 用户硬要求要写清楚 */}
    - 鈿狅笍 {t('settings.providers.localAsrPerformanceWarning')} + ⚠️ {t('settings.providers.localAsrPerformanceWarning')}
    {t(hintKey)}
    - {/* 褰撳墠婵€娲绘ā鍨嬬姸鎬?+ 璺宠浆鎸夐挳 */} + {/* 当前激活模型状态 + 跳转按钮 */}
    {isReady @@ -2163,7 +2163,7 @@ function LocalAsrProviderHint({
    - {/* 宸蹭笅杞芥ā鍨嬪垪琛?+ 鍒犻櫎鎸夐挳锛堢敤鎴凤細宸蹭笅杞界殑椤圭洰瑕佸湪鏃佽竟鏄剧ず + 鎻愪緵鍒犻櫎锛?*/} + {/* 已下载模型列表 + 删除按钮(用户:已下载的项目要在旁边显示 + 提供删除) */} {downloaded.length > 0 && (