From eaca99bdf46c6498fe93f252fecb4e8e686f282e Mon Sep 17 00:00:00 2001 From: baiqing Date: Sat, 9 May 2026 13:15:33 +0800 Subject: [PATCH 01/50] fix(insertion): make Windows/Linux paste shortcut configurable (#360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模拟粘贴硬编码 Ctrl+V,kitty / alacritty / wezterm / gnome-terminal / foot 等 Linux 终端只接受 Ctrl+Shift+V,听写文本只剩在剪贴板里,看起来 像"什么也没插入"。issue #360 报告了 kitty 这个具体场景。 新增 `PasteShortcut` 枚举(`CtrlV` / `CtrlShiftV` / `ShiftInsert`)作为 UserPreferences 字段,串到 simulate_paste 的 enigo 路径。Settings → 录音 多了"模拟粘贴快捷键"一行,仅在非 macOS 显示(macOS 走 AX 直写 不受影响)。默认 `CtrlV` 与历史行为一致,老配置文件没有 pasteShortcut 字段时 `#[serde(default)]` 回退到默认值,不破坏既有用户。 涵盖: - types.rs:新枚举 + 字段 + UserPreferencesWire 串接 - insertion.rs:simulate_paste(shortcut),paste_keys 把枚举拆成 (modifiers, primary),按下/释放严格对称,中途出错也反向释放避免卡键 - coordinator.rs:从 prefs 读取,串到三个 insert 调用点 + Windows IME 链 - 前端 TS 类型 / Settings 选择器 / 5 种语言 i18n - 单元测试:默认 CtrlV、JSON 反序列化、paste_keys 三种映射 cargo test --lib 全部 181 通过;npm run build 干净。 --- openless-all/app/src-tauri/src/coordinator.rs | 16 ++- openless-all/app/src-tauri/src/insertion.rs | 117 ++++++++++++++---- openless-all/app/src-tauri/src/types.rs | 50 ++++++++ openless-all/app/src/i18n/en.ts | 5 + openless-all/app/src/i18n/ja.ts | 5 + openless-all/app/src/i18n/ko.ts | 5 + openless-all/app/src/i18n/zh-CN.ts | 5 + openless-all/app/src/i18n/zh-TW.ts | 5 + openless-all/app/src/lib/ipc.ts | 1 + openless-all/app/src/lib/stylePrefs.test.ts | 1 + openless-all/app/src/lib/types.ts | 11 ++ openless-all/app/src/pages/Settings.tsx | 19 +++ 12 files changed, 214 insertions(+), 26 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 16944c47..cf1ddfe6 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -43,7 +43,7 @@ use crate::selection::{capture_selection, SelectionContext}; use crate::types::{ CapsulePayload, CapsuleState, ChineseScriptPreference, DictationSession, HotkeyCapability, HotkeyMode, HotkeyStatus, HotkeyStatusState, InsertStatus, OutputLanguagePreference, - PolishMode, + PasteShortcut, PolishMode, }; #[cfg(target_os = "windows")] use crate::windows_ime_ipc::ImeSubmitTarget; @@ -2725,6 +2725,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { let prefs = inner.prefs.get(); let restore_clipboard = prefs.restore_clipboard_after_paste; let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback; + let paste_shortcut: PasteShortcut = prefs.paste_shortcut; let status = if focus_ready_for_paste { #[cfg(target_os = "windows")] { @@ -2735,13 +2736,16 @@ async fn end_session(inner: &Arc) -> Result<(), String> { &polished, restore_clipboard, allow_non_tsf_insertion_fallback, + paste_shortcut, ime_target, ) .await } #[cfg(not(target_os = "windows"))] { - inner.inserter.insert(&polished, restore_clipboard) + inner + .inserter + .insert(&polished, restore_clipboard, paste_shortcut) } } else { log::warn!( @@ -2952,6 +2956,7 @@ async fn insert_with_windows_ime_first( polished: &str, restore_clipboard: bool, allow_non_tsf_insertion_fallback: bool, + paste_shortcut: PasteShortcut, ime_target: Option, ) -> InsertStatus { let prepared = { @@ -2964,7 +2969,7 @@ async fn insert_with_windows_ime_first( allow_non_tsf_insertion_fallback, InsertStatus::Failed, ) { - return insert_via_non_tsf_fallback(inner, polished, restore_clipboard); + return insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut); } log::warn!("[windows-ime] non-TSF insertion fallback is disabled; failing insert"); return InsertStatus::Failed; @@ -2989,7 +2994,7 @@ async fn insert_with_windows_ime_first( if ime_status == InsertStatus::Inserted { ime_status } else if should_try_non_tsf_insertion_fallback(allow_non_tsf_insertion_fallback, ime_status) { - insert_via_non_tsf_fallback(inner, polished, restore_clipboard) + insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut) } else { log::warn!("[windows-ime] TSF did not insert; non-TSF insertion fallback is disabled"); InsertStatus::Failed @@ -3009,6 +3014,7 @@ fn insert_via_non_tsf_fallback( inner: &Arc, polished: &str, restore_clipboard: bool, + paste_shortcut: PasteShortcut, ) -> InsertStatus { if inner.inserter.insert_via_unicode_keystrokes(polished) == InsertStatus::Inserted { log::info!("[windows-ime] TSF unavailable; inserted via Unicode SendInput"); @@ -3016,7 +3022,7 @@ fn insert_via_non_tsf_fallback( } else { inner .inserter - .insert_via_clipboard_fallback(polished, restore_clipboard) + .insert_via_clipboard_fallback(polished, restore_clipboard, paste_shortcut) } } diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index ecfc4490..e3285ed6 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -20,7 +20,7 @@ use once_cell::sync::Lazy; #[cfg(not(target_os = "macos"))] use parking_lot::Mutex; -use crate::types::InsertStatus; +use crate::types::{InsertStatus, PasteShortcut}; #[cfg(target_os = "windows")] const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(750); @@ -38,12 +38,19 @@ impl TextInserter { /// Insert `text` at the current cursor position. /// `restore_clipboard_after_paste` 仅在 Windows/Linux 路径下决定 paste 之后是否恢复 /// 用户原剪贴板。macOS 走 AX 直写,参数被忽略。详见 issue #111。 + /// `paste_shortcut` 决定 Windows/Linux 上模拟按下的粘贴快捷键。详见 issue #360: + /// kitty/alacritty 等终端只接受 Ctrl+Shift+V,硬编码 Ctrl+V 会被吞掉。 #[cfg(not(target_os = "macos"))] - pub fn insert(&self, text: &str, restore_clipboard_after_paste: bool) -> InsertStatus { + pub fn insert( + &self, + text: &str, + restore_clipboard_after_paste: bool, + paste_shortcut: PasteShortcut, + ) -> InsertStatus { if text.is_empty() { return InsertStatus::CopiedFallback; } - insert_with_clipboard_restore(text, restore_clipboard_after_paste) + insert_with_clipboard_restore(text, restore_clipboard_after_paste, paste_shortcut) } #[cfg(not(target_os = "macos"))] @@ -51,8 +58,9 @@ impl TextInserter { &self, text: &str, restore_clipboard_after_paste: bool, + paste_shortcut: PasteShortcut, ) -> InsertStatus { - self.insert(text, restore_clipboard_after_paste) + self.insert(text, restore_clipboard_after_paste, paste_shortcut) } #[cfg(target_os = "windows")] @@ -70,8 +78,15 @@ impl TextInserter { } /// Insert `text` at the current cursor position. + /// macOS 走 AX 直写 / Cmd+V:`_restore_clipboard_after_paste` 与 `_paste_shortcut` + /// 仅为跨平台调用方对齐签名而存在,本路径不读它们。 #[cfg(target_os = "macos")] - pub fn insert(&self, text: &str, _restore_clipboard_after_paste: bool) -> InsertStatus { + pub fn insert( + &self, + text: &str, + _restore_clipboard_after_paste: bool, + _paste_shortcut: PasteShortcut, + ) -> InsertStatus { if text.is_empty() { return InsertStatus::CopiedFallback; } @@ -171,7 +186,11 @@ fn copy_to_clipboard_with_restore_plan(text: &str) -> Result InsertStatus { +fn insert_with_clipboard_restore( + text: &str, + restore_clipboard_after_paste: bool, + paste_shortcut: PasteShortcut, +) -> InsertStatus { let restore_plan = match copy_to_clipboard_with_restore_plan(text) { Ok(plan) => plan, Err(err) => { @@ -180,7 +199,7 @@ fn insert_with_clipboard_restore(text: &str, restore_clipboard_after_paste: bool } }; - if let Err(err) = simulate_paste() { + if let Err(err) = simulate_paste(paste_shortcut) { log::warn!("[insertion] simulated paste failed: {}", err); return InsertStatus::CopiedFallback; } @@ -302,20 +321,49 @@ fn simulate_paste() -> Result<(), String> { macos::post_cmd_v() } +/// 把用户配置的 PasteShortcut 拆成 `(modifiers, primary)`。modifier 顺序决定 enigo +/// 按下/释放顺序,跟物理键盘一致:先 Ctrl 再 Shift 再主键,释放反向。 #[cfg(not(target_os = "macos"))] -fn simulate_paste() -> Result<(), String> { - use enigo::{Direction, Enigo, Key, Keyboard, Settings}; - let mut enigo = Enigo::new(&Settings::default()).map_err(|e| e.to_string())?; - let modifier = Key::Control; - enigo - .key(modifier, Direction::Press) - .map_err(|e| e.to_string())?; - let press_v = enigo.key(Key::Unicode('v'), Direction::Click); - let release_modifier = enigo.key(modifier, Direction::Release); - if let Err(e) = release_modifier { - return Err(e.to_string()); +fn paste_keys(shortcut: PasteShortcut) -> (Vec, enigo::Key) { + use enigo::Key; + match shortcut { + PasteShortcut::CtrlV => (vec![Key::Control], Key::Unicode('v')), + PasteShortcut::CtrlShiftV => (vec![Key::Control, Key::Shift], Key::Unicode('v')), + PasteShortcut::ShiftInsert => (vec![Key::Shift], Key::Insert), + } +} + +#[cfg(not(target_os = "macos"))] +fn simulate_paste(shortcut: PasteShortcut) -> Result<(), String> { + use enigo::{Direction, Enigo, Keyboard, Settings}; + let (modifiers, primary) = paste_keys(shortcut); + let mut enigo = Enigo::new(&Settings::default()).map_err(|e| e.to_string())?; + + // 按下顺序:所有 modifier → 主键点击; + // 释放顺序:modifier 反向释放,确保即使中途出错也尽量把按键状态还原回来。 + for modifier in &modifiers { + if let Err(e) = enigo.key(*modifier, Direction::Press) { + // 按下失败时反向释放已经按下的 modifier,避免卡键。 + for already_pressed in modifiers.iter().take_while(|m| *m != modifier).rev() { + let _ = enigo.key(*already_pressed, Direction::Release); + } + return Err(e.to_string()); + } + } + + let click_result = enigo.key(primary, Direction::Click); + + let mut release_err: Option = None; + for modifier in modifiers.iter().rev() { + if let Err(e) = enigo.key(*modifier, Direction::Release) { + release_err.get_or_insert_with(|| e.to_string()); + } + } + + click_result.map_err(|e| e.to_string())?; + if let Some(err) = release_err { + return Err(err); } - press_v.map_err(|e| e.to_string())?; Ok(()) } @@ -474,15 +522,42 @@ mod tests { assert!(!should_restore_clipboard(None, "dictated text")); } + /// issue #360: 用户配置的快捷键必须真的映射到对应按键,否则 Settings UI + /// 改了也没用。这里只检查 modifier 数量 + 主键,不依赖 enigo 内部 PartialEq。 + #[test] + #[cfg(not(target_os = "macos"))] + fn paste_keys_match_configured_shortcut() { + use enigo::Key; + + let (mods, primary) = paste_keys(PasteShortcut::CtrlV); + assert_eq!(mods.len(), 1); + assert!(matches!(mods[0], Key::Control)); + assert!(matches!(primary, Key::Unicode('v'))); + + let (mods, primary) = paste_keys(PasteShortcut::CtrlShiftV); + assert_eq!(mods.len(), 2); + assert!(matches!(mods[0], Key::Control)); + assert!(matches!(mods[1], Key::Shift)); + assert!(matches!(primary, Key::Unicode('v'))); + + let (mods, primary) = paste_keys(PasteShortcut::ShiftInsert); + assert_eq!(mods.len(), 1); + assert!(matches!(mods[0], Key::Shift)); + assert!(matches!(primary, Key::Insert)); + } + #[test] fn empty_insertions_never_touch_clipboard_or_paste_path() { let inserter = TextInserter::new(); - assert_eq!(inserter.insert("", true), InsertStatus::CopiedFallback); + assert_eq!( + inserter.insert("", true, PasteShortcut::CtrlV), + InsertStatus::CopiedFallback + ); #[cfg(not(target_os = "macos"))] { assert_eq!( - inserter.insert_via_clipboard_fallback("", true), + inserter.insert_via_clipboard_fallback("", true, PasteShortcut::CtrlV), InsertStatus::CopiedFallback ); } diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index b2578117..e77612a3 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -45,6 +45,21 @@ pub enum OutputLanguagePreference { Ko, } +/// 模拟粘贴时实际按下的快捷键。macOS 走 AX 直写 / Cmd+V,本枚举只在 +/// Windows / Linux 的 simulate_paste 路径生效。详见 issue #360:kitty 等 +/// Linux 终端只接受 Ctrl+Shift+V,硬编码 Ctrl+V 会被吞掉,听写文本只剩 +/// 在剪贴板里。默认 `CtrlV` 与历史行为一致;用户在 Settings 里改成 +/// `CtrlShiftV`(kitty/alacritty/wezterm/gnome-terminal/foot/...)或 +/// `ShiftInsert`(xterm/urxvt)后,simulate_paste 用对应组合。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub enum PasteShortcut { + #[default] + CtrlV, + CtrlShiftV, + ShiftInsert, +} + /// Auto-update 渠道。决定 Settings → 关于 里展示哪一类版本信息。 /// `Stable` 沿用 `tauri-plugin-updater` 的默认 endpoints(即 `tauri.conf.json` /// 里的 `latest-{{target}}-{{arch}}.json`),与发版 pipeline 对齐。 @@ -145,6 +160,11 @@ pub struct UserPreferences { /// 关掉就把听写文本留在剪贴板,让 simulate_paste 实际没生效时用户能 Ctrl+V 找回。 /// macOS 走 AX 直写,不受这个开关影响。详见 issue #111。 pub restore_clipboard_after_paste: bool, + /// Windows / Linux 的模拟粘贴键。macOS 走 AX 直写不受影响。详见 issue #360: + /// kitty 等 Linux 终端不接受 Ctrl+V,只能配 Ctrl+Shift+V。默认 CtrlV 与历史 + /// 行为一致,不破坏既有用户。 + #[serde(default)] + pub paste_shortcut: PasteShortcut, /// Windows: 是否允许 TSF 失败后继续使用 SendInput / 粘贴类非 TSF 兜底。 /// 默认开启以保持可用性;关闭后可验证文本是否真正由 TSF 上屏。 #[serde(default = "default_true")] @@ -288,6 +308,8 @@ struct UserPreferencesWire { active_asr_provider: String, active_llm_provider: String, restore_clipboard_after_paste: bool, + #[serde(default)] + paste_shortcut: PasteShortcut, allow_non_tsf_insertion_fallback: bool, working_languages: Vec, translation_target_language: String, @@ -339,6 +361,7 @@ impl Default for UserPreferencesWire { active_asr_provider: prefs.active_asr_provider, active_llm_provider: prefs.active_llm_provider, restore_clipboard_after_paste: prefs.restore_clipboard_after_paste, + paste_shortcut: prefs.paste_shortcut, allow_non_tsf_insertion_fallback: prefs.allow_non_tsf_insertion_fallback, working_languages: prefs.working_languages, translation_target_language: prefs.translation_target_language, @@ -388,6 +411,7 @@ impl<'de> Deserialize<'de> for UserPreferences { active_asr_provider: wire.active_asr_provider, active_llm_provider: wire.active_llm_provider, restore_clipboard_after_paste: wire.restore_clipboard_after_paste, + paste_shortcut: wire.paste_shortcut, allow_non_tsf_insertion_fallback: wire.allow_non_tsf_insertion_fallback, working_languages: wire.working_languages, translation_target_language: wire.translation_target_language, @@ -504,6 +528,7 @@ impl Default for UserPreferences { active_asr_provider: default_active_asr_provider(), active_llm_provider: "ark".into(), restore_clipboard_after_paste: true, + paste_shortcut: PasteShortcut::default(), allow_non_tsf_insertion_fallback: true, working_languages: default_working_languages(), translation_target_language: String::new(), @@ -1127,6 +1152,31 @@ mod tests { assert!(prefs.allow_non_tsf_insertion_fallback); } + /// issue #360: 默认值必须是 CtrlV,跟历史行为一致;老配置文件没有 + /// pasteShortcut 字段时反序列化也得回到 CtrlV,否则会把现有用户的粘贴 + /// 行为静默改掉。 + #[test] + fn paste_shortcut_defaults_to_ctrl_v() { + let prefs = UserPreferences::default(); + assert_eq!(prefs.paste_shortcut, PasteShortcut::CtrlV); + + let from_empty: UserPreferences = serde_json::from_str("{}").unwrap(); + assert_eq!(from_empty.paste_shortcut, PasteShortcut::CtrlV); + } + + #[test] + fn paste_shortcut_round_trips_explicit_values() { + for (raw, expected) in [ + ("ctrlV", PasteShortcut::CtrlV), + ("ctrlShiftV", PasteShortcut::CtrlShiftV), + ("shiftInsert", PasteShortcut::ShiftInsert), + ] { + let json = format!(r#"{{ "pasteShortcut": "{raw}" }}"#); + let prefs: UserPreferences = serde_json::from_str(&json).unwrap(); + assert_eq!(prefs.paste_shortcut, expected, "raw={raw}"); + } + } + #[test] fn legacy_custom_hotkey_without_custom_binding_is_rejected() { let result = serde_json::from_str::( diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 17feb523..5b1cf4ca 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -313,6 +313,11 @@ export const en: typeof zhCN = { muteDuringRecordingDesc: 'Temporarily mute system output during voice input, then restore the previous mute state when recording stops, is cancelled, or fails.', restoreClipboardLabel: 'Restore clipboard after insert', restoreClipboardDesc: 'Windows / Linux only: restore your original clipboard after a successful paste (default on). Turn off to keep the dictation text in the clipboard so you can manually Ctrl+V if the simulated paste did not actually land. See issue #111.', + pasteShortcutLabel: 'Simulated paste shortcut', + pasteShortcutDesc: 'Windows / Linux only: which key combo to simulate when inserting text. Terminals like kitty, alacritty, wezterm, gnome-terminal, and foot only accept Ctrl+Shift+V; xterm / urxvt accept Shift+Insert. Takes effect on your next dictation. See issue #360.', + pasteShortcutCtrlV: 'Ctrl+V (default / most apps)', + pasteShortcutCtrlShiftV: 'Ctrl+Shift+V (kitty / alacritty / wezterm / most terminals)', + pasteShortcutShiftInsert: 'Shift+Insert (xterm / urxvt)', comboRecordLabel: 'Record shortcut', comboRecordDesc: 'Click, then press your desired key combination (e.g. \u2318\u21E7D). Supports Toggle and Push-to-talk modes.', comboRecordBtn: 'Record shortcut', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index b5b3ec28..4ba52301 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -315,6 +315,11 @@ export const ja: typeof zhCN = { muteDuringRecordingDesc: '音声入力開始時にシステム出力を一時的にミュートし、停止/キャンセル/エラー後に元のミュート状態を復元。スピーカーの音がマイクに入らないようにします。', restoreClipboardLabel: '入力後にクリップボードを復元', restoreClipboardDesc: 'Windows / Linux のみ:ペースト成功後に元のクリップボード内容を復元(既定 ON)。OFF にするとディクテーションテキストがクリップボードに残り、ペーストが効かなかった場合に手動で Ctrl+V できます。詳細は issue #111。', + pasteShortcutLabel: '貼り付けショートカット', + pasteShortcutDesc: 'Windows / Linux のみ:テキスト挿入時に模擬するショートカット。kitty / alacritty / wezterm / gnome-terminal などのターミナルは Ctrl+Shift+V のみを受け付けます。xterm / urxvt は Shift+Insert。次回のディクテーションから有効。詳細は issue #360。', + pasteShortcutCtrlV: 'Ctrl+V(既定 / ほとんどのアプリ)', + pasteShortcutCtrlShiftV: 'Ctrl+Shift+V(kitty / alacritty / wezterm / ほとんどのターミナル)', + pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)', comboRecordLabel: 'ショートカットを記録', comboRecordDesc: 'クリック後、希望するキーの組み合わせ(例:⌘⇧D)を押してください。トグル / 押し続けの両方に対応。', comboRecordBtn: 'ショートカットを記録', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 9518e2af..9c0ee61e 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -315,6 +315,11 @@ export const ko: typeof zhCN = { muteDuringRecordingDesc: '음성 입력 시작 시 시스템 출력을 일시적으로 음소거하고, 정지/취소/오류 후 원래 음소거 상태를 복원합니다. 스피커 소리가 마이크에 들어가지 않도록 합니다.', restoreClipboardLabel: '입력 후 클립보드 복원', restoreClipboardDesc: 'Windows / Linux 만: 붙여넣기 성공 후 원래 클립보드 내용을 복원합니다(기본 ON). OFF 시 받아쓰기 텍스트가 클립보드에 남아 붙여넣기가 실패한 경우 수동으로 Ctrl+V 할 수 있습니다. issue #111 참조.', + pasteShortcutLabel: '붙여넣기 단축키', + pasteShortcutDesc: 'Windows / Linux 만: 텍스트 삽입 시 시뮬레이션할 단축키. kitty / alacritty / wezterm / gnome-terminal 같은 터미널은 Ctrl+Shift+V 만 받습니다. xterm / urxvt 는 Shift+Insert. 다음 받아쓰기부터 적용. issue #360 참조.', + pasteShortcutCtrlV: 'Ctrl+V (기본 / 대부분 앱)', + pasteShortcutCtrlShiftV: 'Ctrl+Shift+V (kitty / alacritty / wezterm / 대부분 터미널)', + pasteShortcutShiftInsert: 'Shift+Insert (xterm / urxvt)', comboRecordLabel: '단축키 녹화', comboRecordDesc: '클릭 후 원하는 단축키 조합(예: ⌘⇧D)을 누르세요. 토글 및 누르기 모드 모두 지원합니다.', comboRecordBtn: '단축키 녹화', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 265449a2..761d1453 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -311,6 +311,11 @@ export const zhCN = { muteDuringRecordingDesc: '开始语音输入时临时静音系统输出,停止、取消或出错后恢复原来的静音状态,避免扬声器声音被麦克风收进去。', restoreClipboardLabel: '插入后恢复剪贴板', restoreClipboardDesc: '仅 Windows / Linux:粘贴成功后恢复你原来的剪贴板内容(默认开)。关掉就把听写文本留在剪贴板,模拟粘贴没真正落地时可以手动 Ctrl+V 找回。详见 issue #111。', + pasteShortcutLabel: '模拟粘贴快捷键', + pasteShortcutDesc: '仅 Windows / Linux:插入文本时模拟按下的粘贴键。kitty / alacritty / wezterm / gnome-terminal 等终端只接受 Ctrl+Shift+V;xterm / urxvt 用 Shift+Insert。改完下一次听写即生效。详见 issue #360。', + pasteShortcutCtrlV: 'Ctrl+V(默认 / 多数应用)', + pasteShortcutCtrlShiftV: 'Ctrl+Shift+V(kitty / alacritty / wezterm / 多数终端)', + pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)', comboRecordLabel: '录制快捷键', comboRecordDesc: '点击后按下你想要的快捷键组合(如 ⌘⇧D),支持 Toggle 和 Hold 模式。', comboRecordBtn: '录制快捷键', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 21dc2d80..bf574c5e 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -320,6 +320,11 @@ export const zhTW: typeof zhCN = { muteDuringRecordingDesc: '開始語音輸入時臨時靜音系統輸出,停止、取消或出錯後恢復原來的靜音狀態,避免揚聲器聲音被麥克風收進去。', restoreClipboardLabel: '插入後恢復剪貼板', restoreClipboardDesc: '僅 Windows / Linux:粘貼成功後恢復你原來的剪貼板內容(默認開)。關掉就把聽寫文本留在剪貼板,模擬粘貼沒真正落地時可以手動 Ctrl+V 找回。詳見 issue #111。', + pasteShortcutLabel: '模擬粘貼快捷鍵', + pasteShortcutDesc: '僅 Windows / Linux:插入文本時模擬按下的粘貼鍵。kitty / alacritty / wezterm / gnome-terminal 等終端只接受 Ctrl+Shift+V;xterm / urxvt 用 Shift+Insert。改完下一次聽寫即生效。詳見 issue #360。', + pasteShortcutCtrlV: 'Ctrl+V(默認 / 多數應用)', + pasteShortcutCtrlShiftV: 'Ctrl+Shift+V(kitty / alacritty / wezterm / 多數終端)', + pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)', allowNonTsfFallbackLabel: '允許非 TSF 兜底', allowNonTsfFallbackDesc: '僅 Windows:TSF 直接上屏失敗後,允許改用 Unicode SendInput、快捷鍵粘貼或 WM_PASTE。關閉後可驗證是否真實使用 TSF 輸入。', historyRetentionLabel: '歷史保留天數', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 96adf2e7..64aaa3dc 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -55,6 +55,7 @@ const mockSettings: UserPreferences = { activeAsrProvider: 'foundry-local-whisper', activeLlmProvider: 'ark', restoreClipboardAfterPaste: true, + pasteShortcut: 'ctrlV', allowNonTsfInsertionFallback: true, workingLanguages: ['简体中文'], translationTargetLanguage: '', diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 262e31a3..0604e071 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -22,6 +22,7 @@ const previousPrefs: UserPreferences = { activeAsrProvider: 'volcengine', activeLlmProvider: 'ark', restoreClipboardAfterPaste: true, + pasteShortcut: 'ctrlV', allowNonTsfInsertionFallback: true, workingLanguages: ['简体中文'], translationTargetLanguage: '', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index b946ff0e..f3b63f53 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -102,6 +102,13 @@ export type QaHotkeyBinding = ShortcutBinding; /** 自定义录音组合键绑定。当 hotkey.trigger == 'custom' 时使用。 */ export type ComboBinding = ShortcutBinding; +/** 模拟粘贴时按下的快捷键。仅 Windows/Linux 生效;macOS 走 AX 直写。 + * - ctrlV : 标准粘贴(默认;大多数编辑器、浏览器、IDE) + * - ctrlShiftV : kitty / alacritty / wezterm / gnome-terminal / foot 等终端 + * - shiftInsert : xterm / urxvt 等老派 X11 终端 + * 详见 issue #360。 */ +export type PasteShortcut = 'ctrlV' | 'ctrlShiftV' | 'shiftInsert'; + export type WindowsImeInstallState = | 'installed' | 'notInstalled' @@ -130,6 +137,10 @@ export interface UserPreferences { activeLlmProvider: string; /** 仅 Windows/Linux:粘贴成功后是否恢复用户原剪贴板。默认 true。详见 issue #111。 */ restoreClipboardAfterPaste: boolean; + /** 仅 Windows/Linux:模拟粘贴时按下的快捷键。详见 issue #360:kitty/alacritty + * 等终端只接受 Ctrl+Shift+V,硬编码 Ctrl+V 会被吞掉,听写文本只剩在剪贴板里。 + * macOS 走 AX 直写不受影响。默认 'ctrlV' 与历史行为一致。 */ + pasteShortcut: PasteShortcut; /** Windows:TSF 失败后是否允许 SendInput / 粘贴类非 TSF 兜底。关闭后可验证是否真实 TSF 上屏。 */ allowNonTsfInsertionFallback: boolean; /** 用户的工作语言(多选,原生名);作为前提注入 LLM polish/translate prompt 头部。 */ diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 95f68e60..cbe1e876 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -49,6 +49,7 @@ import type { HotkeyStatus, HotkeyTrigger, MicrophoneDevice, + PasteShortcut, PermissionStatus, WindowsImeStatus, } from '../lib/types'; @@ -268,6 +269,8 @@ function RecordingSection() { savePrefs({ ...prefs, microphoneDeviceName }); const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) => savePrefs({ ...prefs, restoreClipboardAfterPaste }); + const onPasteShortcutChange = (pasteShortcut: PasteShortcut) => + savePrefs({ ...prefs, pasteShortcut }); const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) => savePrefs({ ...prefs, allowNonTsfInsertionFallback }); // 历史保留 / 对话感知 polish 上下文窗口都用裸 number input;空字符串时回滚到默认值。 @@ -434,6 +437,22 @@ function RecordingSection() { > + {capability.adapter !== 'macEventTap' && ( + + + + )} {capability.adapter === 'windowsLowLevel' && ( Date: Sat, 9 May 2026 13:24:14 +0800 Subject: [PATCH 02/50] fix(insertion): use slice index instead of TakeWhile::rev for partial release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TakeWhile` 不实现 `DoubleEndedIterator`,所以 `.take_while(..).rev()` 在 Linux target 上编译不过(macOS host 因为整段 cfg(not(macos)) 没编译,所以 之前 cargo check 没暴露)。改用 `modifiers[..pressed].iter().rev()`:切片的 Iter 实现 DoubleEndedIterator,可以安全 `.rev()`。 顺手简化错误恢复路径:用一个 `first_err` 累加器,press → click → release 三段任何一段先报错都先记下来,最后一次性返回。语义跟原版 simulate_paste 对齐,不留卡键。 cargo test --lib 仍然 181 passed。 --- openless-all/app/src-tauri/src/insertion.rs | 39 ++++++++++++--------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index e3285ed6..b48ece86 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -339,32 +339,39 @@ fn simulate_paste(shortcut: PasteShortcut) -> Result<(), String> { let (modifiers, primary) = paste_keys(shortcut); let mut enigo = Enigo::new(&Settings::default()).map_err(|e| e.to_string())?; - // 按下顺序:所有 modifier → 主键点击; - // 释放顺序:modifier 反向释放,确保即使中途出错也尽量把按键状态还原回来。 + // 跟原版 simulate_paste 保持同一行为:按下 modifier → 点击主键 → 反向释放 modifier。 + // 任何中途失败都尽量把已经按下的 modifier 反向释放回来,避免卡键。`pressed` + // 记录已经成功按下的 modifier 数;用切片 `modifiers[..pressed]` 控制释放范围 + // —— 切片自带 DoubleEndedIterator,可以放心 `.rev()`。 + let mut pressed = 0usize; + let mut first_err: Option = None; + for modifier in &modifiers { if let Err(e) = enigo.key(*modifier, Direction::Press) { - // 按下失败时反向释放已经按下的 modifier,避免卡键。 - for already_pressed in modifiers.iter().take_while(|m| *m != modifier).rev() { - let _ = enigo.key(*already_pressed, Direction::Release); - } - return Err(e.to_string()); + first_err = Some(e.to_string()); + break; + } + pressed += 1; + } + + if first_err.is_none() { + if let Err(e) = enigo.key(primary, Direction::Click) { + first_err = Some(e.to_string()); } } - let click_result = enigo.key(primary, Direction::Click); - - let mut release_err: Option = None; - for modifier in modifiers.iter().rev() { + for modifier in modifiers[..pressed].iter().rev() { if let Err(e) = enigo.key(*modifier, Direction::Release) { - release_err.get_or_insert_with(|| e.to_string()); + if first_err.is_none() { + first_err = Some(e.to_string()); + } } } - click_result.map_err(|e| e.to_string())?; - if let Some(err) = release_err { - return Err(err); + match first_err { + Some(err) => Err(err), + None => Ok(()), } - Ok(()) } #[cfg(target_os = "macos")] From af2048e5cadf085f2964929d18d71cf8bd674b65 Mon Sep 17 00:00:00 2001 From: weikeyi Date: Sat, 9 May 2026 16:15:47 +0800 Subject: [PATCH 03/50] fix(windows): map LeftOption to left Alt --- openless-all/app/src-tauri/src/coordinator.rs | 6 +- openless-all/app/src-tauri/src/hotkey.rs | 56 ++++++++++++++++--- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 16944c47..64f75a82 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1893,7 +1893,7 @@ fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, key: &str, c HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => { (key == "Alt" || key == "AltGraph") && code == "AltRight" } - HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltRight", + HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltLeft", HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight", HotkeyTrigger::Fn => key == "Control" && code == "ControlRight", // Custom 走 global-hotkey crate,不走 window hotkey fallback @@ -4033,8 +4033,8 @@ mod tests { (HotkeyTrigger::RightOption, "Alt", "AltRight"), (HotkeyTrigger::RightAlt, "AltGraph", "AltRight"), (HotkeyTrigger::RightCommand, "Meta", "MetaRight"), + (HotkeyTrigger::LeftOption, "Alt", "AltLeft"), // Mirrors Windows trigger_to_vk_code aliases. - (HotkeyTrigger::LeftOption, "Alt", "AltRight"), (HotkeyTrigger::Fn, "Control", "ControlRight"), ]; for (trigger, key, code) in cases { @@ -4052,7 +4052,7 @@ mod tests { assert!(!window_key_matches_trigger( HotkeyTrigger::LeftOption, "Alt", - "AltLeft" + "AltRight" )); assert!(!window_key_matches_trigger(HotkeyTrigger::Fn, "Fn", "Fn")); } diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 6babad78..663fe889 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -716,6 +716,7 @@ mod platform { 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 LLKHF_INJECTED: u32 = 0x0000_0010; @@ -966,18 +967,15 @@ mod platform { } 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 + // Windows 低层 hook 能区分左右 Alt,LeftOption / RightOption 必须保留物理侧。 + // 其他少量跨平台别名仍按 Windows 可用物理键折叠: + // - Fn 复用 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::LeftOption => VK_LMENU, HotkeyTrigger::Fn => VK_RCONTROL, HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"), } @@ -1072,7 +1070,7 @@ mod platform { dispatch_keyboard_event(&ctx, VK_RWIN, WM_KEYDOWN); dispatch_keyboard_event(&ctx, VK_RWIN, WM_KEYDOWN); - dispatch_keyboard_event(&ctx, VK_RMENU, WM_KEYDOWN); + dispatch_keyboard_event(&ctx, VK_LMENU, WM_KEYDOWN); dispatch_keyboard_event(&ctx, VK_LSHIFT, WM_KEYDOWN); dispatch_keyboard_event(&ctx, VK_LSHIFT, WM_KEYDOWN); dispatch_keyboard_event(&ctx, VK_RWIN, WM_KEYUP); @@ -1088,6 +1086,48 @@ mod platform { ] ); } + + #[test] + fn windows_option_triggers_keep_left_and_right_alt_separate() { + let left_shared = shared(HotkeyTrigger::LeftOption); + let (left_ctx, left_rx) = callback_context(left_shared); + + assert!(!dispatch_keyboard_event(&left_ctx, VK_RMENU, WM_KEYDOWN)); + assert!(dispatch_keyboard_event(&left_ctx, VK_LMENU, WM_KEYDOWN)); + assert!(dispatch_keyboard_event(&left_ctx, VK_LMENU, WM_KEYUP)); + assert_eq!( + drain(&left_rx), + vec![HotkeyEvent::Pressed, HotkeyEvent::Released] + ); + + let right_option_shared = shared(HotkeyTrigger::RightOption); + let (right_option_ctx, right_option_rx) = callback_context(right_option_shared); + assert!(!dispatch_keyboard_event( + &right_option_ctx, + VK_LMENU, + WM_KEYDOWN + )); + assert!(dispatch_keyboard_event( + &right_option_ctx, + VK_RMENU, + WM_KEYDOWN + )); + assert_eq!(drain(&right_option_rx), vec![HotkeyEvent::Pressed]); + + let right_alt_shared = shared(HotkeyTrigger::RightAlt); + let (right_alt_ctx, right_alt_rx) = callback_context(right_alt_shared); + assert!(!dispatch_keyboard_event( + &right_alt_ctx, + VK_LMENU, + WM_KEYDOWN + )); + assert!(dispatch_keyboard_event( + &right_alt_ctx, + VK_RMENU, + WM_KEYDOWN + )); + assert_eq!(drain(&right_alt_rx), vec![HotkeyEvent::Pressed]); + } } } From 941542c6e724be244318eeeb58a67a187ae41610 Mon Sep 17 00:00:00 2001 From: weikeyi Date: Sat, 9 May 2026 17:22:21 +0800 Subject: [PATCH 04/50] =?UTF-8?q?chore(release):=20=E5=90=8C=E6=AD=A5=20pa?= =?UTF-8?q?ckage-lock=20=E7=89=88=E6=9C=AC=E5=88=B0=201.2.24-6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openless-all/app/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 9c599b80..2f41530b 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "openless-app", - "version": "1.2.24-5", + "version": "1.2.24-6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "1.2.24-5", + "version": "1.2.24-6", "dependencies": { "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-autostart": "^2.5.1", From 91769e0e4cd0d0a1c230e597d16c80fe14c6d743 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Sat, 9 May 2026 19:27:52 +0800 Subject: [PATCH 05/50] Keep style master toggle from clearing fallback modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Style 页总开关关闭时沿用现有注释语义:保留 raw 和当前 default mode,避免把 enabledModes 持久化为空集合。\n\n将 master toggle 的 off 判定抽到 stylePrefs,确保仅剩 raw/default 时 UI 仍显示为关闭,并用现有手写测试覆盖最终保存值。\n\nConstraint: Issue #312 要求总开关实现与注释一致,并覆盖关闭后的 enabledModes。\nRejected: 保持 enabledModes=[] 仅改注释 | 会让切换风格热键面对空集合,偏离 raw/default 兜底语义。\nConfidence: high\nScope-risk: narrow\nTested: cd openless-all/app && npx tsx src/lib/stylePrefs.test.ts\nTested: cd openless-all/app && npm run build\nNot-tested: Real Tauri UI click path on macOS/Windows.\nRelated: https://github.com/appergb/openless/issues/312\nCo-authored-by: OmX --- openless-all/app/src/lib/stylePrefs.test.ts | 28 +++++++++++++++++++++ openless-all/app/src/lib/stylePrefs.ts | 18 +++++++++++++ openless-all/app/src/pages/Style.tsx | 8 +++--- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 262e31a3..b99b4a2c 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -1,8 +1,11 @@ import { applyStylePreferencesNotification, + isStyleMasterEnabled, persistStylePreferenceChange, rollbackStyleEnabledChange, rollbackWholeStylePreferences, + styleMasterFallbackModes, + styleMasterOffPreferences, } from './stylePrefs'; import type { UserPreferences } from './types'; @@ -114,3 +117,28 @@ const notifiedPrefs: UserPreferences = { }; const syncedPrefs = applyStylePreferencesNotification(previousPrefs, notifiedPrefs); assert(syncedPrefs === notifiedPrefs, 'prefs notification should replace stale style page prefs'); + +const masterOffPrefs = styleMasterOffPreferences(previousPrefs); +assert( + masterOffPrefs.enabledModes.join(',') === 'raw,light', + `master toggle off should persist raw and current default, got ${masterOffPrefs.enabledModes.join(',')}`, +); + +const masterFallback = styleMasterFallbackModes('light'); +assert( + masterFallback.join(',') === 'raw,light', + `master toggle off should preserve raw and current default, got ${masterFallback.join(',')}`, +); +assert( + isStyleMasterEnabled({ ...previousPrefs, enabledModes: masterFallback }) === false, + 'master toggle should render off when only raw and default remain enabled', +); +assert( + isStyleMasterEnabled(previousPrefs) === true, + 'master toggle should render on when extra styles remain enabled', +); +const rawFallback = styleMasterFallbackModes('raw'); +assert( + rawFallback.join(',') === 'raw', + `raw default fallback should not duplicate raw, got ${rawFallback.join(',')}`, +); diff --git a/openless-all/app/src/lib/stylePrefs.ts b/openless-all/app/src/lib/stylePrefs.ts index 0cfc163b..e33f3bcd 100644 --- a/openless-all/app/src/lib/stylePrefs.ts +++ b/openless-all/app/src/lib/stylePrefs.ts @@ -35,6 +35,18 @@ export function applyStylePreferencesNotification( return incoming; } +export function styleMasterFallbackModes(defaultMode: PolishMode): PolishMode[] { + return defaultMode === 'raw' ? ['raw'] : ['raw', defaultMode]; +} + +export function isStyleMasterEnabled(prefs: UserPreferences): boolean { + return !sameModeSet(prefs.enabledModes, styleMasterFallbackModes(prefs.defaultMode)); +} + +export function styleMasterOffPreferences(prefs: UserPreferences): UserPreferences { + return { ...prefs, enabledModes: styleMasterFallbackModes(prefs.defaultMode) }; +} + export function rollbackDefaultModeChange( previousPrefs: UserPreferences, nextPrefs: UserPreferences, @@ -71,6 +83,12 @@ export function rollbackWholeStylePreferences( }; } +function sameModeSet(left: PolishMode[], right: PolishMode[]): boolean { + if (left.length !== right.length) return false; + const rightSet = new Set(right); + return left.every(mode => rightSet.has(mode)); +} + function sameModes(left: PolishMode[], right: PolishMode[]): boolean { if (left.length !== right.length) return false; return left.every((mode, index) => mode === right[index]); diff --git a/openless-all/app/src/pages/Style.tsx b/openless-all/app/src/pages/Style.tsx index 6e868aa5..e06742b6 100644 --- a/openless-all/app/src/pages/Style.tsx +++ b/openless-all/app/src/pages/Style.tsx @@ -7,10 +7,12 @@ import { getSettings, setDefaultPolishMode, setStyleEnabled, setSettings } from import type { PolishMode, UserPreferences } from '../lib/types'; import { applyStylePreferencesNotification, + isStyleMasterEnabled, persistStylePreferenceChange, rollbackDefaultModeChange, rollbackStyleEnabledChange, rollbackWholeStylePreferences, + styleMasterOffPreferences, } from '../lib/stylePrefs'; import { PageHeader, Pill } from './_atoms'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; @@ -112,13 +114,13 @@ export function Style() { ); } - const masterEnabled = prefs.enabledModes.length > 0; + const masterEnabled = isStyleMasterEnabled(prefs); const onMasterToggle = async () => { if (!prefs) return; if (masterEnabled) { - // 全部关闭 → 留 raw 和当前 default 兜底 - const next = { ...prefs, enabledModes: [] as PolishMode[] }; + // 全部关闭 → 留 raw 和当前 default 兜底,避免持久化空集合。 + const next = styleMasterOffPreferences(prefs); const saved = await persistStylePreferenceChange( next, () => setSettings(next), From 6341f19dec945e4802d901890f4b3855cea41a39 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Sat, 9 May 2026 20:31:40 +0800 Subject: [PATCH 06/50] Keep style master off state stable across default changes Review noted that changing defaultMode after turning Style master off made the switch look on again because enabledModes still reflected the old fallback pair.\n\nWhen master is already off, default-mode changes now persist the whole style preference set through setSettings so enabledModes is recomputed to raw plus the new default. Normal enabled master behavior still uses the narrower setDefaultPolishMode IPC.\n\nConstraint: Issue #312 selected raw/default fallback semantics for master-off state.\nRejected: Add a separate persisted masterEnabled flag | broader schema and migration change for a narrow UI consistency bug.\nConfidence: high\nScope-risk: narrow\nTested: cd openless-all/app && npx tsx src/lib/stylePrefs.test.ts\nTested: cd openless-all/app && npm run build\nNot-tested: Manual Style page click flow in packaged Tauri app.\nRelated: https://github.com/appergb/openless/pull/383\nCo-authored-by: OmX --- openless-all/app/src/lib/stylePrefs.test.ts | 27 +++++++++++++++++++++ openless-all/app/src/lib/stylePrefs.ts | 25 +++++++++++++++++++ openless-all/app/src/pages/Style.tsx | 11 ++++++--- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index b99b4a2c..621da4e3 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -1,9 +1,11 @@ import { applyStylePreferencesNotification, isStyleMasterEnabled, + rollbackDefaultAndEnabledChange, persistStylePreferenceChange, rollbackStyleEnabledChange, rollbackWholeStylePreferences, + styleDefaultModePreferences, styleMasterFallbackModes, styleMasterOffPreferences, } from './stylePrefs'; @@ -142,3 +144,28 @@ assert( rawFallback.join(',') === 'raw', `raw default fallback should not duplicate raw, got ${rawFallback.join(',')}`, ); + + +const defaultAfterMasterOff = styleDefaultModePreferences( + { ...previousPrefs, enabledModes: ['raw', 'light'] }, + 'formal', +); +assert( + defaultAfterMasterOff.defaultMode === 'formal' && defaultAfterMasterOff.enabledModes.join(',') === 'raw,formal', + `default change while master is off should refresh fallback modes, got ${defaultAfterMasterOff.defaultMode}/${defaultAfterMasterOff.enabledModes.join(',')}`, +); +assert( + isStyleMasterEnabled(defaultAfterMasterOff) === false, + 'master toggle should stay off after changing default while off', +); + +let rolledBackDefaultAndEnabled: UserPreferences | null = defaultAfterMasterOff; +const rollbackDefaultAndEnabled = rollbackDefaultAndEnabledChange( + { ...previousPrefs, enabledModes: ['raw', 'light'] }, + defaultAfterMasterOff, +); +rolledBackDefaultAndEnabled = rollbackDefaultAndEnabled(rolledBackDefaultAndEnabled); +assert( + rolledBackDefaultAndEnabled?.defaultMode === 'light' && rolledBackDefaultAndEnabled.enabledModes.join(',') === 'raw,light', + 'failed off-state default save should roll back both default mode and enabled modes', +); diff --git a/openless-all/app/src/lib/stylePrefs.ts b/openless-all/app/src/lib/stylePrefs.ts index e33f3bcd..bec0a844 100644 --- a/openless-all/app/src/lib/stylePrefs.ts +++ b/openless-all/app/src/lib/stylePrefs.ts @@ -47,6 +47,11 @@ export function styleMasterOffPreferences(prefs: UserPreferences): UserPreferenc return { ...prefs, enabledModes: styleMasterFallbackModes(prefs.defaultMode) }; } +export function styleDefaultModePreferences(prefs: UserPreferences, mode: PolishMode): UserPreferences { + const nextPrefs = { ...prefs, defaultMode: mode }; + return isStyleMasterEnabled(prefs) ? nextPrefs : styleMasterOffPreferences(nextPrefs); +} + export function rollbackDefaultModeChange( previousPrefs: UserPreferences, nextPrefs: UserPreferences, @@ -83,6 +88,26 @@ export function rollbackWholeStylePreferences( }; } +export function rollbackDefaultAndEnabledChange( + previousPrefs: UserPreferences, + nextPrefs: UserPreferences, +): StylePrefsRollback { + return current => { + if ( + !current || + current.defaultMode !== nextPrefs.defaultMode || + !sameModes(current.enabledModes, nextPrefs.enabledModes) + ) { + return current; + } + return { + ...current, + defaultMode: previousPrefs.defaultMode, + enabledModes: previousPrefs.enabledModes, + }; + }; +} + function sameModeSet(left: PolishMode[], right: PolishMode[]): boolean { if (left.length !== right.length) return false; const rightSet = new Set(right); diff --git a/openless-all/app/src/pages/Style.tsx b/openless-all/app/src/pages/Style.tsx index e06742b6..20602118 100644 --- a/openless-all/app/src/pages/Style.tsx +++ b/openless-all/app/src/pages/Style.tsx @@ -9,9 +9,11 @@ import { applyStylePreferencesNotification, isStyleMasterEnabled, persistStylePreferenceChange, + rollbackDefaultAndEnabledChange, rollbackDefaultModeChange, rollbackStyleEnabledChange, rollbackWholeStylePreferences, + styleDefaultModePreferences, styleMasterOffPreferences, } from '../lib/stylePrefs'; import { PageHeader, Pill } from './_atoms'; @@ -76,13 +78,16 @@ export function Style() { const onPickDefault = async (mode: PolishMode) => { if (!prefs) return; - const next = { ...prefs, defaultMode: mode }; + const masterWasEnabled = isStyleMasterEnabled(prefs); + const next = styleDefaultModePreferences(prefs, mode); const saved = await persistStylePreferenceChange( next, - () => setDefaultPolishMode(mode), + () => (masterWasEnabled ? setDefaultPolishMode(mode) : setSettings(next)), setPrefs, error => showSaveError(mode, error), - rollbackDefaultModeChange(prefs, next), + masterWasEnabled + ? rollbackDefaultModeChange(prefs, next) + : rollbackDefaultAndEnabledChange(prefs, next), ); if (saved) setSaveError(null); }; From 9551786cae12223822945fb4d07cdd8bb07bb2d0 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Sat, 9 May 2026 21:23:53 +0800 Subject: [PATCH 07/50] Enable DashScope realtime ASR without changing recorder flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 百炼传统实时识别协议可以直接消费 OpenLess 已有的 16 kHz mono PCM,所以新增独立 ASR provider 并复用现有 DeferredAsrBridge / coordinator 会话状态机,而不是引入另一条录音链路。Settings 侧增加百炼选项、默认北京 endpoint、默认 fun-asr-realtime 模型,并允许填写可选 vocabulary_id 下发已创建的百炼热词表。 Constraint: issue #384 要求接入百炼 DashScope 实时 ASR,并保持北京/新加坡 endpoint 可配置。 Constraint: 凭据仍必须走系统凭据库,新增 asr.vocabulary_id 只作为 active ASR provider 的可选 provider 设置。 Rejected: 同时实现 qwen3-asr-flash OpenAI Realtime 协议 | 协议和结果事件不同,超出最小更改范围。 Rejected: 自动从本地词汇表创建百炼热词表 | 需要远端资源生命周期和用户确认,不能静默替用户管理供应商资源。 Confidence: high Scope-risk: moderate Directive: 百炼 provider 当前走传统 /api-ws/v1/inference 协议;不要把 qwen realtime 事件格式混进同一实现。 Tested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib Tested: cargo test --manifest-path openless-all/app/src-tauri/backend-tests/Cargo.toml Tested: cd openless-all/app && npm run build Not-tested: 需要真实 DashScope API Key 和麦克风环境才能完成线上 macOS dictation 验证。 Related: #384 Co-authored-by: OmX --- openless-all/app/src-tauri/src/asr/bailian.rs | 588 ++++++++++++++++++ openless-all/app/src-tauri/src/asr/mod.rs | 2 + openless-all/app/src-tauri/src/commands.rs | 59 ++ openless-all/app/src-tauri/src/coordinator.rs | 38 +- .../src-tauri/src/coordinator/dictation.rs | 116 +++- .../src-tauri/src/coordinator/resources.rs | 1 + openless-all/app/src-tauri/src/persistence.rs | 16 +- openless-all/app/src/i18n/en.ts | 3 + openless-all/app/src/i18n/ja.ts | 3 + openless-all/app/src/i18n/ko.ts | 3 + openless-all/app/src/i18n/zh-CN.ts | 3 + openless-all/app/src/i18n/zh-TW.ts | 3 + openless-all/app/src/pages/Overview.tsx | 1 + openless-all/app/src/pages/Settings.tsx | 20 +- 14 files changed, 848 insertions(+), 8 deletions(-) create mode 100644 openless-all/app/src-tauri/src/asr/bailian.rs diff --git a/openless-all/app/src-tauri/src/asr/bailian.rs b/openless-all/app/src-tauri/src/asr/bailian.rs new file mode 100644 index 00000000..bf7ef454 --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/bailian.rs @@ -0,0 +1,588 @@ +//! Alibaba Cloud Bailian / DashScope realtime ASR client. +//! +//! Uses the classic DashScope realtime recognition WebSocket protocol +//! (`/api-ws/v1/inference`) because it accepts raw 16 kHz mono PCM frames and +//! matches OpenLess' recorder output directly. The Qwen OpenAI Realtime line is +//! a different protocol and is intentionally left for a follow-up provider. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use futures_util::{SinkExt, StreamExt}; +use parking_lot::Mutex as ParkingMutex; +use serde_json::{json, Value}; +use tokio::net::TcpStream; +use tokio::runtime::Handle; +use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex, Notify}; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::header::HeaderValue; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; +use uuid::Uuid; + +use super::{AudioConsumer, RawTranscript}; + +pub const PROVIDER_ID: &str = "bailian"; +pub const DEFAULT_ENDPOINT: &str = "wss://dashscope.aliyuncs.com/api-ws/v1/inference/"; +pub const DEFAULT_MODEL: &str = "fun-asr-realtime"; + +/// 100 ms of 16 kHz / 16-bit / mono PCM. +pub const TARGET_AUDIO_CHUNK_BYTES: usize = 3_200; +const BYTES_PER_MS: u64 = 32; +const FINAL_RESULT_TIMEOUT: Duration = Duration::from_secs(12); + +type WsStream = WebSocketStream>; +type WsSink = futures_util::stream::SplitSink; +type SharedWriter = Arc>>; + +#[derive(Clone, Debug)] +pub struct BailianCredentials { + pub api_key: String, + pub endpoint: String, + pub model: String, + pub vocabulary_id: Option, +} + +impl BailianCredentials { + pub fn normalized_endpoint(&self) -> String { + if self.endpoint.trim().is_empty() { + return DEFAULT_ENDPOINT.to_string(); + } + self.endpoint.trim().to_string() + } + + pub fn normalized_model(&self) -> String { + let model = self.model.trim(); + if model.is_empty() { + DEFAULT_MODEL.to_string() + } else { + model.to_string() + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum BailianASRError { + #[error("credentials missing")] + CredentialsMissing, + #[error("connection failed: {0}")] + ConnectionFailed(String), + #[error("send failed: {0}")] + SendFailed(String), + #[error("task failed: {0}")] + TaskFailed(String), + #[error("no final result")] + NoFinalResult, + #[error("final result timed out")] + FinalResultTimeout, +} + +enum SendItem { + Audio(Vec), + Finish(oneshot::Sender>), +} + +#[derive(Default)] +struct SyncState { + task_id: String, + pending_audio: Vec, + audio_scratch: Vec, + bytes_received: u64, + task_started: bool, + task_finished: bool, + runtime: Option, + start: Option, + final_tx: Option>>, + send_tx: Option>, + final_segments: Vec, + last_result_text: String, +} + +pub struct BailianRealtimeASR { + credentials: BailianCredentials, + state: ParkingMutex, + writer: SharedWriter, + final_rx: ParkingMutex>>>, + task_started: Arc, +} + +impl BailianRealtimeASR { + pub fn new(credentials: BailianCredentials) -> Self { + Self { + credentials, + state: ParkingMutex::new(SyncState::default()), + writer: Arc::new(AsyncMutex::new(None)), + final_rx: ParkingMutex::new(None), + task_started: Arc::new(Notify::new()), + } + } + + pub async fn open_session(self: &Arc) -> Result<(), BailianASRError> { + if self.credentials.api_key.trim().is_empty() { + return Err(BailianASRError::CredentialsMissing); + } + + let task_id = Uuid::new_v4().simple().to_string(); + let endpoint = self.credentials.normalized_endpoint(); + let mut request = endpoint + .into_client_request() + .map_err(|e| BailianASRError::ConnectionFailed(e.to_string()))?; + request.headers_mut().insert( + "Authorization", + HeaderValue::from_str(&format!("bearer {}", self.credentials.api_key.trim())) + .map_err(|e| BailianASRError::ConnectionFailed(e.to_string()))?, + ); + + let (ws, _resp) = connect_async(request) + .await + .map_err(|e| BailianASRError::ConnectionFailed(e.to_string()))?; + let (write, read) = ws.split(); + *self.writer.lock().await = Some(write); + + let (final_tx, final_rx) = oneshot::channel(); + let (send_tx, mut send_rx) = mpsc::unbounded_channel::(); + { + let mut st = self.state.lock(); + *st = SyncState::default(); + st.task_id = task_id.clone(); + st.runtime = Some(Handle::current()); + st.start = Some(Instant::now()); + st.final_tx = Some(final_tx); + st.send_tx = Some(send_tx); + } + *self.final_rx.lock() = Some(final_rx); + + let writer_for_worker = Arc::clone(&self.writer); + let task_id_for_worker = task_id.clone(); + tokio::spawn(async move { + while let Some(item) = send_rx.recv().await { + match item { + SendItem::Audio(chunk) => { + if let Err(e) = send_binary(&writer_for_worker, chunk).await { + log::error!("[bailian-asr] audio frame send failed: {e}"); + } + } + SendItem::Finish(done) => { + let result = + send_text(&writer_for_worker, finish_task_message(&task_id_for_worker)) + .await + .map_err(|e| BailianASRError::SendFailed(e.to_string())); + let _ = done.send(result); + } + } + } + }); + + send_text( + &self.writer, + run_task_message( + &task_id, + &self.credentials.normalized_model(), + self.credentials.vocabulary_id.as_deref(), + ), + ) + .await?; + + let weak_self = Arc::downgrade(self); + tokio::spawn(async move { + let mut read = read; + while let Some(msg) = read.next().await { + let Some(this) = weak_self.upgrade() else { + break; + }; + match msg { + Ok(Message::Text(text)) => { + if !this.handle_text_message(&text) { + break; + } + } + Ok(Message::Close(_)) => { + this.finish_with_partial_or_error(BailianASRError::NoFinalResult); + break; + } + Ok(_) => {} + Err(e) => { + log::error!("[bailian-asr] receive loop error: {e}"); + this.finish_with_partial_or_error(BailianASRError::ConnectionFailed( + e.to_string(), + )); + break; + } + } + } + }); + + Ok(()) + } + + pub async fn send_last_frame(&self) -> Result<(), BailianASRError> { + let started = { + let st = self.state.lock(); + st.task_started || st.task_finished + }; + if !started { + tokio::time::timeout(Duration::from_secs(5), self.task_started.notified()) + .await + .map_err(|_| BailianASRError::FinalResultTimeout)?; + } + let (send_tx, tail_chunks) = { + let mut st = self.state.lock(); + let send_tx = st.send_tx.clone(); + if !st.pending_audio.is_empty() { + let pending = std::mem::take(&mut st.pending_audio); + st.audio_scratch.extend_from_slice(&pending); + } + let tail = if st.audio_scratch.is_empty() { + Vec::new() + } else { + vec![std::mem::take(&mut st.audio_scratch)] + }; + (send_tx, tail) + }; + let Some(send_tx) = send_tx else { + return Ok(()); + }; + for chunk in tail_chunks { + let _ = send_tx.send(SendItem::Audio(chunk)); + } + let (done_tx, done_rx) = oneshot::channel(); + send_tx + .send(SendItem::Finish(done_tx)) + .map_err(|_| BailianASRError::SendFailed("send worker closed".to_string()))?; + done_rx + .await + .map_err(|_| BailianASRError::SendFailed("finish ack dropped".to_string()))? + } + + pub async fn await_final_result(&self) -> Result { + let rx = self.final_rx.lock().take(); + let Some(rx) = rx else { + return Err(BailianASRError::NoFinalResult); + }; + tokio::time::timeout(FINAL_RESULT_TIMEOUT, rx) + .await + .map_err(|_| BailianASRError::FinalResultTimeout)? + .map_err(|_| BailianASRError::NoFinalResult)? + } + + pub fn cancel(&self) { + let mut st = self.state.lock(); + st.pending_audio.clear(); + st.audio_scratch.clear(); + st.send_tx.take(); + st.final_tx.take(); + st.task_finished = true; + drop(st); + let writer = Arc::clone(&self.writer); + if let Ok(handle) = Handle::try_current() { + handle.spawn(async move { + let _ = close_writer(&writer).await; + }); + } else { + std::thread::spawn(move || { + if let Ok(rt) = tokio::runtime::Runtime::new() { + rt.block_on(async move { + let _ = close_writer(&writer).await; + }); + } + }); + } + } + + fn handle_text_message(&self, text: &str) -> bool { + let value: Value = match serde_json::from_str(text) { + Ok(v) => v, + Err(e) => { + log::warn!("[bailian-asr] invalid json event: {e}"); + return true; + } + }; + let event = value + .get("header") + .and_then(|h| h.get("event")) + .and_then(Value::as_str) + .unwrap_or_default(); + match event { + "task-started" => { + self.mark_task_started(); + true + } + "result-generated" => { + self.record_result(&value); + true + } + "task-finished" => { + self.finish_success(); + false + } + "task-failed" => { + let message = value + .get("header") + .and_then(|h| h.get("error_message")) + .and_then(Value::as_str) + .unwrap_or("task failed") + .to_string(); + self.finish_error(BailianASRError::TaskFailed(message)); + false + } + _ => true, + } + } + + fn mark_task_started(&self) { + let (send_tx, chunks) = { + let mut st = self.state.lock(); + st.task_started = true; + if !st.pending_audio.is_empty() { + let pending = std::mem::take(&mut st.pending_audio); + st.audio_scratch.extend_from_slice(&pending); + } + let send_tx = st.send_tx.clone(); + let chunks = drain_audio_chunks(&mut st.audio_scratch); + (send_tx, chunks) + }; + if let Some(tx) = send_tx { + for chunk in chunks { + let _ = tx.send(SendItem::Audio(chunk)); + } + } + self.task_started.notify_waiters(); + } + + fn record_result(&self, value: &Value) { + let sentence = value + .get("payload") + .and_then(|p| p.get("output")) + .and_then(|o| o.get("sentence")); + let Some(sentence) = sentence else { + return; + }; + let Some(text) = sentence.get("text").and_then(Value::as_str) else { + return; + }; + let trimmed = text.trim(); + if trimmed.is_empty() { + return; + } + let is_sentence_final = sentence.get("end_time").is_some(); + let mut st = self.state.lock(); + st.last_result_text = trimmed.to_string(); + if is_sentence_final && st.final_segments.last().map(|s| s.as_str()) != Some(trimmed) { + st.final_segments.push(trimmed.to_string()); + } + } + + fn finish_success(&self) { + let (tx, text, duration_ms) = { + let mut st = self.state.lock(); + if st.task_finished { + return; + } + st.task_finished = true; + st.send_tx.take(); + let text = if st.final_segments.is_empty() { + st.last_result_text.clone() + } else { + st.final_segments.join("") + }; + let duration_ms = if st.bytes_received > 0 { + st.bytes_received / BYTES_PER_MS + } else { + st.start + .map(|start| start.elapsed().as_millis() as u64) + .unwrap_or_default() + }; + (st.final_tx.take(), text, duration_ms) + }; + if let Some(tx) = tx { + let _ = tx.send(Ok(RawTranscript { text, duration_ms })); + } + self.close_on_runtime(); + } + + fn finish_with_partial_or_error(&self, error: BailianASRError) { + let has_partial = { + let st = self.state.lock(); + !st.last_result_text.trim().is_empty() || !st.final_segments.is_empty() + }; + if has_partial { + self.finish_success(); + } else { + self.finish_error(error); + } + } + + fn finish_error(&self, error: BailianASRError) { + let tx = { + let mut st = self.state.lock(); + if st.task_finished { + return; + } + st.task_finished = true; + st.send_tx.take(); + st.final_tx.take() + }; + if let Some(tx) = tx { + let _ = tx.send(Err(error)); + } + self.close_on_runtime(); + } + + fn close_on_runtime(&self) { + let writer = Arc::clone(&self.writer); + if let Some(handle) = self.state.lock().runtime.clone() { + handle.spawn(async move { + let _ = close_writer(&writer).await; + }); + } + } +} + +impl AudioConsumer for BailianRealtimeASR { + fn consume_pcm_chunk(&self, pcm: &[u8]) { + if pcm.is_empty() { + return; + } + let (send_tx, chunks) = { + let mut st = self.state.lock(); + st.bytes_received = st.bytes_received.saturating_add(pcm.len() as u64); + if !st.task_started { + st.pending_audio.extend_from_slice(pcm); + return; + } + st.audio_scratch.extend_from_slice(pcm); + let chunks = drain_audio_chunks(&mut st.audio_scratch); + (st.send_tx.clone(), chunks) + }; + if let Some(tx) = send_tx { + for chunk in chunks { + let _ = tx.send(SendItem::Audio(chunk)); + } + } + } +} + +fn drain_audio_chunks(buffer: &mut Vec) -> Vec> { + let mut chunks = Vec::new(); + while buffer.len() >= TARGET_AUDIO_CHUNK_BYTES { + chunks.push(buffer.drain(..TARGET_AUDIO_CHUNK_BYTES).collect()); + } + chunks +} + +fn run_task_message(task_id: &str, model: &str, vocabulary_id: Option<&str>) -> String { + let mut parameters = json!({ + "sample_rate": 16000, + "format": "pcm" + }); + if let Some(vocabulary_id) = vocabulary_id.map(str::trim).filter(|id| !id.is_empty()) { + parameters["vocabulary_id"] = Value::String(vocabulary_id.to_string()); + } + + json!({ + "header": { + "action": "run-task", + "task_id": task_id, + "streaming": "duplex" + }, + "payload": { + "task_group": "audio", + "task": "asr", + "function": "recognition", + "model": model, + "parameters": parameters, + "input": {} + } + }) + .to_string() +} + +fn finish_task_message(task_id: &str) -> String { + json!({ + "header": { + "action": "finish-task", + "task_id": task_id, + "streaming": "duplex" + }, + "payload": { "input": {} } + }) + .to_string() +} + +async fn send_text(writer: &SharedWriter, text: String) -> Result<(), BailianASRError> { + let mut guard = writer.lock().await; + let Some(ws) = guard.as_mut() else { + return Err(BailianASRError::ConnectionFailed( + "websocket writer not available".to_string(), + )); + }; + ws.send(Message::Text(text)) + .await + .map_err(|e| BailianASRError::SendFailed(e.to_string())) +} + +async fn send_binary(writer: &SharedWriter, data: Vec) -> Result<(), BailianASRError> { + let mut guard = writer.lock().await; + let Some(ws) = guard.as_mut() else { + return Err(BailianASRError::ConnectionFailed( + "websocket writer not available".to_string(), + )); + }; + ws.send(Message::Binary(data)) + .await + .map_err(|e| BailianASRError::SendFailed(e.to_string())) +} + +async fn close_writer(writer: &SharedWriter) -> Result<(), BailianASRError> { + let mut guard = writer.lock().await; + if let Some(mut ws) = guard.take() { + let _ = ws.close().await; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn credentials_apply_default_endpoint_and_model() { + let creds = BailianCredentials { + api_key: "sk-test".to_string(), + endpoint: "".to_string(), + model: "".to_string(), + vocabulary_id: None, + }; + assert_eq!(creds.normalized_endpoint(), DEFAULT_ENDPOINT); + assert_eq!(creds.normalized_model(), DEFAULT_MODEL); + } + + #[test] + fn run_task_message_uses_pcm_16k() { + let value: Value = + serde_json::from_str(&run_task_message("abc", DEFAULT_MODEL, None)).unwrap(); + assert_eq!(value["header"]["action"], "run-task"); + assert_eq!(value["payload"]["model"], DEFAULT_MODEL); + assert_eq!(value["payload"]["parameters"]["sample_rate"], 16000); + assert_eq!(value["payload"]["parameters"]["format"], "pcm"); + assert!(value["payload"]["parameters"]["vocabulary_id"].is_null()); + } + + #[test] + fn run_task_message_includes_optional_vocabulary_id() { + let value: Value = + serde_json::from_str(&run_task_message("abc", DEFAULT_MODEL, Some(" vocab-123 "))) + .unwrap(); + assert_eq!(value["payload"]["parameters"]["vocabulary_id"], "vocab-123"); + } + + #[test] + fn drain_audio_chunks_keeps_tail_buffered() { + let mut buffer = vec![1u8; TARGET_AUDIO_CHUNK_BYTES * 2 + 17]; + let chunks = drain_audio_chunks(&mut buffer); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].len(), TARGET_AUDIO_CHUNK_BYTES); + assert_eq!(chunks[1].len(), TARGET_AUDIO_CHUNK_BYTES); + assert_eq!(buffer.len(), 17); + } +} diff --git a/openless-all/app/src-tauri/src/asr/mod.rs b/openless-all/app/src-tauri/src/asr/mod.rs index 203cdea9..4433556d 100644 --- a/openless-all/app/src-tauri/src/asr/mod.rs +++ b/openless-all/app/src-tauri/src/asr/mod.rs @@ -5,12 +5,14 @@ //! `frame.rs` (binary frame codec) and the session lifecycle in //! `volcengine.rs`. +pub mod bailian; mod frame; pub mod local; pub mod volcengine; pub mod wav; pub mod whisper; +pub use bailian::{BailianCredentials, BailianRealtimeASR}; pub use volcengine::{VolcengineCredentials, VolcengineStreamingASR}; pub use whisper::WhisperBatchASR; diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 723d9ea6..9a79386f 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -381,6 +381,9 @@ fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bo // 本地 ASR 不依赖云端凭据。 return true; } + if provider == crate::asr::bailian::PROVIDER_ID { + return configured(&snap.asr_api_key); + } configured(&snap.asr_endpoint) && configured(&snap.asr_model) } @@ -539,6 +542,11 @@ pub async fn validate_provider_credentials(kind: String) -> Result Result { + if kind == "asr" && CredentialsVault::get_active_asr() == crate::asr::bailian::PROVIDER_ID { + return Ok(ProviderModelsResult { + models: vec![crate::asr::bailian::DEFAULT_MODEL.to_string()], + }); + } let config = read_openai_provider_config(&kind)?; fetch_provider_models(&config) .await @@ -619,6 +627,10 @@ async fn validate_asr_provider() -> Result<(), String> { return Ok(()); } + if active_asr == crate::asr::bailian::PROVIDER_ID { + return validate_bailian_asr_provider().await; + } + let config = read_openai_provider_config("asr")?; let model = CredentialsVault::get(CredentialAccount::AsrModel) .map_err(|e| e.to_string())? @@ -627,6 +639,44 @@ async fn validate_asr_provider() -> Result<(), String> { validate_asr_transcription(&config, model.trim()).await } +async fn validate_bailian_asr_provider() -> Result<(), String> { + let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) + .map_err(|e| e.to_string())? + .unwrap_or_default(); + if api_key.trim().is_empty() { + return Err("API Key 为空".to_string()); + } + let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint) + .map_err(|e| e.to_string())? + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| crate::asr::bailian::DEFAULT_ENDPOINT.to_string()); + let model = CredentialsVault::get(CredentialAccount::AsrModel) + .map_err(|e| e.to_string())? + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| crate::asr::bailian::DEFAULT_MODEL.to_string()); + let vocabulary_id = CredentialsVault::get(CredentialAccount::AsrVocabularyId) + .map_err(|e| e.to_string())? + .filter(|s| !s.trim().is_empty()); + let asr = std::sync::Arc::new(crate::asr::BailianRealtimeASR::new( + crate::asr::BailianCredentials { + api_key, + endpoint, + model, + vocabulary_id, + }, + )); + asr.open_session().await.map_err(|e| e.to_string())?; + crate::asr::AudioConsumer::consume_pcm_chunk( + &*asr, + &vec![0u8; crate::asr::bailian::TARGET_AUDIO_CHUNK_BYTES], + ); + asr.send_last_frame().await.map_err(|e| e.to_string())?; + asr.await_final_result() + .await + .map(|_| ()) + .map_err(|e| e.to_string()) +} + fn active_asr_is_keyless_for_validation(provider: &str) -> bool { provider == crate::asr::local::PROVIDER_ID || active_foundry_asr_is_supported(provider) } @@ -821,6 +871,7 @@ fn parse_account(s: &str) -> Result { "asr.api_key" => Ok(CredentialAccount::AsrApiKey), "asr.endpoint" => Ok(CredentialAccount::AsrEndpoint), "asr.model" => Ok(CredentialAccount::AsrModel), + "asr.vocabulary_id" => Ok(CredentialAccount::AsrVocabularyId), _ => Err(format!("unknown account: {s}")), } } @@ -1784,6 +1835,10 @@ mod tests { ..snapshot() }; assert!(!asr_configured_for_provider("whisper", &whisper_key_only)); + assert!(asr_configured_for_provider( + crate::asr::bailian::PROVIDER_ID, + &whisper_key_only + )); let whisper_keyless_ready = CredentialsSnapshot { asr_endpoint: Some("https://api.openai.com/v1".into()), @@ -1794,6 +1849,10 @@ mod tests { "whisper", &whisper_keyless_ready )); + assert!(!asr_configured_for_provider( + crate::asr::bailian::PROVIDER_ID, + &whisper_keyless_ready + )); assert!(asr_configured_for_provider( crate::asr::local::PROVIDER_ID, diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 5eb88e0c..801bbfae 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -19,8 +19,8 @@ use uuid::Uuid; #[cfg(target_os = "windows")] use crate::asr::local::{foundry, FoundryLocalRuntime, FoundryLocalWhisperAsr}; use crate::asr::{ - DictionaryHotword, RawTranscript, VolcengineCredentials, VolcengineStreamingASR, - WhisperBatchASR, + BailianCredentials, BailianRealtimeASR, DictionaryHotword, RawTranscript, + VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR, }; use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent, ComboHotkeyMonitor}; use crate::coordinator_state::{ @@ -69,6 +69,7 @@ use resources::{ enum ActiveAsr { Volcengine(Arc), Whisper(Arc), + Bailian(Arc), #[cfg(target_os = "windows")] FoundryLocalWhisper(Arc), /// 本地 Qwen3-ASR;只在 macOS + 模型已下载时可达。 @@ -1785,7 +1786,7 @@ fn ensure_asr_credentials() -> Result<(), String> { return Ok(()); } - if is_whisper_compatible_provider(&active_asr) { + if is_whisper_compatible_provider(&active_asr) || is_bailian_provider(&active_asr) { let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) .ok() .flatten() @@ -1918,6 +1919,10 @@ fn is_whisper_compatible_provider(id: &str) -> bool { matches!(id, "whisper" | "siliconflow" | "zhipu" | "groq") } +fn is_bailian_provider(id: &str) -> bool { + id == crate::asr::bailian::PROVIDER_ID +} + fn apply_chinese_script_preference(text: &str, pref: ChineseScriptPreference) -> String { if text.is_empty() { return String::new(); @@ -2100,6 +2105,33 @@ fn read_whisper_credentials() -> (String, String, String) { (api_key, base_url, model) } +fn read_bailian_credentials() -> BailianCredentials { + let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) + .ok() + .flatten() + .unwrap_or_default(); + let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint) + .ok() + .flatten() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| crate::asr::bailian::DEFAULT_ENDPOINT.to_string()); + let model = CredentialsVault::get(CredentialAccount::AsrModel) + .ok() + .flatten() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| crate::asr::bailian::DEFAULT_MODEL.to_string()); + let vocabulary_id = CredentialsVault::get(CredentialAccount::AsrVocabularyId) + .ok() + .flatten() + .filter(|s| !s.trim().is_empty()); + BailianCredentials { + api_key, + endpoint, + model, + vocabulary_id, + } +} + fn read_volc_credentials() -> VolcengineCredentials { let app_id = CredentialsVault::get(CredentialAccount::VolcengineAppKey) .ok() diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index a45a0c5f..5cc6105e 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -216,7 +216,77 @@ pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> { return Ok(()); } - if is_whisper_compatible_provider(&active_asr) { + if is_bailian_provider(&active_asr) { + let asr = Arc::new(BailianRealtimeASR::new(read_bailian_credentials())); + let bridge = Arc::new(DeferredAsrBridge::new()); + let consumer: Arc = bridge.clone(); + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::Bailian(Arc::clone(&asr)), + ); + start_recorder_for_starting(inner, current_session_id, &active_asr, consumer)?; + + if let Err(e) = asr.open_session().await { + log::error!("[coord] open Bailian ASR session failed: {e}"); + match startup_race_status_for_starting(inner, current_session_id) { + StartupRaceStatus::StaleContinuation => { + log::info!( + "[coord] stale Bailian ASR open_session error from session {current_session_id} — ignoring" + ); + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::CancelRaced => { + asr.cancel(); + discard_startup_resources_for_session(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(()); + } + StartupRaceStatus::ActiveStarting => {} + } + discard_startup_resources_for_session(inner, current_session_id); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(format!("ASR 连接失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + match startup_race_status_for_starting(inner, current_session_id) { + StartupRaceStatus::ActiveStarting => {} + StartupRaceStatus::CancelRaced => { + log::info!("[coord] cancel raced during Bailian ASR open_session — aborting begin"); + asr.cancel(); + discard_startup_resources_for_session(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(()); + } + StartupRaceStatus::StaleContinuation => { + log::info!( + "[coord] stale Bailian ASR open_session continuation from session {current_session_id} — ignoring" + ); + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + return Ok(()); + } + } + let target: Arc = asr; + let flushed_bytes = bridge.attach(target); + log::info!("[coord] Bailian ASR connected; flushed {flushed_bytes} deferred audio bytes"); + finish_starting_session(inner, current_session_id).await; + } else if is_whisper_compatible_provider(&active_asr) { let (api_key, base_url, model) = read_whisper_credentials(); // 用户辞書の有効フレーズを Whisper の `prompt` に流し込む。固有名詞や // 専門用語の同音・近形誤認識を ASR 段階で抑える。Polish LLM 側には @@ -627,6 +697,50 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { } } } + ActiveAsr::Bailian(asr) => { + debug_assert!(uses_global_timeout); + if let Err(e) = asr.send_last_frame().await { + log::error!("[coord] Bailian send last frame failed: {e}"); + } + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, asr.await_final_result()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] Bailian await final failed: {e}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(format!("识别失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + Err(_) => { + log::error!( + "[coord] Bailian 全局超时 {} 秒", + COORDINATOR_GLOBAL_TIMEOUT_SECS + ); + asr.cancel(); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some("识别超时".to_string()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("bailian global timeout".to_string()); + } + } + } #[cfg(target_os = "windows")] ActiveAsr::FoundryLocalWhisper(local) => { debug_assert!(!uses_global_timeout); diff --git a/openless-all/app/src-tauri/src/coordinator/resources.rs b/openless-all/app/src-tauri/src/coordinator/resources.rs index e04bcd3d..14d60062 100644 --- a/openless-all/app/src-tauri/src/coordinator/resources.rs +++ b/openless-all/app/src-tauri/src/coordinator/resources.rs @@ -67,6 +67,7 @@ pub(super) fn cancel_active_asr(asr: ActiveAsr) { match asr { ActiveAsr::Volcengine(v) => v.cancel(), ActiveAsr::Whisper(w) => w.cancel(), + ActiveAsr::Bailian(b) => b.cancel(), #[cfg(target_os = "windows")] ActiveAsr::FoundryLocalWhisper(local) => local.cancel(), #[cfg(target_os = "macos")] diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index a70362e5..d2175dd4 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -166,7 +166,7 @@ fn read_or_default Deserialize<'de> + Default>(path: &Path) -> Resul // "version": 1, // "active": { "asr": "", "llm": "" }, // "providers": { -// "asr": { "": { "appKey", "accessKey", "resourceId", "apiKey", "baseURL", "model" } }, +// "asr": { "": { "appKey", "accessKey", "resourceId", "apiKey", "baseURL", "model", "vocabularyId" } }, // "llm": { "": { "displayName", "apiKey", "baseURL", "model", "temperature", "extraHeaders" } } // } // } @@ -244,6 +244,8 @@ struct CredsAsrEntry { accessKey: Option, #[serde(skip_serializing_if = "Option::is_none")] resourceId: Option, + #[serde(skip_serializing_if = "Option::is_none")] + vocabularyId: Option, } impl CredsAsrEntry { @@ -254,6 +256,7 @@ impl CredsAsrEntry { && self.appKey.as_deref().unwrap_or("").is_empty() && self.accessKey.as_deref().unwrap_or("").is_empty() && self.resourceId.as_deref().unwrap_or("").is_empty() + && self.vocabularyId.as_deref().unwrap_or("").is_empty() } } @@ -532,7 +535,7 @@ fn migrate_legacy_sources_for_update() -> Result { fn load_credentials() -> CredsRoot { match load_keyring_credentials() { Ok(Some(root)) => { - // 不在这里调 remove_legacy_keyring_credentials() —— 它内部对 9 个 + // 不在这里调 remove_legacy_keyring_credentials() —— 它内部对每个 // 旧 account 各做一次 keyring delete,每次 delete 在 macOS Keychain // 上仍要触发 ACL 检查。第一次成功 load 时 legacy entries 通常已经 // 被 migrate_legacy_sources_for_update 清理过了;这里若再无脑跑, @@ -632,6 +635,7 @@ fn lookup_account(root: &CredsRoot, account: CredentialAccount) -> Option asr.and_then(|e| pick(&e.apiKey)), CredentialAccount::AsrEndpoint => asr.and_then(|e| pick(&e.baseURL)), CredentialAccount::AsrModel => asr.and_then(|e| pick(&e.model)), + CredentialAccount::AsrVocabularyId => asr.and_then(|e| pick(&e.vocabularyId)), } } @@ -676,6 +680,10 @@ fn write_account(root: &mut CredsRoot, account: CredentialAccount, value: Option let entry = root.providers.asr.entry(asr_id).or_default(); entry.model = normalized; } + CredentialAccount::AsrVocabularyId => { + let entry = root.providers.asr.entry(asr_id).or_default(); + entry.vocabularyId = normalized; + } } } @@ -983,6 +991,8 @@ pub enum CredentialAccount { AsrEndpoint, /// Active ASR provider's model name. AsrModel, + /// Active ASR provider's optional hotword vocabulary ID. + AsrVocabularyId, } impl CredentialAccount { @@ -1000,6 +1010,7 @@ impl CredentialAccount { CredentialAccount::AsrApiKey => "asr.api_key", CredentialAccount::AsrEndpoint => "asr.endpoint", CredentialAccount::AsrModel => "asr.model", + CredentialAccount::AsrVocabularyId => "asr.vocabulary_id", } } @@ -1014,6 +1025,7 @@ impl CredentialAccount { CredentialAccount::AsrApiKey, CredentialAccount::AsrEndpoint, CredentialAccount::AsrModel, + CredentialAccount::AsrVocabularyId, ] } } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 17feb523..084df369 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -353,6 +353,7 @@ export const en: typeof zhCN = { codingPlanX: 'CodingPlanX', custom: 'Custom', asrVolcengine: 'Volcengine bigasr', + asrBailian: 'Alibaba Bailian realtime ASR', asrSiliconflow: 'SiliconFlow SenseVoice', asrZhipu: 'Zhipu GLM-ASR', asrGroq: 'Groq Whisper-large-v3', @@ -378,6 +379,8 @@ export const en: typeof zhCN = { apiKeyLabel: 'API Key', baseUrlLabel: 'Base URL', modelLabel: 'Model', + bailianVocabularyIdLabel: 'Hotword Vocabulary ID (optional)', + bailianVocabularyIdNote: 'If you have created a DashScope hotword vocabulary, enter its vocab-... ID. Leave blank to skip hotwords.', appIdLabel: 'App ID', accessKeyLabel: 'Access Key', resourceIdLabel: 'Resource ID', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index b5b3ec28..e3e8684a 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -355,6 +355,7 @@ export const ja: typeof zhCN = { codingPlanX: 'CodingPlanX', custom: 'カスタム', asrVolcengine: 'Volcengine bigasr', + asrBailian: 'Alibaba Bailian リアルタイム ASR', asrSiliconflow: 'SiliconFlow SenseVoice', asrZhipu: 'Zhipu GLM-ASR', asrGroq: 'Groq Whisper-large-v3', @@ -380,6 +381,8 @@ export const ja: typeof zhCN = { apiKeyLabel: 'API キー', baseUrlLabel: 'エンドポイント', modelLabel: 'モデル', + bailianVocabularyIdLabel: 'ホットワード Vocabulary ID(任意)', + bailianVocabularyIdNote: 'DashScope でホットワード辞書を作成済みの場合は vocab-... ID を入力します。空欄なら送信しません。', appIdLabel: 'App ID(アプリケーション ID)', accessKeyLabel: 'Access Key', resourceIdLabel: 'Resource ID', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 9518e2af..086f5f1c 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -355,6 +355,7 @@ export const ko: typeof zhCN = { codingPlanX: 'CodingPlanX', custom: '사용자 정의', asrVolcengine: 'Volcengine bigasr', + asrBailian: 'Alibaba Bailian 실시간 ASR', asrSiliconflow: 'SiliconFlow SenseVoice', asrZhipu: 'Zhipu GLM-ASR', asrGroq: 'Groq Whisper-large-v3', @@ -380,6 +381,8 @@ export const ko: typeof zhCN = { apiKeyLabel: 'API 키', baseUrlLabel: '엔드포인트', modelLabel: '모델', + bailianVocabularyIdLabel: '핫워드 Vocabulary ID(선택)', + bailianVocabularyIdNote: 'DashScope에서 핫워드 사전을 만들었다면 vocab-... ID를 입력하세요. 비워 두면 핫워드를 전송하지 않습니다.', appIdLabel: 'App ID(애플리케이션 ID)', accessKeyLabel: 'Access Key', resourceIdLabel: 'Resource ID', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 265449a2..c025274d 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -351,6 +351,7 @@ export const zhCN = { codingPlanX: 'CodingPlanX', custom: '自定义', asrVolcengine: '火山引擎 bigasr', + asrBailian: '阿里云百炼实时 ASR', asrSiliconflow: '硅基流动 SenseVoice', asrZhipu: '智谱 GLM-ASR', asrGroq: 'Groq Whisper-large-v3', @@ -376,6 +377,8 @@ export const zhCN = { apiKeyLabel: 'API 密钥', baseUrlLabel: '接口地址', modelLabel: '模型', + bailianVocabularyIdLabel: '热词 Vocabulary ID(可选)', + bailianVocabularyIdNote: '如已在百炼创建热词表,可填写 vocab-...;留空则不下发热词。', appIdLabel: 'App ID(应用 ID)', accessKeyLabel: 'Access Key', resourceIdLabel: '资源 ID', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 21dc2d80..4ab3b5a7 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -353,6 +353,7 @@ export const zhTW: typeof zhCN = { codingPlanX: 'CodingPlanX', custom: '自定義', asrVolcengine: '火山引擎 bigasr', + asrBailian: '阿里雲百煉即時 ASR', asrSiliconflow: '硅基流動 SenseVoice', asrZhipu: '智譜 GLM-ASR', asrGroq: 'Groq Whisper-large-v3', @@ -378,6 +379,8 @@ export const zhTW: typeof zhCN = { apiKeyLabel: 'API 密鑰', baseUrlLabel: '接口地址', modelLabel: '模型', + bailianVocabularyIdLabel: '熱詞 Vocabulary ID(可選)', + bailianVocabularyIdNote: '如已在百煉建立熱詞表,可填寫 vocab-...;留空則不下發熱詞。', appIdLabel: 'App ID(應用 ID)', accessKeyLabel: 'Access Key', resourceIdLabel: '資源 ID', diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index e80427ff..8316bb68 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -25,6 +25,7 @@ interface OverviewProps { const ASR_NAME_KEY_BY_ID: Record = { volcengine: 'asrVolcengine', + bailian: 'asrBailian', siliconflow: 'asrSiliconflow', zhipu: 'asrZhipu', groq: 'asrGroq', diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 60b9ede3..565578f8 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -1182,13 +1182,15 @@ 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`)。新增兼容厂商: +// `volcengine` / `bailian` 走自建流式客户端;其余走 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: 'bailian', nameKey: 'asrBailian', baseUrl: 'wss://dashscope.aliyuncs.com/api-ws/v1/inference/', model: 'fun-asr-realtime' }, { id: 'siliconflow', nameKey: 'asrSiliconflow', baseUrl: 'https://api.siliconflow.cn/v1', model: 'FunAudioLLM/SenseVoiceSmall' }, { 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' }, @@ -1393,6 +1395,20 @@ function ProvidersSection() { defaultValue={asrPreset?.baseUrl || undefined} /> + {committedAsrProvider === 'bailian' && ( + <> + +
+ {t('settings.providers.bailianVocabularyIdNote')} +
+ + )} setAsrModelRevision(v => v + 1)} /> )} From 00f150a0910c7b4f5edbd2253a0a61aa4b743b03 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Sat, 9 May 2026 21:59:55 +0800 Subject: [PATCH 08/50] Prevent stale Bailian startup resources after connect failure Bailian startup stores the ASR instance before opening the remote session so recorder startup audio can be buffered. If the websocket setup then fails in the active startup path, the provider must be cancelled before the coordinator returns to idle. The partial-result fallback remains intentional and is now documented as matching the existing Volcengine no-loss behavior.\n\nConstraint: OpenLess ASR paths prefer not losing already recognized user speech.\nRejected: Treat every websocket close with partial text as failure | would diverge from Volcengine fallback semantics.\nConfidence: high\nScope-risk: narrow\nTested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml\nTested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib\nTested: cargo test --manifest-path openless-all/app/src-tauri/backend-tests/Cargo.toml\nCo-authored-by: OmX --- openless-all/app/src-tauri/src/asr/bailian.rs | 1 + openless-all/app/src-tauri/src/coordinator/dictation.rs | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/asr/bailian.rs b/openless-all/app/src-tauri/src/asr/bailian.rs index bf7ef454..aa2ecfab 100644 --- a/openless-all/app/src-tauri/src/asr/bailian.rs +++ b/openless-all/app/src-tauri/src/asr/bailian.rs @@ -406,6 +406,7 @@ impl BailianRealtimeASR { !st.last_result_text.trim().is_empty() || !st.final_segments.is_empty() }; if has_partial { + // 与 Volcengine 保持一致:连接异常但已有 partial 时优先兜底返回,避免丢失用户已识别出的内容。 self.finish_success(); } else { self.finish_error(error); diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 5cc6105e..b250410b 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -246,7 +246,9 @@ pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> { set_phase_idle_if_session_matches(inner, current_session_id); return Ok(()); } - StartupRaceStatus::ActiveStarting => {} + StartupRaceStatus::ActiveStarting => { + asr.cancel(); + } } discard_startup_resources_for_session(inner, current_session_id); emit_capsule( From 0a2b6bd14c207ff643ed202e7f85a777643b7f39 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Sat, 9 May 2026 23:30:39 +0800 Subject: [PATCH 09/50] Avoid missed Bailian task-start notifications Bailian uses Notify::notify_waiters when the remote task starts. send_last_frame must register the Notified future before checking the started flag so a task-started event cannot land between the check and waiter registration.\n\nConstraint: DashScope stop should not falsely time out when task-started races with user stop.\nRejected: Replace Notify with polling | broader and less direct than the Tokio enable pattern.\nConfidence: high\nScope-risk: narrow\nTested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml\nTested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib asr::bailian::tests\nCo-authored-by: OmX --- openless-all/app/src-tauri/src/asr/bailian.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src-tauri/src/asr/bailian.rs b/openless-all/app/src-tauri/src/asr/bailian.rs index aa2ecfab..484a9125 100644 --- a/openless-all/app/src-tauri/src/asr/bailian.rs +++ b/openless-all/app/src-tauri/src/asr/bailian.rs @@ -216,12 +216,15 @@ impl BailianRealtimeASR { } pub async fn send_last_frame(&self) -> Result<(), BailianASRError> { - let started = { + let started = self.task_started.notified(); + tokio::pin!(started); + started.as_mut().enable(); + let ready = { let st = self.state.lock(); st.task_started || st.task_finished }; - if !started { - tokio::time::timeout(Duration::from_secs(5), self.task_started.notified()) + if !ready { + tokio::time::timeout(Duration::from_secs(5), started) .await .map_err(|_| BailianASRError::FinalResultTimeout)?; } From fd2d43083ba61b0a79845add5dfcbc02d3965d4c Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 01:23:28 +0800 Subject: [PATCH 10/50] fix(types): align TS UserPreferences with Rust update_channel field Rust types.rs declares pub update_channel: UpdateChannel, but the TS UserPreferences interface in src/lib/types.ts and the mockSettings fixture in src/lib/ipc.ts both omitted the field. setSettings() round-trips were lossless only because Rust's serde(default) refilled the field, but consumers that destructured UserPreferences would silently miss it. - Move UpdateChannel type from ipc.ts to types.ts (avoids ipc <-> types circular import); ipc.ts re-exports for backward compat with SettingsModal etc. - Add updateChannel: UpdateChannel to UserPreferences interface. - Add updateChannel: 'stable' to mockSettings fixture. - Update stylePrefs.test.ts fixture for new required field. Audit ID 2.2.1 (docs/audit-2026-05-10-validated.md, kept local). --- openless-all/app/src/lib/ipc.ts | 6 +++++- openless-all/app/src/lib/stylePrefs.test.ts | 1 + openless-all/app/src/lib/types.ts | 7 +++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 96adf2e7..8841f7dc 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -14,10 +14,12 @@ import type { PolishMode, QaHotkeyBinding, ShortcutBinding, + UpdateChannel, UserPreferences, WindowsImeStatus, VocabPresetStore, } from './types'; +export type { UpdateChannel } from './types'; import { OL_DATA } from './mockData'; import { defaultAppShortcutModifiers, defaultQaShortcut, formatComboLabel } from './hotkey'; @@ -76,6 +78,7 @@ const mockSettings: UserPreferences = { historyRetentionDays: 7, polishContextWindowMinutes: 5, startMinimized: false, + updateChannel: 'stable', }; const mockHotkeyCapability: HotkeyCapability = { @@ -159,7 +162,8 @@ export function setSettings(prefs: UserPreferences): Promise { // ── Release channel (Beta opt-in) ────────────────────────────────────── // 渠道偏好与 fetch_latest_beta_release 实际效果只在 Tauri runtime 内有意义; // 浏览器开发模式下走 mock,避免设置页因 invoke 抛错而白屏。 -export type UpdateChannel = 'stable' | 'beta'; +// UpdateChannel 类型搬到 types.ts(UserPreferences.updateChannel 字段使用), +// 这里 re-export 保持外部模块(SettingsModal 等)import 路径不变。 export interface LatestBetaRelease { tagName: string; diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 621da4e3..a9704484 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -48,6 +48,7 @@ const previousPrefs: UserPreferences = { historyRetentionDays: 7, polishContextWindowMinutes: 5, startMinimized: false, + updateChannel: 'stable', }; const nextPrefs: UserPreferences = { diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index b946ff0e..fccbaddc 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -115,6 +115,10 @@ export interface WindowsImeStatus { dllPath: string | null; } +/** Auto-update 渠道偏好。stable = 跟正式版(默认);beta = Settings 里多一个 + * 手动下载 Beta 的入口。不影响 plugin-updater 的自动检查路径。 */ +export type UpdateChannel = 'stable' | 'beta'; + export interface UserPreferences { hotkey: HotkeyBinding; dictationHotkey: ShortcutBinding; @@ -174,6 +178,9 @@ export interface UserPreferences { /** 启动时静默运行(不弹主窗口)。Windows 开机自启场景常用——只想要后台 + 托盘, * 不想被主窗口打扰。开后所有启动路径都不弹窗,从菜单栏 / 托盘进入主窗口。默认 false。 */ startMinimized: boolean; + /** 自动更新渠道。'stable'(默认)= plugin-updater 仅检查正式版; + * 'beta' = Settings → About 出现手动下载 Beta 的入口。 */ + updateChannel: UpdateChannel; } export interface MicrophoneDevice { From ce82fcd9fafd316ef7b4b9795feb193eccb05d87 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 01:25:11 +0800 Subject: [PATCH 11/50] fix(coordinator): clear focus_target on cancel regardless of phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit finish_cancel_session_state previously only cleared state.focus_target when the cancelled phase was not Processing — Processing-phase cancel left the stale window-resource handle in place until the next begin_session_state overwrote it. Between cancel and next begin, any reader of focus_target sees a value belonging to a session that is about to be torn down. Move state.focus_target = None ahead of the phase check so it always fires when the cancel was accepted (i.e. not rejected for Idle / Inserting phases). Phase transition stays conditional on the original non-Processing branch, since end_session must still own the Processing → Idle collapse to avoid racing with polish/insert. Tighten cancel_session_state_machine_is_table_driven so it asserts focus_target gets cleared for every accepted phase (including Processing) and stays untouched on rejected cancels. Audit ID 3.3.5. --- .../app/src-tauri/src/coordinator_state.rs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator_state.rs b/openless-all/app/src-tauri/src/coordinator_state.rs index 70733327..82ce5672 100644 --- a/openless-all/app/src-tauri/src/coordinator_state.rs +++ b/openless-all/app/src-tauri/src/coordinator_state.rs @@ -164,11 +164,14 @@ pub(crate) fn begin_cancel_session_state(state: &mut SessionState) -> Option Date: Sun, 10 May 2026 01:29:22 +0800 Subject: [PATCH 12/50] fix(hotkey): MacHotkeyAdapter::shutdown stops CFRunLoop + disables tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CFRunLoopRun() in run_listen_loop has no return path; when HotkeyMonitor::drop runs adapter.shutdown(), the macOS branch fell through to the empty default impl from the trait. So every preference- driven monitor swap (e.g. user changes hotkey trigger) leaked the listener thread + CGEventTap. Long-running sessions that cycle bindings would steadily accumulate background threads. Fix: - Add CFRunLoopStop FFI extern. - Add MacShutdownHandles { tap, runloop } shared via Arc between the listener thread (writes refs in run_listen_loop after CGEventTapCreate and CFRunLoopGetCurrent) and MacHotkeyAdapter (reads them in shutdown). - Implement MacHotkeyAdapter::shutdown — disable tap then stop runloop, in that order, so the OS stops dispatching before we tear down. take()s on the option locks make shutdown idempotent. - run_listen_loop now does Box::from_raw(context) after CFRunLoopRun returns so the listener thread releases the callback context before exiting. - Move tap re-enable in tap_callback (TAP_DISABLED_BY_TIMEOUT) to read through ctx.handles.tap (same lock the adapter releases on shutdown, consistent with the rest of the FFI thread-safety story). Mirrors the Windows adapter's PostThreadMessageW(WM_QUIT) shutdown pattern. Apple documents CGEventTapEnable + CFRunLoopStop as safe to call from any thread, captured in a SAFETY comment on the unsafe Send/Sync impl. Audit ID 3.1.1 (CONFIRMED 严重). Test: hotkey + coordinator + types + commands lib tests pass (31/31). Functional verification of the leak fix requires running the app and swapping bindings — to be done after PR ships. --- openless-all/app/src-tauri/src/hotkey.rs | 65 +++++++++++++++++++++--- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 663fe889..9be0abea 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -292,14 +292,32 @@ mod platform { "hotkey hook 启动超时", run_listen_loop, )?; - listener.startup; Ok(Box::new(MacHotkeyAdapter { shared: listener.shared, + handles: listener.startup, })) } + /// Refs needed to stop the Mac CFRunLoop / CGEventTap from outside the listener + /// thread. Filled in by `run_listen_loop` once the tap is created and the runloop + /// reference is captured; consumed by `MacHotkeyAdapter::shutdown` when the + /// monitor is dropped (so a binding swap or app shutdown doesn't leak the + /// listener thread + tap). Cf. audit 3.1.1. + struct MacShutdownHandles { + tap: std::sync::Mutex>, + runloop: std::sync::Mutex>, + } + + // SAFETY: CfMachPortRef / CfRunLoopRef are CoreFoundation handles; the only + // operations we perform on them across threads are CGEventTapEnable and + // CFRunLoopStop, both of which Apple documents as safe to call from any + // thread. + unsafe impl Send for MacShutdownHandles {} + unsafe impl Sync for MacShutdownHandles {} + struct MacHotkeyAdapter { shared: Arc, + handles: Arc, } impl HotkeyAdapter for MacHotkeyAdapter { @@ -322,6 +340,19 @@ mod platform { fn reset_held_state(&self) { reset_shared_held_state(&self.shared); } + + fn shutdown(&self) { + // 顺序:先 disable tap 让 OS 不再向我们派发事件,然后 stop runloop + // 让 listener 线程从 CFRunLoopRun() 返回退出。take() 保证幂等。 + let tap = self.handles.tap.lock().ok().and_then(|mut g| g.take()); + if let Some(tap) = tap { + unsafe { CGEventTapEnable(tap, false) }; + } + let runloop = self.handles.runloop.lock().ok().and_then(|mut g| g.take()); + if let Some(rl) = runloop { + unsafe { CFRunLoopStop(rl) }; + } + } } // ── Raw CG/CF FFI ────────────────────────────────────────────────────── @@ -404,24 +435,35 @@ mod platform { fn CFRunLoopGetCurrent() -> CfRunLoopRef; fn CFRunLoopAddSource(rl: CfRunLoopRef, source: CfRunLoopSourceRef, mode: CfStringRef); fn CFRunLoopRun(); + fn CFRunLoopStop(rl: CfRunLoopRef); static kCFRunLoopCommonModes: CfStringRef; } struct CallbackContext { shared: Arc, tx: Sender, - tap: std::sync::Mutex>, + /// 与 MacHotkeyAdapter 共享的 (tap, runloop) refs。tap re-enable on + /// TAP_DISABLED_BY_TIMEOUT 走 handles.tap;adapter shutdown 也走这两个 lock。 + handles: Arc, } unsafe impl Send for CallbackContext {} unsafe impl Sync for CallbackContext {} - fn run_listen_loop(shared: Arc, tx: Sender, status_tx: StartupTx<()>) { + fn run_listen_loop( + shared: Arc, + tx: Sender, + status_tx: StartupTx>, + ) { let mask: CgEventMask = (1u64 << FLAGS_CHANGED) | (1u64 << KEY_DOWN); + let handles = Arc::new(MacShutdownHandles { + tap: std::sync::Mutex::new(None), + runloop: std::sync::Mutex::new(None), + }); let context = Box::into_raw(Box::new(CallbackContext { shared, tx, - tap: std::sync::Mutex::new(None), + handles: Arc::clone(&handles), })); unsafe { @@ -444,16 +486,20 @@ mod platform { ))); return; } - *(*context).tap.lock().unwrap() = Some(tap); + *handles.tap.lock().unwrap() = Some(tap); let source = CFMachPortCreateRunLoopSource(std::ptr::null(), tap, 0); let runloop = CFRunLoopGetCurrent(); + *handles.runloop.lock().unwrap() = Some(runloop); CFRunLoopAddSource(runloop, source, kCFRunLoopCommonModes); CGEventTapEnable(tap, true); log::info!("[hotkey] CGEventTap 已启动"); - let _ = status_tx.send(Ok(())); + let _ = status_tx.send(Ok(handles)); + // CFRunLoopRun 阻塞直到 CFRunLoopStop 被调用(由 MacHotkeyAdapter::shutdown + // 触发)。返回后 listener 线程清理 context 并自然退出。 CFRunLoopRun(); + let _ = Box::from_raw(context); } } @@ -470,7 +516,7 @@ mod platform { match event_type { TAP_DISABLED_BY_TIMEOUT | TAP_DISABLED_BY_USER_INPUT => { - if let Some(tap) = *ctx.tap.lock().unwrap() { + if let Some(tap) = *ctx.handles.tap.lock().unwrap() { unsafe { CGEventTapEnable(tap, true) }; } return event; @@ -622,7 +668,10 @@ mod platform { CallbackContext { shared, tx, - tap: std::sync::Mutex::new(None), + handles: Arc::new(MacShutdownHandles { + tap: std::sync::Mutex::new(None), + runloop: std::sync::Mutex::new(None), + }), }, rx, ) From faf02ad47e25400566903942f91a160f56af88c5 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 01:32:15 +0800 Subject: [PATCH 13/50] fix(coordinator): marshal capsule window.show/hide to main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emit_capsule does two things — (a) push a CapsulePayload event to the JS frontend via app.emit_to, and (b) flip the OS-level window visibility via window.show/hide and reposition it via maybe_position_capsule_*. Path (a) is safe from any thread (Tauri event bus marshals internally). Path (b) calls NSWindow / HWND APIs that expect the main thread — but emit_capsule is invoked at ~30 Hz from the cpal process_callback (audio thread) via the level_handler in coordinator/dictation.rs. On macOS this risks dispatch_assert_queue_fail SIGTRAP if the AppKit runloop assertion fires; on Windows SendMessage can deadlock against the GUI thread; in either case the audio callback can miss its deadline and trigger kAudioUnitErr_TooManyFramesToProcess on macOS. The 30 Hz throttle limits frequency but does not change per-call risk. Fix: wrap window.show / window.hide / maybe_position_capsule_bottom_center / show_capsule_window_no_activate / restore_main_window_key_if_active in app.run_on_main_thread. The closure captures an Arc clone + AppHandle clone + Copy primitives (state, translation, show_capsule, visible) — no payload clone needed. emit_to stays on the calling thread since it is internally thread-safe, preserving the existing happens- before relationship between event payload and window state on the JS frontend (frontend processes events asynchronously regardless). This pattern is already established in the codebase — see coordinator.rs::stop_qa_hotkey_listener (line 326) for the same reasoning around Carbon hotkey drop on the wrong thread (issue #169). Audit ID 3.2.2 (CONFIRMED 高). Test: 31 lib tests pass. Functional verification (no SIGTRAP on long recordings, capsule still appears at start of dictation) requires running the app — to be done after PR ships. --- openless-all/app/src-tauri/src/coordinator.rs | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 801bbfae..8a26fadd 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -3656,37 +3656,48 @@ fn emit_capsule( ) { let app_opt = inner.app.lock().clone(); let Some(app) = app_opt else { return }; + let translation = inner.translation_modifier_seen.load(Ordering::SeqCst); let payload = CapsulePayload { state, level, elapsed_ms, message, inserted_chars, - translation: inner.translation_modifier_seen.load(Ordering::SeqCst), + translation, }; let show_capsule = inner.prefs.get().show_capsule; - if let Some(window) = app.get_webview_window("capsule") { + let visible = !matches!(state, CapsuleState::Idle); + + // emit_capsule 会被 cpal process_callback(音频回调线程)调用 ~30 Hz —— 在该 + // 线程上调用 NSWindow / HWND API 会撞 macOS dispatch_assert_queue_fail SIGTRAP + // 或者 Win32 SendMessage 死锁。把 window.show/hide + 位置调整 marshal 到主线程; + // app.emit_to 走 Tauri 内部事件总线,本身线程安全,保留同步调用。详见 audit 3.2.2。 + let inner_for_main = Arc::clone(inner); + let app_for_main = app.clone(); + let _ = app.run_on_main_thread(move || { + let Some(window) = app_for_main.get_webview_window("capsule") else { + return; + }; // 三平台统一:Done / Cancelled / Error 状态保留 ~1.5s toast // (schedule_capsule_idle 之后会回 Idle 隐藏)。 // Windows 上 linger 的真实问题(截图选中 / 死区 / 拖拽卡顿)由 #140 加的 // `hide_capsule_window_if_present()` Win32 hard-hide 在 visible=false 分支 // 处理,不依赖把 Done/Cancelled/Error 打成 invisible。详见 PR #140 评论。 - let visible = !matches!(state, CapsuleState::Idle); - maybe_position_capsule_bottom_center(inner, &window, payload.translation); + maybe_position_capsule_bottom_center(&inner_for_main, &window, translation); if show_capsule && visible { - if !show_capsule_window_no_activate(&app, &window) { + if !show_capsule_window_no_activate(&app_for_main, &window) { let _ = window.show(); } // macOS/Windows 优先走 no-activate show,避免录音胶囊抢走主窗口点击焦点。 // 若 fallback 到 show(),OpenLess 已是前台 app 时再把 key window 还给 main。 #[cfg(target_os = "macos")] - crate::restore_main_window_key_if_active(&app); + crate::restore_main_window_key_if_active(&app_for_main); } else { hide_capsule_window_if_present(); let _ = window.hide(); } - } + }); let _ = app.emit_to("capsule", "capsule:state", payload); } From f46adce3178ca1bcfe754ca1f99c5be1c5d18049 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 01:35:10 +0800 Subject: [PATCH 14/50] fix(coordinator): don't route hotkey edges to QA while dictation runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handle_pressed_edge previously routed the dictation hotkey to the QA panel whenever qa_state.panel_visible was true, regardless of whether a main dictation session was already active. If the user opened the QA panel mid-dictation (or while polishing/inserting), the next dictation- hotkey edge was routed into begin_qa_session, which calls Recorder::start a second time on the same mic device. cpal usually rejects the second build_input_stream on macOS/Windows but the dictation session keeps running with no UX path to stop it from the QA panel; on Linux/PipeWire the second open can succeed and you get two concurrent capture streams competing for the audio device. Symmetrically, handle_released_edge swallowed Released entirely when the panel was visible — fine when QA owned the press, but if dictation owned the press, Hold-mode dictation could never end because the Released edge was eaten. Fix: read inner.state.phase alongside panel_visible. If a dictation session is non-Idle (Starting / Listening / Processing / Inserting), both edges go to dictation regardless of panel_visible. The QA panel stays open, just doesn't capture this hotkey. 3.3.4 (same PR, same files): open_qa_panel always emit'd CapsuleState::Idle to sweep stale Done residue, but if dictation was mid-flight that clobbered the in-flight capsule (Recording bar, Polishing progress, the brief Done toast). Guard the sweep on dictation phase == Idle. Audit IDs 3.3.1 + 3.3.4 (CONFIRMED 高 + 中). Test: 183/183 lib tests pass. Manual verification (open QA panel during dictation, press dictation hotkey — should stop dictation, not begin QA; also Hold-mode dictation while panel visible should still stop on release) requires the running app, to be done after merge. --- .../app/src-tauri/src/coordinator/dictation.rs | 14 ++++++++++++-- openless-all/app/src-tauri/src/coordinator/qa.rs | 15 +++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index b250410b..67e756c8 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -12,8 +12,14 @@ pub(super) async fn handle_pressed_edge(inner: &Arc) { let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst); if !was_held { // 路由:QA 浮窗可见时,rightOption 边沿走 QA;否则走主听写。详见 issue #118 v2。 + // 例外:dictation session 已经在跑(Starting / Listening / Processing / Inserting), + // 即使 QA 浮窗被打开了,这条边沿也必须先走 dictation。否则 begin_qa_session 会 + // 第二次抢同一个麦克风 device —— 在 Linux/PipeWire 上甚至会成功打开两路捕获, + // dictation 的 recorder 没人停;在 macOS/Windows 上 cpal 会拒绝第二次 build_input_stream + // 但 dictation session 仍在跑、用户找不到从 QA 面板停掉它的入口。审计 3.3.1。 + let dictation_active = !matches!(inner.state.lock().phase, SessionPhase::Idle); let panel_visible = inner.qa_state.lock().panel_visible; - if panel_visible { + if panel_visible && !dictation_active { handle_qa_option_edge(inner).await; } else { handle_pressed(inner).await; @@ -48,8 +54,12 @@ pub(super) async fn handle_released_edge(inner: &Arc) { let was_held = inner.hotkey_trigger_held.swap(false, Ordering::SeqCst); if was_held { // QA 浮窗可见时,Option 行为是 press-toggle(不分 hold/release),release 边沿忽略。 + // 与 handle_pressed_edge 的路由对称:dictation session 在跑时 Pressed 已经被路由到 + // dictation,那 Released 必须也路由到 dictation —— 否则 Hold 模式松开热键时 + // end_session 不会触发,dictation 永远停不下来。审计 3.3.1。 + let dictation_active = !matches!(inner.state.lock().phase, SessionPhase::Idle); let panel_visible = inner.qa_state.lock().panel_visible; - if panel_visible { + if panel_visible && !dictation_active { return; } handle_released(inner).await; diff --git a/openless-all/app/src-tauri/src/coordinator/qa.rs b/openless-all/app/src-tauri/src/coordinator/qa.rs index 14ca1376..149756ca 100644 --- a/openless-all/app/src-tauri/src/coordinator/qa.rs +++ b/openless-all/app/src-tauri/src/coordinator/qa.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use tauri::Emitter; -use crate::coordinator_state::{initial_session_id, SessionId}; +use crate::coordinator_state::{initial_session_id, SessionId, SessionPhase}; use crate::selection::SelectionContext; use crate::types::CapsuleState; @@ -86,9 +86,16 @@ pub(super) fn open_qa_panel(inner: &Arc) { state.selection = None; state.front_app = capture_frontmost_app(); } - // 先把胶囊清干净,避免主听写上一次 Done 状态残留的 message/insertedChars - // 在 QA Done 阶段被 capsule UI 错误复用("已之一粘贴这个 0" 那种)。 - emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None); + // 主听写 phase 是 Idle 才需要 sweep capsule —— 这里的语义是清掉「上一次 dictation + // Done 状态残留」的 message / insertedChars,让 QA 自己的 capsule 状态从干净起跑 + // (否则 capsule UI 会出现 "已粘贴这个 0" 之类把上一次 inserted_chars 错误复用的 + // 显示)。但如果 dictation 当前正处于 Recording / Polishing / Inserting / Done toast + // 显示中,强行 emit Idle 会把用户没看完的反馈抹掉、或者把 Polishing 中的进度条 + // 卡死。审计 3.3.4。 + let dictation_idle = matches!(inner.state.lock().phase, SessionPhase::Idle); + if dictation_idle { + emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None); + } if let Some(app) = inner.app.lock().clone() { crate::show_qa_window(&app, "idle"); let _ = app.emit_to( From 6171df6105199fb1a1b1f01c196dedaa8e63e88a Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 01:39:58 +0800 Subject: [PATCH 15/50] fix(coordinator): wrap audio-mute shellouts in spawn_blocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AudioMuteGuard::activate() shells out to osascript on macOS (~100–300 ms AppleScript runtime startup) and to wpctl/pactl on Linux (similar cost). Both implementations execute synchronously through std::process::Command. acquire_recording_mute and release_recording_mute were called from the async begin_session path, so the tokio worker thread was blocked for the full shellout duration on every dictation start and stop. Fix: - Make acquire_recording_mute async; wrap the existing Mutex/holders/activate body in tokio::task::spawn_blocking. The parking_lot Mutex never crosses an await — it lives entirely inside the blocking task. Caller (start_recorder_for_starting) awaits. - release_recording_mute stays a sync entry point (some call sites are on plain std::thread workers, e.g. the recorder error monitor) but delegates to spawn_blocking when a tokio Handle::try_current is available. Falls back to running synchronously when there's no tokio context — that case is already on a non-async OS thread, so blocking there blocks nothing. - start_recorder_for_starting becomes async to thread the acquire_recording_mute().await up. Three call sites (Bailian, generic Volcengine, the start_recorder_and_enter_listening helper) get matching .await? sequels. Net effect: dictation-start latency on macOS no longer absorbs the ~100–300 ms osascript startup before Recorder::start can run; the work runs on a tokio blocking pool thread instead. Same on Linux. Windows path uses native COM (IAudioEndpointVolume::SetMute), already fast — spawn_blocking is a small extra hop but harmless. Audit ID 3.2.4 (CONFIRMED 中). Audit IDs 3.2.3 (sync inserter.insert) and 3.2.5 (Linux-only 120 ms thread::sleep in permission probe) deferred to follow-up — both are smaller-impact perf concerns and don't share code with the audio-mute path. Test: 183/183 lib tests pass. Manual verification (start dictation, measure time-to-first-mic-capture before/after) requires the running app and is expected to drop ~150 ms on macOS. --- .../src-tauri/src/coordinator/dictation.rs | 10 +-- .../src-tauri/src/coordinator/resources.rs | 82 +++++++++++++------ 2 files changed, 63 insertions(+), 29 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index b250410b..a3a9d52d 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -225,7 +225,7 @@ pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> { current_session_id, ActiveAsr::Bailian(Arc::clone(&asr)), ); - start_recorder_for_starting(inner, current_session_id, &active_asr, consumer)?; + start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?; if let Err(e) = asr.open_session().await { log::error!("[coord] open Bailian ASR session failed: {e}"); @@ -324,7 +324,7 @@ pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> { current_session_id, ActiveAsr::Volcengine(Arc::clone(&asr)), ); - start_recorder_for_starting(inner, current_session_id, &active_asr, consumer)?; + start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?; if let Err(e) = asr.open_session().await { log::error!("[coord] open ASR session failed: {e}"); @@ -393,7 +393,7 @@ pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> { Ok(()) } -pub(super) fn start_recorder_for_starting( +pub(super) async fn start_recorder_for_starting( inner: &Arc, session_id: SessionId, active_asr: &str, @@ -438,7 +438,7 @@ pub(super) fn start_recorder_for_starting( let microphone_device_name = selected_microphone_device_name(inner); stop_microphone_preview_monitor(inner, "dictation recorder"); - acquire_recording_mute(inner, "dictation"); + acquire_recording_mute(inner, "dictation").await; match Recorder::start(microphone_device_name, consumer, level_handler) { Ok((rec, runtime_errors)) => { store_recorder_for_session(inner, session_id, rec); @@ -545,7 +545,7 @@ pub(super) async fn start_recorder_and_enter_listening( active_asr: &str, consumer: Arc, ) -> Result<(), String> { - start_recorder_for_starting(inner, session_id, active_asr, consumer)?; + start_recorder_for_starting(inner, session_id, active_asr, consumer).await?; finish_starting_session(inner, session_id).await; Ok(()) } diff --git a/openless-all/app/src-tauri/src/coordinator/resources.rs b/openless-all/app/src-tauri/src/coordinator/resources.rs index 14d60062..929f482b 100644 --- a/openless-all/app/src-tauri/src/coordinator/resources.rs +++ b/openless-all/app/src-tauri/src/coordinator/resources.rs @@ -110,37 +110,71 @@ pub(super) fn stop_microphone_preview_monitor(inner: &Arc, owner: &str) { } } -pub(super) fn acquire_recording_mute(inner: &Arc, owner: &str) { +/// Acquire system-output mute for the duration of a recording session. +/// +/// `AudioMuteGuard::activate()` on macOS shells out to `osascript` (~100–300 ms) +/// and on Linux to `wpctl`/`pactl` (similar). When called from the async +/// `begin_session` path that blocks the tokio worker thread for the entire +/// duration, delaying the recorder start by exactly that much. Wrap the +/// activate + bookkeeping in `spawn_blocking` so the tokio worker is freed +/// while the shell-out runs. Parking-lot `Mutex` guards never cross an await +/// (they live entirely inside the blocking task). Audit 3.2.4. +pub(super) async fn acquire_recording_mute(inner: &Arc, owner: &'static str) { if !inner.prefs.get().mute_during_recording { return; } - let mut mute = inner.recording_mute.lock(); - if mute.holders == 0 { - match crate::audio_mute::AudioMuteGuard::activate() { - Ok(guard) => { - mute.guard = Some(guard); - log::info!("[audio-mute] system output muted for recording"); - } - Err(err) => { - log::warn!("[audio-mute] failed to mute output for {owner}: {err}"); - return; + let inner = Arc::clone(inner); + let _ = tokio::task::spawn_blocking(move || { + let mut mute = inner.recording_mute.lock(); + if mute.holders == 0 { + match crate::audio_mute::AudioMuteGuard::activate() { + Ok(guard) => { + mute.guard = Some(guard); + log::info!("[audio-mute] system output muted for recording"); + } + Err(err) => { + log::warn!("[audio-mute] failed to mute output for {owner}: {err}"); + return; + } } } - } - mute.holders = mute.holders.saturating_add(1); - log::info!("[audio-mute] acquired by {owner}; holders={}", mute.holders); + mute.holders = mute.holders.saturating_add(1); + log::info!("[audio-mute] acquired by {owner}; holders={}", mute.holders); + }) + .await; } -pub(super) fn release_recording_mute(inner: &Arc, owner: &str) { - let mut mute = inner.recording_mute.lock(); - if mute.holders == 0 { - return; - } - mute.holders -= 1; - log::info!("[audio-mute] released by {owner}; holders={}", mute.holders); - if mute.holders == 0 { - mute.guard.take(); - log::info!("[audio-mute] system output mute restored after recording"); +/// Release the recording-mute guard. The Drop impl on `AudioMuteGuard` shells +/// out to `osascript` / `wpctl` again, so when holders reaches 0 we hand the +/// drop off to a blocking task to keep the tokio worker free. Audit 3.2.4. +/// +/// Fire-and-forget (no await): callers — `cancel_session`, `end_session`, +/// recorder error monitor — don't need the mute restoration to complete +/// before they continue. The user has already stopped recording; system audio +/// recovery happening 100 ms later is fine. +/// +/// `release_recording_mute` is also called from non-tokio threads (the recorder +/// error monitor uses `std::thread::spawn`), so fall back to a synchronous +/// run when there's no current tokio handle — running synchronously on a std +/// thread blocks nothing. +pub(super) fn release_recording_mute(inner: &Arc, owner: &'static str) { + let inner = Arc::clone(inner); + let work = move || { + let mut mute = inner.recording_mute.lock(); + if mute.holders == 0 { + return; + } + mute.holders -= 1; + log::info!("[audio-mute] released by {owner}; holders={}", mute.holders); + if mute.holders == 0 { + mute.guard.take(); + log::info!("[audio-mute] system output mute restored after recording"); + } + }; + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn_blocking(work); + } else { + work(); } } From 4de6164152e5d6517645bf62327590a53c37d861 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 01:44:53 +0800 Subject: [PATCH 16/50] fix(coordinator): add shutdown flag to hotkey supervisor + dispatcher loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five hotkey supervisor loops in coordinator.rs (dictation / qa / combo / translation / action) and the global-hotkey dispatcher loop in global_hotkey_runtime.rs run as `loop { ... sleep N }` with no exit signal. In production the OS reaps these threads at process exit, but: - Integration tests can't drive coordinator up/down without leaking threads across runs. - Future RunEvent::Exit cleanup work has nowhere to plug in. - A panicked listener thread leaves the supervisor spinning forever with no way to surface the failure. Fix: - Inner gains an `AtomicBool shutdown` field; each supervisor loop reads it at the top of each iteration (before any work / sleep) and returns when set. - GlobalHotkeyRuntime gains a parallel AtomicBool shutdown; dispatcher loop checks it before each 250 ms recv_timeout. - Public methods Coordinator::request_shutdown and GlobalHotkeyRuntime::request_shutdown set the respective flags. Marked #[allow(dead_code)] until the RunEvent::Exit hook lands — infrastructure-only change for now. Crucially this is *passive*: nothing sets the flags in the production binary, so behavior is unchanged. Tests that want bounded teardown can now call request_shutdown explicitly. Audit IDs 3.1.2 + 3.4.4 (CONFIRMED 中 + 低). 3.1.3 (JoinHandle storage) is deferred — without a supervisor consumer for is_finished() liveness checks, storing the handle is no-op. Test: 183/183 lib tests pass. cargo check clean. --- openless-all/app/src-tauri/src/coordinator.rs | 31 +++++++++++++++++++ .../src-tauri/src/global_hotkey_runtime.rs | 17 ++++++++++ 2 files changed, 48 insertions(+) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 801bbfae..e7c70f7d 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -139,6 +139,11 @@ struct Inner { /// polish::chat_completion_history_streaming 的 loop 每帧检查,true 时 break loop /// 避免取消后 LLM 仍 drain HTTP body 烧 token。详见 issue #161。 qa_stream_cancelled: Arc, + /// Coordinator 退出信号。各 hotkey supervisor loop 在每轮重试 sleep 之前会检查 + /// 此 flag;为 true 时 loop 立刻 return。生产场景里 process exit 一并 reap 所有 + /// supervisor 线程,但 integration test 和未来 RunEvent::Exit 钩子需要这条 + /// 显式退出路径。审计 3.1.2。 + shutdown: AtomicBool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -197,6 +202,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()), + shutdown: AtomicBool::new(false), }), } } @@ -241,6 +247,7 @@ impl Coordinator { qa_stream_cancelled: Arc::new(AtomicBool::new(false)), local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()), foundry_local_runtime, + shutdown: AtomicBool::new(false), }), } } @@ -299,6 +306,15 @@ impl Coordinator { *self.inner.app.lock() = Some(handle); } + /// 让所有 hotkey supervisor loop(dictation / qa / combo / translation / + /// switch_style / open_app)在下一轮 sleep / poll 后退出。生产场景下进程退出 + /// 一并 reap 所有线程,但 integration test 和未来 RunEvent::Exit 钩子需要 + /// 显式退出路径。审计 3.1.2。 + #[allow(dead_code)] + pub fn request_shutdown(&self) { + self.inner.shutdown.store(true, Ordering::SeqCst); + } + pub fn start_hotkey_listener(&self) { // 起一个守护线程,反复尝试安装 hotkey hook。Accessibility 一被授予就立即生效, // 用户不需要手动重启 OpenLess。 @@ -768,6 +784,9 @@ fn hotkey_supervisor_loop(inner: Arc) { let mut attempts: u32 = 0; let capability = HotkeyMonitor::capability(); loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } let prefs = inner.prefs.get(); if inner.hotkey.lock().is_some() { @@ -838,6 +857,9 @@ fn hotkey_supervisor_loop(inner: Arc) { fn qa_hotkey_supervisor_loop(inner: Arc) { let mut attempts: u32 = 0; loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } // 用户已经把 QA 关掉就睡着等 prefs 改动;改动通过 update_qa_hotkey_binding 唤醒。 let binding = match inner.prefs.get().qa_hotkey.clone() { Some(b) => b, @@ -945,6 +967,9 @@ fn qa_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { fn combo_hotkey_supervisor_loop(inner: Arc) { let mut attempts: u32 = 0; loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } // 读当前 prefs let prefs = inner.prefs.get(); if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() { @@ -1043,6 +1068,9 @@ fn combo_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { let mut attempts: u32 = 0; loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } let binding = inner.prefs.get().translation_hotkey; if is_builtin_translation_shift(&binding) || crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() @@ -1142,6 +1170,9 @@ fn translation_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver, kind: ActionHotkeyKind) { let mut attempts: u32 = 0; loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } let binding = action_hotkey_binding(&inner, kind); if is_modifier_only_shortcut(&binding) { take_action_hotkey_on_main_thread(&inner, kind); diff --git a/openless-all/app/src-tauri/src/global_hotkey_runtime.rs b/openless-all/app/src-tauri/src/global_hotkey_runtime.rs index b04516a3..119eaeb3 100644 --- a/openless-all/app/src-tauri/src/global_hotkey_runtime.rs +++ b/openless-all/app/src-tauri/src/global_hotkey_runtime.rs @@ -6,6 +6,7 @@ //! and one dispatcher instead of racing on `GlobalHotKeyEvent::receiver()`. use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver, Sender}; use std::sync::Arc; use std::time::Duration; @@ -20,6 +21,10 @@ static RUNTIME: OnceCell> = OnceCell::new(); pub struct GlobalHotkeyRuntime { manager: GlobalHotKeyManager, routes: Mutex>>, + /// 用于 dispatcher loop 的退出信号。process-singleton 在生产路径里不会被 + /// drop,但 integration test / future RunEvent::Exit 钩子可以调用 + /// `request_shutdown` 让 dispatcher 退出。审计 3.4.4。 + shutdown: AtomicBool, } // global-hotkey 0.6 does not mark its manager Send/Sync on all platforms even @@ -41,6 +46,7 @@ impl GlobalHotkeyRuntime { let runtime = Arc::new(Self { manager, routes: Mutex::new(HashMap::new()), + shutdown: AtomicBool::new(false), }); start_dispatcher(Arc::clone(&runtime)); Ok(runtime) @@ -48,6 +54,14 @@ impl GlobalHotkeyRuntime { .cloned() } + /// Signal the dispatcher loop to exit at its next 250 ms tick. Idempotent. + /// Currently called only from tests; production app shutdown lets the OS + /// reap the process. Audit 3.4.4. + #[allow(dead_code)] + pub fn request_shutdown(&self) { + self.shutdown.store(true, Ordering::SeqCst); + } + pub fn register( self: &Arc, hotkey: HotKey, @@ -97,6 +111,9 @@ fn start_dispatcher(runtime: Arc) { .spawn(move || { let receiver = GlobalHotKeyEvent::receiver(); loop { + if runtime.shutdown.load(Ordering::SeqCst) { + return; + } match receiver.recv_timeout(Duration::from_millis(250)) { Ok(event) => runtime.dispatch(event), Err(_) => continue, From 84ee3d96d59ff4e0ce938ab9c0924fb562f36624 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 08:08:07 +0800 Subject: [PATCH 17/50] fix(coordinator): read show_capsule pref inside main-thread closure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses pr_agent "Stale State" review on PR #389. show_capsule was read on the audio callback thread and snapshotted into the run_on_main_thread closure. If the user toggled the preference between the audio-thread emit and the main-thread closure firing (one or two frames later), the queued window show/hide could apply the old preference and briefly flash the wrong UI state. Move that read inside the closure so the latest user pref always wins. Why visible / translation stay snapshotted: they're the payload of THIS specific capsule:state event — the whole point is to tell the JS frontend "this is the state at the moment emit_capsule was called". Re-reading them inside the closure would mean the OS-level window state and the JS-side payload disagree (the closure lands one frame later than the emit_to that already shipped). No behavior change in the steady state; eliminates a 16–33 ms stale- pref window when the user changes show_capsule mid-dictation. --- openless-all/app/src-tauri/src/coordinator.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 8a26fadd..aefd6397 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -3666,19 +3666,26 @@ fn emit_capsule( translation, }; - let show_capsule = inner.prefs.get().show_capsule; + // visible / translation 是「这一帧 capsule:state event 的 payload」内容 —— + // 必须在 call-site(即音频线程触发 emit_capsule 时)就算定,否则 main thread + // 闭包里读到的将是「下一帧」的 state,跟实际下发给 JS 的 payload 不一致。 let visible = !matches!(state, CapsuleState::Idle); // emit_capsule 会被 cpal process_callback(音频回调线程)调用 ~30 Hz —— 在该 // 线程上调用 NSWindow / HWND API 会撞 macOS dispatch_assert_queue_fail SIGTRAP // 或者 Win32 SendMessage 死锁。把 window.show/hide + 位置调整 marshal 到主线程; // app.emit_to 走 Tauri 内部事件总线,本身线程安全,保留同步调用。详见 audit 3.2.2。 + // + // show_capsule(用户偏好)在主线程执行时再读 —— 用户可以在录音过程中改设置, + // 闭包入队到真正跑之间窗口上限是一两帧(~16-33ms),用最新值消除 stale-pref + // 闪烁。pr_agent 关注点 — 见 audit follow-up。 let inner_for_main = Arc::clone(inner); let app_for_main = app.clone(); let _ = app.run_on_main_thread(move || { let Some(window) = app_for_main.get_webview_window("capsule") else { return; }; + let show_capsule = inner_for_main.prefs.get().show_capsule; // 三平台统一:Done / Cancelled / Error 状态保留 ~1.5s toast // (schedule_capsule_idle 之后会回 Idle 隐藏)。 // Windows 上 linger 的真实问题(截图选中 / 死区 / 拖拽卡顿)由 #140 加的 From 1889b7912b9f2c57f4463102561373f27c9b5af9 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 08:09:02 +0800 Subject: [PATCH 18/50] fix(coordinator): log spawn_blocking panic in acquire_recording_mute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses pr_agent "Possible Issue" review on PR #391. The original `let _ = tokio::task::spawn_blocking(...).await` silently swallowed JoinError, which means a panic inside AudioMuteGuard::activate() (or the parking_lot lock) would leave the user with system audio leaking during recording — and zero [audio-mute] log to explain why. Match the JoinResult and log::error! the panic. State (holders/guard) stays consistent under panic — activate panic means no guard stored and holders not incremented; lock panic can't reach the mutate. The fix is purely about observability. --- .../app/src-tauri/src/coordinator/resources.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/coordinator/resources.rs b/openless-all/app/src-tauri/src/coordinator/resources.rs index 929f482b..ec649d6b 100644 --- a/openless-all/app/src-tauri/src/coordinator/resources.rs +++ b/openless-all/app/src-tauri/src/coordinator/resources.rs @@ -124,7 +124,7 @@ pub(super) async fn acquire_recording_mute(inner: &Arc, owner: &'static s return; } let inner = Arc::clone(inner); - let _ = tokio::task::spawn_blocking(move || { + let join_result = tokio::task::spawn_blocking(move || { let mut mute = inner.recording_mute.lock(); if mute.holders == 0 { match crate::audio_mute::AudioMuteGuard::activate() { @@ -142,6 +142,16 @@ pub(super) async fn acquire_recording_mute(inner: &Arc, owner: &'static s log::info!("[audio-mute] acquired by {owner}; holders={}", mute.holders); }) .await; + // 显式记录 spawn_blocking 任务的 panic(之前是 `let _ = .await` 静默吞掉)。 + // holders/guard 状态本身在 panic 路径下仍然一致 —— 因为 panic 只能发生在 + // activate() 抛 / lock 抛,前者会让 holders 不增 + guard 仍 None,后者根本 + // 进不到 mutate 阶段;但用户碰到 system audio 在录音时漏出系统声却找不到 + // 任何 [audio-mute] 日志,没法 debug。pr_agent feedback on PR #391。 + if let Err(join_err) = join_result { + log::error!( + "[audio-mute] acquire task panicked for {owner}: {join_err}; mute did not activate" + ); + } } /// Release the recording-mute guard. The Drop impl on `AudioMuteGuard` shells From e45860538d86a94c4a70cab4bb5459750e831fcd Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 08:24:04 +0800 Subject: [PATCH 19/50] ci: re-trigger pr_agent after PR description cleanup (no code change) From d40b08ab447eb588251177ed8abafd98d1ee026b Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 08:42:23 +0800 Subject: [PATCH 20/50] fix(coordinator): post-audit logic-review hotfixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real bugs surfaced by the 2026-05-10 end-to-end logic review of beta after the 8-PR audit-fix wave landed. 1. coordinator.rs:2313 — `acquire_recording_mute(inner, "qa");` was missing `.await`. PR #391 (`6171df61`) made the function `async fn` and updated the dictation call site at coordinator/dictation.rs:451, but the QA call site was missed. Effect: when a user has `mute_during_recording = true` and triggers QA via Right Option, the returned Future is dropped on the next line, `spawn_blocking` is never scheduled, holders never increments, and system audio is NOT muted (e.g. YouTube playback continues into the QA recording). Both the matching `release_recording_mute(inner, "qa")` calls become no-ops (early return at holders == 0). The compiler was emitting `unused_must_use` for this site (verified before this commit). Fix: add `.await`. 2. coordinator/dictation.rs:843-849 — PR #387 (`ce82fcd9`) was framed as "clear focus_target on cancel regardless of phase", but the only code path that gained the unconditional clear was `finish_cancel_session_state`. cancel_session deliberately skips that helper for Processing (so end_session can drive its own teardown), and end_session's "ASR-finished, cancelled" exit at dictation.rs:843-849 set phase=Idle but never touched focus_target. Result: cancel-during-Processing leaves a stale `usize` focus slot in state.focus_target until the next begin_session_state overwrites it. Today the leak is bounded (no documented reader between cancel and next begin), but it violates PR #387's stated contract and is a silent footgun for any future reader of focus_target on that interval. Fix: in the cancelled-after-ASR exit branch, take the state lock once and clear both phase and focus_target together. Source: docs/logic-review-2026-05-10.md (subagent end-to-end review). 185/185 lib tests pass; cargo check clean (unused_must_use gone). Manual verification checklist for both fixes in the review doc. --- openless-all/app/src-tauri/src/coordinator.rs | 2 +- .../app/src-tauri/src/coordinator/dictation.rs | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c666c0d5..4428a8f7 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2310,7 +2310,7 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { let microphone_device_name = selected_microphone_device_name(inner); stop_microphone_preview_monitor(inner, "QA recorder"); - acquire_recording_mute(inner, "qa"); + acquire_recording_mute(inner, "qa").await; match Recorder::start(microphone_device_name, consumer, level_handler) { Ok((rec, runtime_errors)) => { *inner.qa_recorder.lock() = Some(rec); diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 1daa7efd..51512fba 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -845,7 +845,15 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { if inner.state.lock().cancelled { log::info!("[coord] cancel detected after ASR — discarding transcript"); restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; + // PR #387 的「cancel 后清 focus_target」契约要在 Processing 路径上也成立。 + // cancel_session 在 Processing 阶段故意跳过 finish_cancel_session_state(让 + // 这里收尾),但此前的 end_session 没把 focus_target 清掉。logic-review + // 2026-05-10 P3 (🚩) 把这条补完。 + { + let mut state = inner.state.lock(); + state.phase = SessionPhase::Idle; + state.focus_target = None; + } return Ok(()); } From b0da8e01b945dc479f1c8ae3f333640bd856a94d Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 09:23:29 +0800 Subject: [PATCH 21/50] fix(vault): cache CredsRoot in process to stop repeated Keychain prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without an in-process cache, every CredentialsVault::get_* / get_active_asr / snapshot call drove load_credentials() → load_keyring_credentials(), which reads the manifest entry plus every chunk entry from the OS keyring. On macOS each distinct keychain entry has its own ACL — so an ad-hoc-signed binary (or any binary whose ACL grants haven't yet been "Always Allow"-ed) prompts the user on every read of every entry. A single dictation cycle reads credentials 5–10 times, multiplied by (1 manifest + N chunks), produces tens of "OpenLess wants to use the keychain" prompts per recording. Add a process-wide CREDENTIALS_CACHE: OnceLock>>: - load_credentials / load_credentials_for_update consult the cache before falling through to Keychain. First read populates the cache; every subsequent read in the same process is silent. - save_credentials populates the cache with the cleaned root after successful Keychain write, keeping Settings → Recording credential edits visible immediately to dictation. - migrate_legacy_sources{,_for_update} call save_credentials internally so legacy-migration paths inherit the cache update for free. Trade-off: cross-process changes (e.g. user runs `security` CLI manually, or a second instance of the app — single-instance is enforced but defense in depth) are invisible until next launch. Acceptable per the credential vault contract: the keyring is owned by this app. Note: this does NOT fix the underlying problem that ad-hoc-signed builds change cdhash on every rebuild and lose "Always Allow" grants. For that the build must use a stable Apple Developer ID signature (APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / APPLE_TEAM_ID env vars). The cache only ensures that within a single process lifetime, the user is prompted at most once per Keychain entry. 185/185 lib tests pass. cargo check clean. --- openless-all/app/src-tauri/src/persistence.rs | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index d2175dd4..4a251c26 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -47,6 +47,41 @@ fn credentials_lock() -> &'static Mutex<()> { CREDENTIALS_LOCK.get_or_init(|| Mutex::new(())) } +/// Process-wide credentials cache. +/// +/// Without this cache every `CredentialsVault::get_*` / `snapshot` call hits +/// `load_credentials()` → `load_keyring_credentials()` which reads the +/// manifest entry plus every chunk entry from the OS keyring. On macOS each +/// distinct keychain entry has its own ACL — so an ad-hoc-signed binary (or +/// any binary whose ACL grants haven't been set up yet) prompts on every read +/// of every entry. A single dictation cycle reads credentials 5–10 times, +/// times (1 manifest + N chunks) entries → tens of "OpenLess wants to use +/// the keychain" prompts per recording. +/// +/// With this cache the first read populates `Some(CredsRoot)` and every +/// subsequent read in the same process is silent. `save_credentials` keeps +/// the cache in sync after writes so Settings → Recording credential edits +/// take effect immediately. +/// +/// Cross-process changes (e.g. user edits via `security` CLI, or another +/// instance of the app — single-instance is enforced but defense in depth) +/// will be invisible until the next process launch. Acceptable trade-off +/// per the credential vault contract: the keyring is owned by this app. +static CREDENTIALS_CACHE: OnceLock>> = OnceLock::new(); + +fn credentials_cache() -> &'static Mutex> { + CREDENTIALS_CACHE.get_or_init(|| Mutex::new(None)) +} + +fn store_credentials_cache(root: &CredsRoot) { + *credentials_cache().lock() = Some(root.clone()); +} + +#[cfg(test)] +fn reset_credentials_cache_for_tests() { + *credentials_cache().lock() = None; +} + // ───────────────────────── path helpers ───────────────────────── fn data_dir() -> Result { @@ -533,7 +568,10 @@ fn migrate_legacy_sources_for_update() -> Result { } fn load_credentials() -> CredsRoot { - match load_keyring_credentials() { + if let Some(cached) = credentials_cache().lock().as_ref().cloned() { + return cached; + } + let root = match load_keyring_credentials() { Ok(Some(root)) => { // 不在这里调 remove_legacy_keyring_credentials() —— 它内部对每个 // 旧 account 各做一次 keyring delete,每次 delete 在 macOS Keychain @@ -549,20 +587,27 @@ fn load_credentials() -> CredsRoot { log::warn!("[vault] system credential read failed: {e}"); load_legacy_sources_without_migration() } - } + }; + store_credentials_cache(&root); + root } fn load_credentials_for_update() -> Result { - match load_keyring_credentials() { + if let Some(cached) = credentials_cache().lock().as_ref().cloned() { + return Ok(cached); + } + let root = match load_keyring_credentials() { Ok(Some(root)) => { // 同 load_credentials:不再每次 update 都尝试 delete legacy keyring // entries,避免反复触发 macOS Keychain ACL 弹窗。 remove_legacy_credentials_file_best_effort(); - Ok(root) + root } - Ok(None) => migrate_legacy_sources_for_update(), - Err(e) => Err(e), - } + Ok(None) => migrate_legacy_sources_for_update()?, + Err(e) => return Err(e), + }; + store_credentials_cache(&root); + Ok(root) } fn save_credentials(root: &CredsRoot) -> Result<()> { @@ -616,6 +661,9 @@ fn save_credentials(root: &CredsRoot) -> Result<()> { } remove_legacy_credentials_file_best_effort(); + // 写完成功后立刻刷新 process cache —— 同进程后续读不再回 Keychain。 + // 见 CREDENTIALS_CACHE 的 doc。 + store_credentials_cache(&cleaned); Ok(()) } From 2dc7ac59cfe1cfdc60518bc2231686635f0ee1b4 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 09:41:07 +0800 Subject: [PATCH 22/50] ci: re-trigger pr_agent after PR description cleanup From 8a9fb4f4f888ff7f73911cbeab1d403266c93298 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 10:01:00 +0800 Subject: [PATCH 23/50] fix(vault): don't cache fallback creds when keyring read fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr_agent on PR #394 flagged a "Stale cache on read error" issue: both load_credentials / load_credentials_for_update were unconditionally writing the result into CREDENTIALS_CACHE, including the legacy-file fallback returned when load_keyring_credentials() hit an Err (e.g. user hasn't yet clicked "Always Allow" on the first keychain dialog, login keychain locked, DataProtection error). Effect: after a transient keyring failure the process pinned the fallback (or default) creds in cache for its entire lifetime — even after the user granted access, subsequent reads would never retry the keyring and would keep returning stale defaults / legacy content until the app is restarted. Fix: cache only on the Ok(Some) and Ok(None)→migrate paths. The Err path now returns the legacy fallback without populating the cache, so the next call retries the keyring and picks up the user's just-granted access. 185/185 lib tests pass; cargo check clean. --- openless-all/app/src-tauri/src/persistence.rs | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index 4a251c26..191fd2e6 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -571,7 +571,7 @@ fn load_credentials() -> CredsRoot { if let Some(cached) = credentials_cache().lock().as_ref().cloned() { return cached; } - let root = match load_keyring_credentials() { + match load_keyring_credentials() { Ok(Some(root)) => { // 不在这里调 remove_legacy_keyring_credentials() —— 它内部对每个 // 旧 account 各做一次 keyring delete,每次 delete 在 macOS Keychain @@ -580,34 +580,52 @@ fn load_credentials() -> CredsRoot { // 只会反复弹「OpenLess 想删除 X」十几次。文件 legacy(plaintext // JSON)不需要 ACL,可继续 best-effort 删除。 remove_legacy_credentials_file_best_effort(); + store_credentials_cache(&root); + root + } + Ok(None) => { + // 没有现成 chunked manifest —— 走 migrate(如果有 legacy 则写入并返回写后的 root)。 + // migrate_legacy_sources 内部 save_credentials 已经会刷 cache,这里再补一次 + // 是为了「无 legacy 也无 manifest」走默认 root 的路径也能进 cache。 + let root = migrate_legacy_sources(); + store_credentials_cache(&root); root } - Ok(None) => migrate_legacy_sources(), Err(e) => { + // **不缓存 keyring 错误路径下的 fallback**。Keychain 可能只是临时不可读 + // (用户尚未在第一次弹窗里点同意 / DataProtection 错误 / login keychain + // 还没 unlock);如果在这里把 legacy fallback 写进 cache,等用户授权后 + // 我们就再也不会重读 keyring,整个进程生命周期里都拿 stale 数据。下次 + // 调用让它再尝试一次 keyring。pr_agent feedback on PR #394。 log::warn!("[vault] system credential read failed: {e}"); load_legacy_sources_without_migration() } - }; - store_credentials_cache(&root); - root + } } fn load_credentials_for_update() -> Result { if let Some(cached) = credentials_cache().lock().as_ref().cloned() { return Ok(cached); } - let root = match load_keyring_credentials() { + match load_keyring_credentials() { Ok(Some(root)) => { // 同 load_credentials:不再每次 update 都尝试 delete legacy keyring // entries,避免反复触发 macOS Keychain ACL 弹窗。 remove_legacy_credentials_file_best_effort(); - root + store_credentials_cache(&root); + Ok(root) } - Ok(None) => migrate_legacy_sources_for_update()?, - Err(e) => return Err(e), - }; - store_credentials_cache(&root); - Ok(root) + Ok(None) => { + // migrate_legacy_sources_for_update 内部如果实际 migrate 会调 + // save_credentials,cache 会被刷新;如果只返回 default root(没 legacy), + // 我们这里再显式 cache 一次防御性补一下。 + let root = migrate_legacy_sources_for_update()?; + store_credentials_cache(&root); + Ok(root) + } + // 错误路径不缓存 —— 同 load_credentials 注释;让下次读重试 keyring。 + Err(e) => Err(e), + } } fn save_credentials(root: &CredsRoot) -> Result<()> { From 568d30d079638ce2b2fe84d3bce97d8dd084a1fa Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 10:34:08 +0800 Subject: [PATCH 24/50] chore: bump version to 1.2.24-7 (Beta-7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10 PRs landed on beta this cycle: - #377 paste shortcut configurable (issue #360) - #386 TS UserPreferences updateChannel alignment - #387 focus_target leak on Processing-phase cancel - #388 [严重] MacHotkeyAdapter::shutdown stops CFRunLoop + tap - #389 emit_capsule window.show/hide off audio thread - #390 QA / dictation hotkey routing race - #391 audio-mute spawn_blocking (async hygiene) - #392 hotkey supervisor + global dispatcher exit signal - #393 post-audit logic-review hotfixes (QA mute .await + focus_target Processing branch) - #394 in-process credentials cache (kills repeated Keychain prompts) Bump 4 files: package.json, tauri.conf.json, Cargo.toml, Cargo.lock. --- openless-all/app/package.json | 2 +- openless-all/app/src-tauri/Cargo.lock | 2 +- openless-all/app/src-tauri/Cargo.toml | 2 +- openless-all/app/src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 14e06a58..369721c7 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -1,7 +1,7 @@ { "name": "openless-app", "private": true, - "version": "1.2.24-6", + "version": "1.2.24-7", "type": "module", "scripts": { "dev": "vite", diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 90f13a36..ef129ebc 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -3751,7 +3751,7 @@ dependencies = [ [[package]] name = "openless" -version = "1.2.24-6" +version = "1.2.24-7" dependencies = [ "anyhow", "arboard", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 109ca354..b819507b 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openless" -version = "1.2.24-6" +version = "1.2.24-7" description = "OpenLess — local voice input that types where your cursor is" authors = ["OpenLess"] edition = "2021" diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 1659a1df..7153cbf1 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenLess", - "version": "1.2.24-6", + "version": "1.2.24-7", "identifier": "com.openless.app", "build": { "beforeDevCommand": "npm run dev", From 049147c4819bdd0ecde27e7d8ef197c0e9616bb5 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 10:42:20 +0800 Subject: [PATCH 25/50] chore(release): sync package-lock.json version to 1.2.24-7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI ci.yml "Verify version sync across all 5 files" 步骤要求 5 处版本号一致: package.json / package-lock.json (root + nested) / tauri.conf.json / Cargo.toml / Cargo.lock 的 [openless] 包。上一个 commit (568d30d) 漏改了 package-lock.json 两处 → Linux + macOS CI fail。 修法跟 PR #383 后续 commit 941542c6 同形:手动同步两处 'version' 字段到 1.2.24-7。下次正确流程是用 scripts/bump-version.sh 一把过。 --- openless-all/app/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 2f41530b..fb3c0627 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "openless-app", - "version": "1.2.24-6", + "version": "1.2.24-7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "1.2.24-6", + "version": "1.2.24-7", "dependencies": { "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-autostart": "^2.5.1", From 95237d2c4928595e6bf5d508c4142059f2f2a026 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Sun, 10 May 2026 10:58:19 +0800 Subject: [PATCH 26/50] Let users repair recurring ASR classifier mistakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #372 reports repeated classifier errors such as 2粒 being intended as 2例. The existing vocabulary hotword path is probabilistic, so this adds a separate deterministic correction-rules store and applies supported literal/{num} rules in the coordinator before polish and before insertion. The review follow-up keeps the UI refresh path consistent, handles remove failures, rejects rules that would otherwise save as enabled but never run, and fixes the Traditional Chinese wording. Constraint: Keep correction rules separate from dictionary.json and vocab presets. Constraint: Only support literal replacement and one {num} wildcard; do not expose arbitrary regex. Rejected: Add import/export or a new top-level page | outside the approved scheme-C scope. Rejected: Change history rawTranscript schema | product/data-model decision not approved in this review round. Confidence: high Scope-risk: moderate Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib correction Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib correction_rule_syntax_rejects_silent_noops Tested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml Tested: cd openless-all/app && npm run build Tested: git diff --check Related: #372 Co-authored-by: OmX --- openless-all/app/src-tauri/src/commands.rs | 86 ++++++-- openless-all/app/src-tauri/src/coordinator.rs | 15 +- .../src-tauri/src/coordinator/dictation.rs | 37 +++- openless-all/app/src-tauri/src/correction.rs | 203 ++++++++++++++++++ openless-all/app/src-tauri/src/lib.rs | 19 +- openless-all/app/src-tauri/src/persistence.rs | 119 +++++++++- openless-all/app/src-tauri/src/types.rs | 12 ++ openless-all/app/src/i18n/en.ts | 11 + openless-all/app/src/i18n/ja.ts | 11 + openless-all/app/src/i18n/ko.ts | 11 + openless-all/app/src/i18n/zh-CN.ts | 11 + openless-all/app/src/i18n/zh-TW.ts | 11 + openless-all/app/src/lib/ipc.ts | 33 +++ openless-all/app/src/lib/types.ts | 8 + openless-all/app/src/pages/Vocab.tsx | 163 +++++++++++++- 15 files changed, 721 insertions(+), 29 deletions(-) create mode 100644 openless-all/app/src-tauri/src/correction.rs diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 9a79386f..c9cd9b6e 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -19,9 +19,9 @@ use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVaul use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; use crate::recorder::{AudioConsumer, Recorder}; use crate::types::{ - ChineseScriptPreference, ComboBinding, CredentialsStatus, DictationSession, DictionaryEntry, - HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode, ShortcutBinding, - UpdateChannel, UserPreferences, VocabPresetStore, WindowsImeStatus, + ChineseScriptPreference, ComboBinding, CorrectionRule, CredentialsStatus, DictationSession, + DictionaryEntry, HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode, + ShortcutBinding, UpdateChannel, UserPreferences, VocabPresetStore, WindowsImeStatus, }; type CoordinatorState<'a> = State<'a, Arc>; @@ -160,7 +160,10 @@ pub fn set_settings( if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) { log::warn!("[tray] refresh microphone menu after settings save failed: {err}"); let tray_state = app_for_main.state::(); - sync_tray_microphone_selection(&tray_state.lock(), &prefs_for_main.microphone_device_name); + sync_tray_microphone_selection( + &tray_state.lock(), + &prefs_for_main.microphone_device_name, + ); } }); // 抑制 unused 警告:tray_microphones 现在改在闭包里通过 app.state 取, @@ -246,7 +249,10 @@ pub async fn fetch_latest_beta_release() -> Result, St /// 用 `/releases/tag/` 这个唯一锚点抓 tag。 fn parse_latest_beta_from_atom(body: &str) -> Option { for entry in body.split("").skip(1) { - let entry_body = entry.split_once("").map(|(b, _)| b).unwrap_or(entry); + let entry_body = entry + .split_once("") + .map(|(b, _)| b) + .unwrap_or(entry); let needle = "/releases/tag/"; let tag_start = match entry_body.find(needle) { Some(i) => i + needle.len(), @@ -260,11 +266,9 @@ fn parse_latest_beta_from_atom(body: &str) -> Option { if !tag_name.ends_with("-beta-tauri") { continue; } - let html_url = format!( - "https://github.com/appergb/openless/releases/tag/{tag_name}" - ); - let published_at = extract_between(entry_body, "", "") - .unwrap_or_default(); + let html_url = format!("https://github.com/appergb/openless/releases/tag/{tag_name}"); + let published_at = + extract_between(entry_body, "", "").unwrap_or_default(); return Some(LatestBetaRelease { tag_name, html_url, @@ -926,6 +930,43 @@ pub fn set_vocab_enabled( .map_err(|e| e.to_string()) } +#[tauri::command] +pub fn list_correction_rules(coord: CoordinatorState<'_>) -> Result, String> { + coord.correction_rules().list().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn add_correction_rule( + coord: CoordinatorState<'_>, + pattern: String, + replacement: String, +) -> Result { + coord + .correction_rules() + .add(pattern, replacement) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn remove_correction_rule(coord: CoordinatorState<'_>, id: String) -> Result<(), String> { + coord + .correction_rules() + .remove(&id) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_correction_rule_enabled( + coord: CoordinatorState<'_>, + id: String, + enabled: bool, +) -> Result<(), String> { + coord + .correction_rules() + .set_enabled(&id, enabled) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub fn list_vocab_presets() -> Result { crate::persistence::list_vocab_presets().map_err(|e| e.to_string()) @@ -991,7 +1032,10 @@ pub fn set_default_polish_mode( ) -> Result<(), String> { let mut prefs = coord.prefs().get(); prefs.default_mode = mode; - coord.prefs().set(prefs.clone()).map_err(|e| e.to_string())?; + coord + .prefs() + .set(prefs.clone()) + .map_err(|e| e.to_string())?; // 跟 set_settings 同样:refresh_tray_microphone_menu 里 tray.set_menu 改 NSStatusItem, // 必须主线程;这里是同步 Tauri command 跑在 IPC 线程,直调会让 macOS 死锁。 let app_for_main = app.clone(); @@ -1975,14 +2019,20 @@ mod tests { }; assert!(llm_configured_for_provider("custom", &keyless_ready)); assert!(llm_configured_for_provider("self-hosted", &keyless_ready)); - assert!(llm_configured_for_provider("openrouterFree", &keyless_ready)); + assert!(llm_configured_for_provider( + "openrouterFree", + &keyless_ready + )); let hosted_keyless = CredentialsSnapshot { ark_endpoint: Some("https://openrouter.ai/api/v1".into()), ark_model_id: Some("qwen/qwen3-coder:free".into()), ..snapshot() }; - assert!(!llm_configured_for_provider("openrouterFree", &hosted_keyless)); + assert!(!llm_configured_for_provider( + "openrouterFree", + &hosted_keyless + )); let hosted_ready = CredentialsSnapshot { ark_api_key: Some("key".into()), @@ -1997,13 +2047,19 @@ mod tests { ark_model_id: Some("qwen".into()), ..snapshot() }; - assert!(!llm_configured_for_provider("custom", &key_without_endpoint)); + assert!(!llm_configured_for_provider( + "custom", + &key_without_endpoint + )); let endpoint_without_model = CredentialsSnapshot { ark_endpoint: Some("http://localhost:11434/v1".into()), ..snapshot() }; - assert!(!llm_configured_for_provider("custom", &endpoint_without_model)); + assert!(!llm_configured_for_provider( + "custom", + &endpoint_without_model + )); } impl SettingsWriter for FakeSettingsWriter { diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 4428a8f7..283bc7a6 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -32,20 +32,21 @@ use crate::coordinator_state::{ use crate::hotkey::{HotkeyEvent, HotkeyMonitor}; use crate::insertion::TextInserter; use crate::persistence::{ - CredentialAccount, CredentialsVault, DictionaryStore, HistoryStore, PreferencesStore, + CorrectionRuleStore, CredentialAccount, CredentialsVault, DictionaryStore, HistoryStore, + PreferencesStore, }; use crate::polish::{OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor}; use crate::recorder::{Recorder, RecorderError}; use crate::selection::capture_selection; +#[cfg(target_os = "windows")] +use crate::types::PasteShortcut; use crate::types::{ CapsulePayload, CapsuleState, ChineseScriptPreference, DictationSession, HotkeyCapability, HotkeyStatus, HotkeyStatusState, InsertStatus, OutputLanguagePreference, PolishMode, }; #[cfg(target_os = "windows")] -use crate::types::PasteShortcut; -#[cfg(target_os = "windows")] use crate::windows_ime_ipc::ImeSubmitTarget; #[cfg(target_os = "windows")] use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionController}; @@ -96,6 +97,7 @@ struct Inner { history: HistoryStore, prefs: PreferencesStore, vocab: DictionaryStore, + correction_rules: CorrectionRuleStore, inserter: TextInserter, #[cfg(target_os = "windows")] windows_ime: WindowsImeSessionController, @@ -176,6 +178,7 @@ impl Coordinator { }); let prefs = PreferencesStore::new().expect("preferences store init"); let vocab = DictionaryStore::new().expect("dictionary store init"); + let correction_rules = CorrectionRuleStore::new().expect("correction rule store init"); Self { inner: Arc::new(Inner { @@ -183,6 +186,7 @@ impl Coordinator { history, prefs, vocab, + correction_rules, inserter: TextInserter::new(), state: Mutex::new(SessionState::default()), asr: Mutex::new(None), @@ -218,6 +222,7 @@ impl Coordinator { }); let prefs = PreferencesStore::new().expect("preferences store init"); let vocab = DictionaryStore::new().expect("dictionary store init"); + let correction_rules = CorrectionRuleStore::new().expect("correction rule store init"); Self { inner: Arc::new(Inner { @@ -225,6 +230,7 @@ impl Coordinator { history, prefs, vocab, + correction_rules, inserter: TextInserter::new(), windows_ime: WindowsImeSessionController::new(), prepared_windows_ime_session: Arc::new(Mutex::new(Vec::new())), @@ -642,6 +648,9 @@ impl Coordinator { pub fn vocab(&self) -> &DictionaryStore { &self.inner.vocab } + pub fn correction_rules(&self) -> &CorrectionRuleStore { + &self.inner.correction_rules + } pub fn update_hotkey_binding(&self) { let prefs = self.inner.prefs.get(); diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 51512fba..b78b6c64 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -2,6 +2,7 @@ use std::sync::atomic::Ordering; use std::sync::Arc; use crate::coordinator_state::request_stop_during_starting_state; +use crate::correction::apply_correction_rules; use crate::types::HotkeyMode; use super::qa::handle_qa_option_edge; @@ -906,6 +907,25 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { return Err("ASR returned empty transcript".to_string()); } + let correction_rules = match inner.correction_rules.list() { + Ok(rules) => rules, + Err(e) => { + log::warn!("[coord] load correction rules failed: {e}; continue without correction"); + Vec::new() + } + }; + if !correction_rules.is_empty() { + let corrected = apply_correction_rules(&raw.text, &correction_rules); + if corrected != raw.text { + log::info!( + "[coord] correction rules adjusted raw transcript ({} → {} chars)", + raw.text.chars().count(), + corrected.chars().count() + ); + raw.text = corrected; + } + } + emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None); let prefs = inner.prefs.get(); @@ -988,6 +1008,19 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { } else { polished }; + let polished = if correction_rules.is_empty() { + polished + } else { + let corrected = apply_correction_rules(&polished, &correction_rules); + if corrected != polished { + log::info!( + "[coord] correction rules adjusted final text ({} → {} chars)", + polished.chars().count(), + corrected.chars().count() + ); + } + corrected + }; // 原子化最后一次 cancel 检查 + 转 Inserting: // 在同一 lock 内决定「丢弃」还是「进入 Inserting」。一旦设到 Inserting, @@ -1035,7 +1068,9 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { } #[cfg(not(target_os = "windows"))] { - inner.inserter.insert(&polished, restore_clipboard, paste_shortcut) + inner + .inserter + .insert(&polished, restore_clipboard, paste_shortcut) } } else { log::warn!( diff --git a/openless-all/app/src-tauri/src/correction.rs b/openless-all/app/src-tauri/src/correction.rs new file mode 100644 index 00000000..387135a7 --- /dev/null +++ b/openless-all/app/src-tauri/src/correction.rs @@ -0,0 +1,203 @@ +//! 用户自定义纠正规则。 +//! +//! 规则独立于词汇表:词汇表负责 ASR/LLM 热词提示,纠正规则负责在听写流水线里 +//! 做确定性的文本替换。当前只支持一个保守通配符 `{num}`,避免把任意正则暴露给 +//! 用户造成误替换。 + +use crate::types::CorrectionRule; + +const NUM_TOKEN: &str = "{num}"; + +pub fn apply_correction_rules(text: &str, rules: &[CorrectionRule]) -> String { + let mut current = text.to_string(); + for rule in rules { + if !rule.enabled { + continue; + } + let pattern = rule.pattern.trim(); + if pattern.is_empty() { + continue; + } + current = apply_rule(¤t, pattern, &rule.replacement); + } + current +} + +fn apply_rule(text: &str, pattern: &str, replacement: &str) -> String { + let token_count = pattern.matches(NUM_TOKEN).count(); + if token_count == 0 { + if replacement.contains(NUM_TOKEN) { + return text.to_string(); + } + return text.replace(pattern, replacement); + } + if token_count != 1 { + return text.to_string(); + } + apply_num_rule(text, pattern, replacement) +} + +fn apply_num_rule(text: &str, pattern: &str, replacement: &str) -> String { + let Some((prefix, suffix)) = pattern.split_once(NUM_TOKEN) else { + return text.to_string(); + }; + if prefix.is_empty() && suffix.is_empty() { + return text.to_string(); + } + + let mut output = String::with_capacity(text.len()); + let mut cursor = 0usize; + while cursor < text.len() { + let Some((match_start, token_start)) = next_prefix_match(text, cursor, prefix) else { + break; + }; + let Some(token_end) = consume_number_token(text, token_start) else { + output.push_str(&text[cursor..next_char_boundary(text, match_start)]); + cursor = next_char_boundary(text, match_start); + continue; + }; + let after_number = &text[token_end..]; + if !after_number.starts_with(suffix) { + output.push_str(&text[cursor..next_char_boundary(text, match_start)]); + cursor = next_char_boundary(text, match_start); + continue; + } + + let match_end = token_end + suffix.len(); + output.push_str(&text[cursor..match_start]); + output.push_str(&replacement.replace(NUM_TOKEN, &text[token_start..token_end])); + cursor = match_end; + } + output.push_str(&text[cursor..]); + output +} + +fn next_prefix_match(text: &str, cursor: usize, prefix: &str) -> Option<(usize, usize)> { + if prefix.is_empty() { + let match_start = next_number_start(text, cursor)?; + return Some((match_start, match_start)); + } + let relative = text[cursor..].find(prefix)?; + let match_start = cursor + relative; + Some((match_start, match_start + prefix.len())) +} + +fn next_number_start(text: &str, cursor: usize) -> Option { + text[cursor..] + .char_indices() + .find_map(|(offset, ch)| is_number_char(ch).then_some(cursor + offset)) +} + +fn consume_number_token(text: &str, start: usize) -> Option { + let mut end = start; + let mut consumed = false; + for (offset, ch) in text[start..].char_indices() { + if !is_number_char(ch) { + break; + } + consumed = true; + end = start + offset + ch.len_utf8(); + } + consumed.then_some(end) +} + +fn is_number_char(ch: char) -> bool { + ch.is_ascii_digit() + || matches!( + ch, + '零' | '〇' + | '一' + | '二' + | '两' + | '兩' + | '三' + | '四' + | '五' + | '六' + | '七' + | '八' + | '九' + | '十' + | '百' + | '千' + | '万' + | '萬' + | '亿' + | '億' + | '几' + | '幾' + ) +} + +fn next_char_boundary(text: &str, start: usize) -> usize { + text[start..] + .chars() + .next() + .map(|ch| start + ch.len_utf8()) + .unwrap_or(text.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rule(pattern: &str, replacement: &str) -> CorrectionRule { + CorrectionRule { + id: "rule".into(), + pattern: pattern.into(), + replacement: replacement.into(), + enabled: true, + created_at: String::new(), + } + } + + #[test] + fn applies_literal_replacement() { + let rules = vec![rule("几粒", "几例")]; + assert_eq!( + apply_correction_rules("这里有几粒样品", &rules), + "这里有几例样品" + ); + } + + #[test] + fn applies_num_wildcard_for_arabic_digits() { + let rules = vec![rule("{num}粒", "{num}例")]; + assert_eq!( + apply_correction_rules("2粒样品和10粒对照", &rules), + "2例样品和10例对照" + ); + } + + #[test] + fn applies_num_wildcard_for_chinese_numbers() { + let rules = vec![rule("{num}粒", "{num}例")]; + assert_eq!( + apply_correction_rules("两粒样品和幾粒对照", &rules), + "两例样品和幾例对照" + ); + } + + #[test] + fn disabled_rules_are_ignored() { + let mut disabled = rule("{num}粒", "{num}例"); + disabled.enabled = false; + assert_eq!(apply_correction_rules("10粒样品", &[disabled]), "10粒样品"); + } + + #[test] + fn malformed_rules_are_inert() { + let rules = vec![ + rule("{num}到{num}粒", "{num}例"), + rule("几粒", "{num}例"), + rule("{num}", "{num}例"), + ]; + assert_eq!(apply_correction_rules("几粒和10粒", &rules), "几粒和10粒"); + } + + #[test] + fn applies_rules_sequentially() { + let rules = vec![rule("{num}粒", "{num}例"), rule("样本", "样品")]; + assert_eq!(apply_correction_rules("10粒样本", &rules), "10例样品"); + } +} diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 1e47e16a..0f076f84 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -16,6 +16,7 @@ mod combo_hotkey; mod commands; mod coordinator; mod coordinator_state; +mod correction; mod global_hotkey_runtime; mod hotkey; mod insertion; @@ -157,8 +158,8 @@ pub fn run() { // 登录都被主窗口打扰。OPENLESS_SHOW_MAIN_ON_START=1 仍保留 // 老的强制 show 路径(手动 dispatch 测试 / dev 用),优先级高 // 于 prefs。 - let force_show = std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() - == Some("1"); + let force_show = + std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() == Some("1"); let suppress_show = !force_show && coordinator.prefs().get().start_minimized; if suppress_show { log::info!("[main] start_minimized=true → 跳过初始 show,等用户点托盘"); @@ -254,6 +255,10 @@ pub fn run() { commands::add_vocab, commands::remove_vocab, commands::set_vocab_enabled, + commands::list_correction_rules, + commands::add_correction_rule, + commands::remove_correction_rule, + commands::set_correction_rule_enabled, commands::list_vocab_presets, commands::save_vocab_presets, commands::start_dictation, @@ -1197,8 +1202,14 @@ mod tests { #[test] fn tray_style_menu_id_parsing_accepts_only_style_items() { - assert_eq!(parse_tray_polish_mode_id("style-raw"), Some(PolishMode::Raw)); - assert_eq!(parse_tray_polish_mode_id("style-light"), Some(PolishMode::Light)); + assert_eq!( + parse_tray_polish_mode_id("style-raw"), + Some(PolishMode::Raw) + ); + assert_eq!( + parse_tray_polish_mode_id("style-light"), + Some(PolishMode::Light) + ); assert_eq!( parse_tray_polish_mode_id("style-structured"), Some(PolishMode::Structured) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index 191fd2e6..f5984202 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -21,7 +21,9 @@ use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::types::{DictationSession, DictionaryEntry, UserPreferences, VocabPresetStore}; +use crate::types::{ + CorrectionRule, DictationSession, DictionaryEntry, UserPreferences, VocabPresetStore, +}; const HISTORY_CAP: usize = 200; const HISTORY_FILE: &str = "history.json"; @@ -29,6 +31,8 @@ const PREFERENCES_FILE: &str = "preferences.json"; /// 与 Swift `Sources/OpenLessPersistence/DictionaryStore.swift` 同名, /// 让旧版词汇表在升级后无缝继承。**不要**改成 `vocab.json`,会丢用户数据。 const VOCAB_FILE: &str = "dictionary.json"; +const CORRECTION_RULES_FILE: &str = "correction-rules.json"; +const CORRECTION_NUM_TOKEN: &str = "{num}"; const VOCAB_PRESETS_FILE: &str = "vocab-presets.json"; /// 旧版 plaintext JSON 凭据路径。仅作为迁移来源;成功写入系统凭据库后会删除。 @@ -1041,6 +1045,106 @@ pub fn save_vocab_presets(store: &VocabPresetStore) -> Result<()> { atomic_write(&path, &json) } +// ───────────────────────── CorrectionRuleStore ───────────────────────── + +pub struct CorrectionRuleStore { + path: PathBuf, + lock: Mutex<()>, +} + +impl CorrectionRuleStore { + pub fn new() -> Result { + let dir = data_dir()?; + ensure_dir(&dir)?; + Ok(Self { + path: dir.join(CORRECTION_RULES_FILE), + lock: Mutex::new(()), + }) + } + + pub fn list(&self) -> Result> { + let _guard = self.lock.lock(); + self.read_locked() + } + + pub fn add(&self, pattern: String, replacement: String) -> Result { + let pattern = pattern.trim().to_string(); + let replacement = replacement.trim().to_string(); + validate_correction_rule_syntax(&pattern, &replacement)?; + let _guard = self.lock.lock(); + let mut rules = self.read_locked()?; + let rule = CorrectionRule { + id: Uuid::new_v4().to_string(), + pattern, + replacement, + enabled: true, + created_at: Utc::now().to_rfc3339(), + }; + rules.insert(0, rule.clone()); + self.write_locked(&rules)?; + Ok(rule) + } + + pub fn remove(&self, id: &str) -> Result<()> { + let _guard = self.lock.lock(); + let mut rules = self.read_locked()?; + let before = rules.len(); + rules.retain(|r| r.id != id); + if rules.len() == before { + return Ok(()); + } + self.write_locked(&rules) + } + + pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<()> { + let _guard = self.lock.lock(); + let mut rules = self.read_locked()?; + let mut found = false; + for rule in rules.iter_mut() { + if rule.id == id { + rule.enabled = enabled; + found = true; + break; + } + } + if !found { + return Err(anyhow!("correction rule {} not found", id)); + } + self.write_locked(&rules) + } + + fn read_locked(&self) -> Result> { + read_or_default::>(&self.path) + } + + fn write_locked(&self, rules: &[CorrectionRule]) -> Result<()> { + let json = serde_json::to_vec_pretty(rules).context("encode correction rules failed")?; + atomic_write(&self.path, &json) + } +} + +fn validate_correction_rule_syntax(pattern: &str, replacement: &str) -> Result<()> { + if pattern.is_empty() { + return Err(anyhow!("correction rule pattern is empty")); + } + let pattern_token_count = pattern.matches(CORRECTION_NUM_TOKEN).count(); + if pattern_token_count > 1 { + return Err(anyhow!("unsupported correction rule syntax")); + } + if replacement.contains(CORRECTION_NUM_TOKEN) && pattern_token_count == 0 { + return Err(anyhow!("unsupported correction rule syntax")); + } + if pattern_token_count == 1 { + let Some((prefix, suffix)) = pattern.split_once(CORRECTION_NUM_TOKEN) else { + return Err(anyhow!("unsupported correction rule syntax")); + }; + if prefix.is_empty() && suffix.is_empty() { + return Err(anyhow!("unsupported correction rule syntax")); + } + } + Ok(()) +} + // ───────────────────────── CredentialsVault ───────────────────────── #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -1185,7 +1289,8 @@ impl CredentialsVault { #[cfg(test)] mod tests { use super::{ - chunk_json_payload, list_vocab_presets, save_vocab_presets, KEYRING_CHUNK_MAX_UTF16_UNITS, + chunk_json_payload, list_vocab_presets, save_vocab_presets, + validate_correction_rule_syntax, KEYRING_CHUNK_MAX_UTF16_UNITS, }; use crate::types::{VocabPreset, VocabPresetStore}; use std::fs; @@ -1236,4 +1341,14 @@ mod tests { assert_eq!(loaded.disabled_builtin_preset_ids, vec!["chef".to_string()]); let _ = fs::remove_dir_all(&tmp); } + + #[test] + fn correction_rule_syntax_rejects_silent_noops() { + assert!(validate_correction_rule_syntax("{num}粒", "{num}例").is_ok()); + assert!(validate_correction_rule_syntax("几粒", "几例").is_ok()); + assert!(validate_correction_rule_syntax("", "几例").is_err()); + assert!(validate_correction_rule_syntax("{num}", "{num}例").is_err()); + assert!(validate_correction_rule_syntax("{num}到{num}粒", "{num}例").is_err()); + assert!(validate_correction_rule_syntax("几粒", "{num}例").is_err()); + } } diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index e77612a3..5f658b24 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -119,6 +119,18 @@ pub struct DictionaryEntry { pub created_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CorrectionRule { + pub id: String, + pub pattern: String, + pub replacement: String, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub created_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VocabPreset { diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 608b5d18..fdfa9ba4 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -178,6 +178,17 @@ export const en: typeof zhCN = { tipDisabled: 'Click to disable this entry', tipEnabled: 'Click to enable this entry', removeAria: 'Remove', + corrections: { + title: 'Correction rules', + tip: 'Separate from hot words. Deterministically fixes common ASR mistakes; {num} matches numbers. Example: {num}粒 → {num}例.', + patternPlaceholder: 'Mistaken text, e.g. {num}粒', + replacementPlaceholder: 'Target text, e.g. {num}例', + empty: 'No correction rules yet.', + invalid: 'Only literal replacements or one {num} number wildcard are supported, for example {num}粒 → {num}例.', + tipDisabled: 'Click to disable this rule', + tipEnabled: 'Click to enable this rule', + removeAria: 'Remove correction rule', + }, presets: { title: 'Scenario presets', tip: 'Multi-select to apply in batch; supports edit/create and keeps local preset structure ready for future import/export.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 70361f8f..1fe51718 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -180,6 +180,17 @@ export const ja: typeof zhCN = { tipDisabled: 'クリックで無効化', tipEnabled: 'クリックで有効化', removeAria: '削除', + corrections: { + title: '補正ルール', + tip: 'ホットワードとは別に、ASR のよくある誤認識を文字列として補正します。{num} は数字に一致します。例:{num}粒 → {num}例。', + patternPlaceholder: '誤認識された表記(例:{num}粒)', + replacementPlaceholder: '修正後の表記(例:{num}例)', + empty: '補正ルールはまだありません。', + invalid: '文字列の置換、または {num} 数字ワイルドカードを 1 つだけ含むルールに対応しています。例:{num}粒 → {num}例。', + tipDisabled: 'クリックしてこのルールを無効化', + tipEnabled: 'クリックしてこのルールを有効化', + removeAria: '補正ルールを削除', + }, presets: { title: 'シーンプリセット', tip: '複数選択して一括有効化できます。編集と新規作成にも対応し、将来のインポート/エクスポート用のローカル構造を予約しています。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index ead25268..dec4a534 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -180,6 +180,17 @@ export const ko: typeof zhCN = { tipDisabled: '클릭하여 비활성화', tipEnabled: '클릭하여 활성화', removeAria: '삭제', + corrections: { + title: '교정 규칙', + tip: '핫워드와 별도로 ASR의 흔한 오인식을 문자 그대로 교정합니다. {num}은 숫자와 일치합니다. 예: {num}粒 → {num}例.', + patternPlaceholder: '오인식 표현, 예: {num}粒', + replacementPlaceholder: '대상 표현, 예: {num}例', + empty: '아직 교정 규칙이 없습니다.', + invalid: '문자 그대로 바꾸기 또는 {num} 숫자 와일드카드 1개가 포함된 규칙만 지원합니다. 예: {num}粒 → {num}例.', + tipDisabled: '이 규칙 비활성화', + tipEnabled: '이 규칙 활성화', + removeAria: '교정 규칙 삭제', + }, presets: { title: '시나리오 프리셋', tip: '여러 개 선택 후 일괄 활성화 가능. 편집과 새로 만들기 지원, 향후 가져오기/내보내기를 위한 로컬 구조가 예약되어 있습니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 81badd4f..8379d77d 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -176,6 +176,17 @@ export const zhCN = { tipDisabled: '点击禁用此词条', tipEnabled: '点击启用此词条', removeAria: '删除', + corrections: { + title: '纠正规则', + tip: '独立于热词,按字面修正 ASR 常见误识别;支持 {num} 通配数字。例如:{num}粒 → {num}例。', + patternPlaceholder: '误识别写法,如 {num}粒', + replacementPlaceholder: '目标写法,如 {num}例', + empty: '还没有纠正规则。', + invalid: '仅支持字面替换,或一个 {num} 通配数字的规则,例如 {num}粒 → {num}例。', + tipDisabled: '点击禁用此规则', + tipEnabled: '点击启用此规则', + removeAria: '删除纠正规则', + }, presets: { title: '场景预设', tip: '可多选后批量启用;支持编辑和新建,已为后续导入导出预留本地结构。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 5d16b941..c4d8f316 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -178,6 +178,17 @@ export const zhTW: typeof zhCN = { tipDisabled: '點擊禁用此詞條', tipEnabled: '點擊啓用此詞條', removeAria: '刪除', + corrections: { + title: '糾正規則', + tip: '獨立於熱詞,按字面修正 ASR 常見誤識別;支援 {num} 通配數字。例如:{num}粒 → {num}例。', + patternPlaceholder: '誤識別寫法,如 {num}粒', + replacementPlaceholder: '目標寫法,如 {num}例', + empty: '還沒有糾正規則。', + invalid: '僅支援字面替換,或一個 {num} 通配數字的規則,例如 {num}粒 → {num}例。', + tipDisabled: '點擊停用此規則', + tipEnabled: '點擊啟用此規則', + removeAria: '刪除糾正規則', + }, presets: { title: '場景預設', tip: '可多選後批量啓用;支持編輯和新建,已爲後續導入導出預留本地結構。', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index c64a91a9..4b347e4f 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -4,6 +4,7 @@ import type { ComboBinding, + CorrectionRule, CredentialsStatus, DictationSession, DictionaryEntry, @@ -151,6 +152,16 @@ const mockVocab: DictionaryEntry[] = OL_DATA.vocab.map((v, i) => ({ createdAt: new Date().toISOString(), })); +const mockCorrectionRules: CorrectionRule[] = [ + { + id: 'rule-quantity-classifier', + pattern: '{num}粒', + replacement: '{num}例', + enabled: true, + createdAt: new Date().toISOString(), + }, +]; + // ── Settings ─────────────────────────────────────────────────────────── export function getSettings(): Promise { return invokeOrMock('get_settings', undefined, () => mockSettings); @@ -274,6 +285,28 @@ export function setVocabEnabled(id: string, enabled: boolean): Promise { return invokeOrMock('set_vocab_enabled', { id, enabled }, () => undefined); } +export function listCorrectionRules(): Promise { + return invokeOrMock('list_correction_rules', undefined, () => mockCorrectionRules); +} + +export function addCorrectionRule(pattern: string, replacement: string): Promise { + return invokeOrMock('add_correction_rule', { pattern, replacement }, () => ({ + id: `rule-new-${Date.now()}`, + pattern, + replacement, + enabled: true, + createdAt: new Date().toISOString(), + })); +} + +export function removeCorrectionRule(id: string): Promise { + return invokeOrMock('remove_correction_rule', { id }, () => undefined); +} + +export function setCorrectionRuleEnabled(id: string, enabled: boolean): Promise { + return invokeOrMock('set_correction_rule_enabled', { id, enabled }, () => undefined); +} + export function listVocabPresets(): Promise { return invokeOrMock('list_vocab_presets', undefined, () => ({ custom: [], diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 8ab687af..56678d88 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -29,6 +29,14 @@ export interface DictionaryEntry { createdAt: string; } +export interface CorrectionRule { + id: string; + pattern: string; + replacement: string; + enabled: boolean; + createdAt: string; +} + export interface VocabPreset { id: string; name: string; diff --git a/openless-all/app/src/pages/Vocab.tsx b/openless-all/app/src/pages/Vocab.tsx index 3df4241a..98901f73 100644 --- a/openless-all/app/src/pages/Vocab.tsx +++ b/openless-all/app/src/pages/Vocab.tsx @@ -3,12 +3,35 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { addVocab, isTauri, listVocab, removeVocab, setVocabEnabled } from '../lib/ipc'; -import type { DictionaryEntry, VocabPreset } from '../lib/types'; +import { + addCorrectionRule, + addVocab, + isTauri, + listCorrectionRules, + listVocab, + removeCorrectionRule, + removeVocab, + setCorrectionRuleEnabled, + setVocabEnabled, +} from '../lib/ipc'; +import type { CorrectionRule, DictionaryEntry, VocabPreset } from '../lib/types'; import { DEFAULT_VOCAB_PRESETS, loadVocabPresets, persistVocabPresets } from '../lib/vocabPresets'; import { Btn, Card, PageHeader } from './_atoms'; const NEW_PRESET_DRAFT_ID = '__new__'; +const NUM_TOKEN = '{num}'; + +function isSupportedCorrectionRule(pattern: string, replacement: string) { + const tokenCount = pattern.split(NUM_TOKEN).length - 1; + if (!pattern) return false; + if (tokenCount > 1) return false; + if (replacement.includes(NUM_TOKEN) && tokenCount === 0) return false; + if (tokenCount === 1) { + const [prefix, suffix] = pattern.split(NUM_TOKEN); + return Boolean(prefix || suffix); + } + return true; +} export function Vocab() { const { t } = useTranslation(); @@ -22,6 +45,9 @@ export function Vocab() { const [editingPresetId, setEditingPresetId] = useState(null); const [presetNameDraft, setPresetNameDraft] = useState(''); const [presetPhrasesDraft, setPresetPhrasesDraft] = useState(''); + const [correctionRules, setCorrectionRules] = useState([]); + const [rulePatternDraft, setRulePatternDraft] = useState(''); + const [ruleReplacementDraft, setRuleReplacementDraft] = useState(''); const refresh = async () => { try { @@ -36,8 +62,22 @@ export function Vocab() { } }; + const refreshCorrectionRules = async () => { + try { + const data = await listCorrectionRules(); + setCorrectionRules(data); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + const refreshAll = () => { + void refresh(); + void refreshCorrectionRules(); + }; + useEffect(() => { - refresh(); + refreshAll(); void loadVocabPresets() .then(setPresets) .catch(err => setError(err instanceof Error ? err.message : String(err))); @@ -75,6 +115,44 @@ export function Vocab() { } }; + const onAddCorrectionRule = async () => { + const pattern = rulePatternDraft.trim(); + if (!pattern) return; + const replacement = ruleReplacementDraft.trim(); + if (!isSupportedCorrectionRule(pattern, replacement)) { + setError(t('vocab.corrections.invalid')); + return; + } + try { + const rule = await addCorrectionRule(pattern, replacement); + setCorrectionRules(prev => [rule, ...prev]); + setRulePatternDraft(''); + setRuleReplacementDraft(''); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const onRemoveCorrectionRule = async (id: string) => { + try { + await removeCorrectionRule(id); + setCorrectionRules(prev => prev.filter(r => r.id !== id)); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const onToggleCorrectionRule = async (rule: CorrectionRule) => { + const next = !rule.enabled; + setCorrectionRules(prev => prev.map(r => (r.id === rule.id ? { ...r, enabled: next } : r))); + try { + await setCorrectionRuleEnabled(rule.id, next); + } catch (err) { + setCorrectionRules(prev => prev.map(r => (r.id === rule.id ? { ...r, enabled: rule.enabled } : r))); + setError(err instanceof Error ? err.message : String(err)); + } + }; + const onRemove = async (id: string) => { await removeVocab(id); setEntries(prev => prev.filter(e => e.id !== id)); @@ -184,7 +262,7 @@ export function Vocab() { desc={t('vocab.desc')} right={
- {t('common.refresh')} + {t('common.refresh')}
} /> @@ -231,6 +309,43 @@ export function Vocab() { )} +
+
+
+ {t('vocab.corrections.title')} +
{t('vocab.corrections.tip')}
+
+
+ setRulePatternDraft(e.target.value)} + placeholder={t('vocab.corrections.patternPlaceholder')} + style={{ height: 32, padding: '0 10px', border: '0.5px solid var(--ol-line-strong)', borderRadius: 8, background: 'var(--ol-surface-2)' }} + /> + → + setRuleReplacementDraft(e.target.value)} + placeholder={t('vocab.corrections.replacementPlaceholder')} + style={{ height: 32, padding: '0 10px', border: '0.5px solid var(--ol-line-strong)', borderRadius: 8, background: 'var(--ol-surface-2)' }} + /> + void onAddCorrectionRule()}>{t('common.add')} +
+
+ {correctionRules.length === 0 && ( + {t('vocab.corrections.empty')} + )} + {correctionRules.map(rule => ( + void onToggleCorrectionRule(rule)} + onRemove={() => void onRemoveCorrectionRule(rule.id)} + /> + ))} +
+
+
void; + onRemove: () => void; +} + +function CorrectionRuleChip({ rule, onToggle, onRemove }: CorrectionRuleChipProps) { + const { t } = useTranslation(); + const enabled = rule.enabled; + return ( + + + + + ); +} + interface VocabChipProps { entry: DictionaryEntry; onRemove: () => void; From 0cc3a2412d63e798dddf4a76103eb384783f2349 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 11:05:50 +0800 Subject: [PATCH 27/50] docs: codify subagent-mediated API research workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-requested mechanism (/init args): when implementing against any external surface — third-party HTTP APIs, unfamiliar Rust crates / npm packages, or platform APIs (Apple Security framework / Win32 / Carbon / AppKit) — Claude must dispatch a dedicated subagent to fetch authoritative documentation rather than write integration code from training memory. The subagent acts as a network-search-and-retrieval layer that returns distilled, version-aware usage data so the main context stays uncluttered and the implementation always tracks the current upstream surface. Two surgical edits: 1. AGENTS.md §"Third-party service integrations" — heading widened to "Third-party service integrations & library / platform API research" so the existing 5-step research-first workflow explicitly covers library + platform APIs, not just HTTP service integrations. Rephrased the lead-in paragraph to enumerate the broader scope. 2. CLAUDE.md — new top-level section "调研先于编码:派子 agent 查 API / 库 / 平台文档" pointing back to AGENTS.md as the canonical rules and listing the concrete tooling Claude Code has available: Context7 MCP (resolve-library-id + query-docs), the documentation-lookup skill, and Agent dispatch with subagent_type=general-purpose. Specifies trigger conditions, non-trigger conditions (when grep is enough), the prompt fields every subagent dispatch must include, and the four canonical anti-patterns. No code changes; pure docs. --- AGENTS.md | 24 ++++++++++++++++++++++++ CLAUDE.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 15145daf..11b59cdb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,3 +167,27 @@ Windows release 链路修过四颗雷,每一颗的 fix 都是不可合并的 2. Register it in `lib.rs` (`mod ;`). 3. Wire it into `coordinator.rs` and expose any frontend-callable surface via `commands.rs` + `invoke_handler!`. 4. Add the matching TS wrapper in `openless-all/app/src/lib/ipc.ts` (with a mock branch for browser dev). + +### Third-party service integrations & library / platform API research + +When implementing features that depend on **anything outside this repo** — external HTTP APIs (ASR providers, polish endpoints, GitHub API), unfamiliar crates / npm packages, platform APIs (Apple Security framework, Win32, CoreFoundation), or any SDK whose surface shifts faster than your training cut-off — do not write integration code from memory. API surfaces drift; model training data is stale by definition. The same workflow below applies whether you are calling an HTTP endpoint, learning a new Rust crate, or wiring a system framework — substitute "endpoint URL" / "function signature" / "feature flag" as appropriate. + +Follow this research-first workflow: + +1. **Analyze before coding.** Identify every external call this feature needs: endpoint URL, HTTP method, authentication mechanism, request body schema, expected response schema, and error codes. +2. **Delegate web search to a sub-agent.** Spawn a read-only sub-agent whose sole job is to search for official documentation. The sub-agent runs in parallel — you continue other work instead of blocking on sequential web pages. +3. **Filter sub-agent results.** When the sub-agent returns, extract only the information directly relevant to the current implementation. Discard marketing pages, unrelated API versions, or tangential tutorials. +4. **Cross-verify one key finding.** Before writing code, validate at least one structural claim (endpoint URL, required header, auth format) with a direct `web_search` or `fetch_url` call. Sub-agents can hallucinate. +5. **Implement from verified documentation.** Only write integration code after the above steps. Never guess. + +**Sub-agent search brief:** +- Focus each sub-agent on a single external service or protocol — one service, one sub-agent. +- Prioritize official documentation domains (e.g., `docs.volcengine.com`, `platform.openai.com/docs`), falling back to the project's GitHub README. +- The sub-agent must return **structured** findings: endpoint URL, HTTP method, required headers, request body JSON Schema, response body JSON Schema, and error code meanings. +- If the documentation covers multiple API versions, the sub-agent must note which version was referenced. + +**Anti-patterns (do not do these):** +- ✗ Writing API integration code from memory without a documentation search. +- ✗ Pasting entire web pages into the main agent context — the sub-agent does the filtering. +- ✗ Mixing field names or endpoint paths from different API versions. +- ✗ Skipping error handling — every external call must degrade gracefully when the service is unavailable. diff --git a/CLAUDE.md b/CLAUDE.md index d99ec085..ddad74c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,3 +161,55 @@ If any step fails, do not announce the release; investigate `release-tauri.yml` 2. Register it in `lib.rs` (`mod ;`). 3. Wire it into `coordinator.rs` and expose any frontend-callable surface via `commands.rs` + `invoke_handler!`. 4. Add the matching TS wrapper in `openless-all/app/src/lib/ipc.ts` (with a mock branch for browser dev). + +## 调研先于编码:派子 agent 查 API / 库 / 平台文档 + +**完整规则在 [AGENTS.md `Third-party service integrations & library / platform API research`](AGENTS.md) 段落(line 171-191)。** 这里列的是 Claude Code 入场后用得上的具体工具映射。 + +### 触发条件 — 命中任一项都先派子 agent 调研,再下笔 + +- 第三方 HTTP API(ASR 厂家 / LLM 端点 / GitHub API / Tauri plugin 服务等) +- 不熟的 Rust crate / npm 包:连签名和 feature flag 都不确定时 +- 平台 API:Apple Security framework / CoreFoundation / Win32 / Carbon / AppKit +- 仓库 lock 文件锁着的某版本到底支持什么 — 训练记忆和 `Cargo.lock` / `package-lock.json` 实际版本可能不一致 +- 任何跟「训练 cutoff 之后才迭代过」相关的接口 + +### 不需要派子 agent + +- 仓库代码里已有现成调用 → `rg` / `grep` 找参考即可(仓库即文档) +- 通用编程 / 算法 / 自己能推导的语言特性 +- 单文件 surgical 改动且改动点的 API 已有用例 +- 查本仓库已有模块(`types.rs` / `coordinator.rs` 等)— 直接 Read + +### 工具优先级 + +```text +1. Context7 MCP(最高优先 — 主流库覆盖广,version-aware) + - mcp__context7__resolve-library-id → 拿 library id + - mcp__context7__query-docs → 当前版本的官方文档片段 + +2. documentation-lookup skill + /skill documentation-lookup —— 包装 Context7,含路由 + 缓存。 + +3. Agent 子 agent(subagent_type=general-purpose) + 场景:Context7 没覆盖(小众 crate / 新 SDK / 非英文文档), + 或需多源交叉(官方文档 + GitHub README + Stack Overflow)。 + 子 agent 用 WebFetch / WebSearch / Context7 综合,回 200-400 字结构化结果。 + +4. 单点兜底:直接 WebFetch 单页文档(只读最权威一篇时) +``` + +### 子 agent prompt 必备字段 + +1. **目标问题**:一句话讲清要解决的具体技术问题(不要"了解一下 X"这种空靶) +2. **本仓库现状**:当前 lock 着的版本(`Cargo.lock` / `package-lock.json` 拉一下)+ 现有调用点 `file:line`(若有) +3. **必须返回的结构**:函数/端点签名 → 最小可运行示例(≤20 行)→ **版本兼容范围**(vs 训练记忆的核心校验点)→ 已知坑 / 平台差异 / 弃用计划 +4. **禁令**:不改本仓库代码;不贴文档原文(distill 关键部分,避免上下文撑爆);多个独立服务分别派 agent — 一个服务一个 agent + +### 反例 + +- ✗ 凭训练记忆写第三方 API 调用,假定参数签名就这样 +- ✗ 把整段官方文档 paste 进主上下文 +- ✗ 先写代码再查文档 +- ✗ 单子 agent 同时调研 5 个不相关库(每个独立 prompt + 独立上下文) +- ✗ 子 agent 返回后跳过 cross-verify 直接写代码 — AGENTS.md 第 4 步要求至少用一次 `WebFetch` 直接命中官方源核对一项关键事实 From ce21d99f058935b86ebc7dca32cf3376402ffbb1 Mon Sep 17 00:00:00 2001 From: TRIP <1933142963@qq.com> Date: Sun, 10 May 2026 18:06:28 +0800 Subject: [PATCH 28/50] feat(settings): hide local ASR behind new Advanced tab + confirm dialog (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings): hide local ASR behind new Advanced tab + system confirm dialog Move local ASR options (local-qwen3 / foundry-local-whisper) out of the main Providers → ASR dropdown into a new "Advanced" Settings tab, so first-time users can't accidentally enable CPU inference and get slower / less-accurate transcription than they expected. Enabling local ASR now requires reading a strong yellow/amber warning and confirming a system-level dialog (@tauri-apps/plugin-dialog ask() with kind:'warning') — the in-page modal pattern wasn't loud enough. Behavior changes: - visibleAsrPresets now excludes local-qwen3 and foundry-local-whisper (the dropdown is cloud-only) - useEffect lookup uses ASR_PRESETS (all) so committedAsrProvider can still resolve to a local provider when one is active - When committedAsrProvider is local, the ASR Card replaces the dropdown with a plain notice ("Local ASR (X) is currently active — switch / disable in the Advanced tab"); existing LocalAsrProviderHint rendering for the Card body is preserved - New AdvancedSection component: - Strong warning block (amber background + amber border + amber text, fontWeight 500, ⚠️ icon) — meets user's "yellow/red obvious" ask - Per-platform engine selector: macOS : Qwen3-ASR only Windows: Qwen3-ASR + Foundry Local Whisper Linux : "Local ASR model integration is not supported on this platform" - Enable buttons trigger Tauri ask() confirm dialog before switching - When already on local: shows a "Disable local ASR" button (switches to volcengine, no confirm needed) plus the existing LocalAsrProviderHint for model status / download navigation - SettingsSectionId gains 'advanced'; SECTION_ORDER appends it after language so it sits last in the sidebar (matches macOS conventions for Advanced/Experimental panes) i18n: 5 locales (zh-CN, zh-TW, en, ja, ko) gain - settings.sections.advanced - settings.providers.localAsrActiveNotice - settings.advanced.{localAsrTitle, localAsrDesc, localAsrWarningStrong, qwen3Desc, foundryDesc, enable, alreadyActive, disableLocalLabel, disableLocalDesc, disable, platformNotSupported, confirmEnableLocalTitle, confirmEnableLocalBody} Test plan: - npm run build (tsc + vite) — green; the typeof zhCN constraint catches any locale that drifted out of shape - Manual: switch Settings tab to "Advanced", verify warning is amber-on-amber and clearly visible, click "Enable" on Qwen3-ASR, verify a system dialog (not a React modal) appears with kind:warning iconography, cancel — verify nothing changed; confirm — verify the Providers tab now shows "Local ASR is currently active" instead of the dropdown - Manual on macOS: confirm Foundry option does NOT appear - Manual on Linux (if available): confirm "platform not supported" message renders * feat(advanced): inline LocalAsr model UI + page-top confirm popup + concise warning User feedback: the standalone "Models" page (LocalAsr.tsx as a main nav tab) was redundant once Advanced settings exists, the inline amber warning was a wall of text, and clicking Enable triggered an OS-native dialog that didn't feel like part of the app. Refactor the whole local-ASR surface around what users actually need: - "Models" main-nav tab removed (FloatingShell + NAVIGATE_LOCAL_ASR_EVENT listener gone). Model management is now exclusively reachable via Settings → Advanced. - LocalAsr.tsx gains an `embedded` prop. When embedded, the outer page padding/height, PageHeader, and standalone warning Card are skipped — the host (AdvancedSection) supplies the chrome. - AdvancedSection no longer calls @tauri-apps/plugin-dialog ask(). Enabling local ASR sets pendingTarget which renders a single amber-bordered Card at the very top of the section with Confirm / Cancel buttons. One box, in-app, dismissable. Disable is direct (no confirm needed — going back to cloud is safe). - Persistent inline warning shrunk to one line. The longer wording (including the new "may drop words on under-spec hardware" risk the user asked for) lives in the popup body so it's only seen when actually consequential. - ProvidersSection ASR Card body returns null when active is local — the dropdown row's localAsrActiveNotice already points to Advanced, no need to duplicate manage UI here. i18n (5 locales): localAsrWarningStrong replaced by localAsrWarningShort; confirmEnableLocalBody / Title shortened; new `confirm` key for the popup CTA. LocalAsrProviderHint and NAVIGATE_LOCAL_ASR_EVENT in Settings.tsx are now unused but left in place to keep the diff focused; will clean up in a follow-up commit if pr_agent flags them. Test plan: - npm run build (tsc + vite) — green - Manual on macOS: Settings → Advanced shows one short amber line + Enable Qwen3-ASR button + LocalAsr model UI inline. Click Enable → page-top popup appears (no OS dialog), with Confirm / Cancel. Cancel → no change. Confirm → switches; Disable button appears, still single-click no confirm. - Manual: Providers → ASR with active=local-qwen3 shows only the "Local ASR is currently active — switch in Advanced" notice, no manage UI. * fix(advanced): pr_agent #400 follow-up — keep disable accessible on unsupported platform If a user enables local-qwen3 (or foundry-local-whisper) on a machine that supports it, then their preferences sync to a Linux/unsupported machine, the previous Advanced UI rendered only the "platformNotSupported" message and hid the disable button — they had no in-app path to switch back to cloud and dictation would fail silently on every press. Move the disable SettingRow out of the platformSupported branch. It's gated only on isOnAnyLocal now, so: - Supported platform + on local → enable Qwen3/Foundry rows + disable row - Supported platform + on cloud → enable rows only - Unsupported platform + on local → "not supported" notice + disable row - Unsupported platform + on cloud → "not supported" notice only This is the pr_agent advisory from PR #400 review @ d0a425a1. --------- Co-authored-by: baiqing --- .../app/src/components/FloatingShell.tsx | 18 +- openless-all/app/src/i18n/en.ts | 18 ++ openless-all/app/src/i18n/ja.ts | 18 ++ openless-all/app/src/i18n/ko.ts | 18 ++ openless-all/app/src/i18n/zh-CN.ts | 18 ++ openless-all/app/src/i18n/zh-TW.ts | 18 ++ openless-all/app/src/pages/LocalAsr.tsx | 56 +++-- openless-all/app/src/pages/Settings.tsx | 227 ++++++++++++++++-- 8 files changed, 349 insertions(+), 42 deletions(-) diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 0ff74b2f..96882c48 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -15,7 +15,8 @@ import { Vocab } from '../pages/Vocab'; import { Style } from '../pages/Style'; import { Translation } from '../pages/Translation'; import { SelectionAsk } from '../pages/SelectionAsk'; -import { LocalAsr } from '../pages/LocalAsr'; +// LocalAsr 不再作为主 nav tab——本地 ASR 模型管理已合并到 Settings → Advanced 中 +// 通过 渲染。这里之前的 import 与 NAV_BASE 条目都已移除。 import { APP_VERSION_LABEL, IS_BETA_BUILD } from '../lib/appVersion'; import { HOTKEY_MODE_MIGRATION_ACK_KEY, @@ -28,7 +29,7 @@ import { PROVIDER_SETUP_PROMPT_DEFERRED_KEY, shouldShowProviderSetupPrompt, } from '../lib/providerSetup'; -import { NAVIGATE_LOCAL_ASR_EVENT, type SettingsSectionId } from '../pages/Settings'; +import { type SettingsSectionId } from '../pages/Settings'; import { useAppState, type AppTab } from '../state/useAppState'; interface NavItem { @@ -45,7 +46,6 @@ const NAV_BASE: Array> = [ { id: 'style', icon: 'style', cmp: Style }, { id: 'translation', icon: 'translate', cmp: Translation }, { id: 'selectionAsk', icon: 'selectionAsk', cmp: SelectionAsk }, - { id: 'localAsr', icon: 'archive', cmp: LocalAsr }, ]; interface FloatingShellProps { @@ -128,15 +128,9 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia } }, []); - // Settings → ASR 选 local-qwen3 时的"前往模型设置"事件 → 关 modal + 切 tab。 - useEffect(() => { - const onNavigate = () => { - setSettingsOpen(false); - setCurrentTab('localAsr'); - }; - window.addEventListener(NAVIGATE_LOCAL_ASR_EVENT, onNavigate); - return () => window.removeEventListener(NAVIGATE_LOCAL_ASR_EVENT, onNavigate); - }, [setCurrentTab, setSettingsOpen]); + // 之前监听的 NAVIGATE_LOCAL_ASR_EVENT 已无意义——「模型设置」独立 tab 已下线, + // 模型管理 UI 现在通过 Settings → Advanced 的 渲染, + // 用户在 Settings 内即可一站式管理,无需跨页跳转。 const rememberProviderPrompt = () => { window.sessionStorage.setItem(PROVIDER_SETUP_PROMPT_DEFERRED_KEY, '1'); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index fdfa9ba4..1dea5474 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -294,6 +294,7 @@ export const en: typeof zhCN = { shortcuts: 'Shortcuts', permissions: 'Permissions', language: 'Language', + advanced: 'Advanced', about: 'About', }, recording: { @@ -381,6 +382,7 @@ export const en: typeof zhCN = { volcengineAccessKeyLabel: 'Access Token', volcengineResourceIdLabel: 'Resource ID', volcengineMappingNote: 'Secret Key is not required right now. Resource ID defaults to volc.bigasr.sauc.duration.', + localAsrActiveNotice: 'Local ASR ({{name}}) is currently active. Switch or disable it from the Advanced tab.', 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.', @@ -469,6 +471,22 @@ export const en: typeof zhCN = { notWindows: 'Only available on Windows.', }, }, + advanced: { + localAsrTitle: 'Local ASR models (experimental)', + localAsrDesc: 'Move transcription from cloud ASR to on-device inference. Offline / privacy-sensitive use only.', + localAsrWarningShort: 'Local ASR is several seconds slower than cloud, typically less accurate, and on under-spec hardware can drop words (output only part of your speech).', + qwen3Desc: 'Alibaba Qwen3-ASR, cross-platform. Models download from HuggingFace.', + foundryDesc: 'Microsoft Foundry Local Whisper. Windows only.', + enable: 'Enable', + alreadyActive: 'Active', + disableLocalLabel: 'Disable local ASR', + disableLocalDesc: 'Switch back to cloud ASR (defaults to Volcengine bigasr).', + disable: 'Disable', + platformNotSupported: 'Local ASR model integration is not supported on this platform.', + confirmEnableLocalTitle: 'Enable local ASR?', + confirmEnableLocalBody: 'Enabling "{{target}}" makes transcription several seconds slower than cloud and typically less accurate. On under-spec hardware it can drop words (output only part of your speech).', + confirm: 'Enable', + }, language: { title: 'Interface language', desc: 'Switch the UI language. Applies to the current session immediately and persists across launches.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 1fe51718..31dae950 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -296,6 +296,7 @@ export const ja: typeof zhCN = { shortcuts: 'ショートカット', permissions: '権限', language: '言語', + advanced: '詳細設定', about: '情報', }, recording: { @@ -383,6 +384,7 @@ export const ja: typeof zhCN = { volcengineAccessKeyLabel: 'Access Token', volcengineResourceIdLabel: 'Resource ID', volcengineMappingNote: 'Secret Key は現在不要。Resource ID のデフォルトは volc.bigasr.sauc.duration。', + localAsrActiveNotice: '現在「{{name}}」を使用中。「詳細設定」タブから切り替えまたは無効化できます。', localAsrHint: 'ローカル Qwen3-ASR は本機で実行されるため API Key 不要。HuggingFace からモデルをダウンロードすればすぐに利用できます。', foundryLocalAsrHint: 'Windows ローカル Whisper は本機で実行され、ASR API Key は不要です。初回使用時に Foundry Local ランタイムコンポーネントと Whisper モデルをダウンロードします。LLM 整文は引き続き設定済みの LLM プロバイダーを使用します。', localAsrPerformanceWarning: 'ローカル推論は CPU + Apple Silicon Accelerate で動作するため、1 回の転写時間は **クラウド ASR より数秒長くなります**。中国語認識精度や方言/訛り対応も **通常は** Volcengine / Whisper turbo に劣ります。ネットワーク制限下またはプライバシー重視の場合に選択してください。', @@ -471,6 +473,22 @@ export const ja: typeof zhCN = { notWindows: 'Windows のみ利用可能。', }, }, + advanced: { + localAsrTitle: 'ローカル ASR モデル(実験的)', + localAsrDesc: '転写をクラウドから本機推論に切り替えます。オフライン/プライバシー重視向け。', + localAsrWarningShort: 'ローカル ASR はクラウドより数秒遅く、精度も通常は低め。スペック不足のマシンでは欠字(音声の一部のみ出力)が起きる場合があります。', + qwen3Desc: 'Alibaba Qwen3-ASR、クロスプラットフォーム。モデルは HuggingFace からダウンロード。', + foundryDesc: 'Microsoft Foundry Local Whisper、Windows のみ。', + enable: '有効化', + alreadyActive: '有効', + disableLocalLabel: 'ローカル ASR を無効化', + disableLocalDesc: 'クラウド ASR(既定は Volcengine bigasr)に戻します。', + disable: '無効化', + platformNotSupported: 'このプラットフォームではローカル ASR モデル統合に対応していません。', + confirmEnableLocalTitle: 'ローカル ASR を有効化しますか?', + confirmEnableLocalBody: '「{{target}}」を有効化すると、転写はクラウドより数秒遅くなり、精度も通常は低くなります。スペック不足のマシンでは欠字(音声の一部のみ出力)が起きる場合があります。', + confirm: '有効化する', + }, language: { title: '表示言語', desc: 'UI の表示言語を切り替えます。現在のセッションに即時反映され、次回起動時も維持されます。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index dec4a534..5974c69e 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -296,6 +296,7 @@ export const ko: typeof zhCN = { shortcuts: '단축키', permissions: '권한', language: '언어', + advanced: '고급', about: '정보', }, recording: { @@ -383,6 +384,7 @@ export const ko: typeof zhCN = { volcengineAccessKeyLabel: 'Access Token', volcengineResourceIdLabel: 'Resource ID', volcengineMappingNote: 'Secret Key 는 현재 입력 불필요. Resource ID 기본값은 volc.bigasr.sauc.duration.', + localAsrActiveNotice: '현재 "{{name}}" 사용 중. "고급" 탭에서 전환 또는 비활성화할 수 있습니다.', localAsrHint: '로컬 Qwen3-ASR 은 본 기기에서 실행되며 API Key 가 필요 없습니다. HuggingFace 에서 모델을 로컬로 다운로드하면 즉시 사용 가능합니다.', foundryLocalAsrHint: 'Windows 로컬 Whisper 는 이 기기에서 실행되며 ASR API Key 가 필요 없습니다. 첫 사용 시 Foundry Local 런타임 구성 요소와 Whisper 모델을 다운로드합니다. LLM 정리는 계속 설정된 LLM 공급자를 사용합니다.', localAsrPerformanceWarning: '로컬 추론은 CPU + Apple Silicon Accelerate 에서 동작하므로, 한 번의 전사 시간이 **클라우드 ASR 보다 몇 초 더 걸립니다**. 중국어 인식 정확도와 방언/억양 대응도 **일반적으로** Volcengine / Whisper turbo 에 미치지 못합니다. 네트워크 제한 또는 프라이버시가 중요한 경우에 사용하세요.', @@ -471,6 +473,22 @@ export const ko: typeof zhCN = { notWindows: 'Windows 만 사용 가능.', }, }, + advanced: { + localAsrTitle: '로컬 ASR 모델 (실험적)', + localAsrDesc: '전사를 클라우드에서 로컬 추론으로 전환합니다. 오프라인 / 프라이버시용에만 권장됩니다.', + localAsrWarningShort: '로컬 ASR 은 클라우드보다 몇 초 느리고 정확도도 일반적으로 낮습니다. 사양이 부족한 기기에서는 글자 누락(음성의 일부만 출력)이 발생할 수 있습니다.', + qwen3Desc: 'Alibaba Qwen3-ASR, 크로스 플랫폼. 모델은 HuggingFace 에서 다운로드.', + foundryDesc: 'Microsoft Foundry Local Whisper, Windows 전용.', + enable: '활성화', + alreadyActive: '활성', + disableLocalLabel: '로컬 ASR 비활성화', + disableLocalDesc: '클라우드 ASR (기본 Volcengine bigasr) 로 돌아갑니다.', + disable: '비활성화', + platformNotSupported: '이 플랫폼에서는 로컬 ASR 모델 통합이 아직 지원되지 않습니다.', + confirmEnableLocalTitle: '로컬 ASR 을 활성화할까요?', + confirmEnableLocalBody: '"{{target}}" 을 활성화하면 전사가 클라우드보다 몇 초 느려지고 정확도도 일반적으로 낮아집니다. 사양이 부족한 기기에서는 글자 누락(음성의 일부만 출력)이 발생할 수 있습니다.', + confirm: '활성화', + }, language: { title: '인터페이스 언어', desc: 'UI 표시 언어를 전환합니다. 현재 세션에 즉시 반영되며 다음 실행에도 유지됩니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 8379d77d..f9c1fcf5 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -292,6 +292,7 @@ export const zhCN = { shortcuts: '快捷键', permissions: '权限', language: '语言', + advanced: '高级', about: '关于', }, recording: { @@ -379,6 +380,7 @@ export const zhCN = { volcengineAccessKeyLabel: 'Access Token', volcengineResourceIdLabel: 'Resource ID', volcengineMappingNote: 'Secret Key 当前无需填写。Resource ID 默认使用 volc.bigasr.sauc.duration。', + localAsrActiveNotice: '当前已启用「{{name}}」,可在「高级」中切换或禁用。', 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。请按需取舍:网络受限或对隐私敏感时再用本地。', @@ -467,6 +469,22 @@ export const zhCN = { notWindows: '仅 Windows 可用。', }, }, + advanced: { + localAsrTitle: '本地 ASR 模型(实验性)', + localAsrDesc: '把转写从云端切到本机推理。仅推荐离线 / 隐私敏感场景。', + localAsrWarningShort: '本地 ASR 比云端慢若干秒、准确率更低;配置不足时可能吞字(仅输出部分语音)。', + qwen3Desc: 'Alibaba Qwen3-ASR,跨平台。模型从 HuggingFace 下载。', + foundryDesc: 'Microsoft Foundry Local Whisper,仅 Windows。', + enable: '启用', + alreadyActive: '已启用', + disableLocalLabel: '禁用本地 ASR', + disableLocalDesc: '切回云端 ASR(默认火山引擎 bigasr)。', + disable: '禁用', + platformNotSupported: '该平台暂未支持本地 ASR 模型集成。', + confirmEnableLocalTitle: '启用本地 ASR?', + confirmEnableLocalBody: '启用「{{target}}」后,转写会比云端慢若干秒,准确率更低;如果机器配置不足,可能出现吞字(仅输出部分语音)。', + confirm: '确认启用', + }, language: { title: '界面语言', desc: '切换 UI 显示语言。当前会话即时生效,下次启动自动沿用。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index c4d8f316..349e2dcc 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -294,6 +294,7 @@ export const zhTW: typeof zhCN = { shortcuts: '快捷鍵', permissions: '權限', language: '語言', + advanced: '高級', about: '關於', }, recording: { @@ -381,6 +382,7 @@ export const zhTW: typeof zhCN = { volcengineAccessKeyLabel: 'Access Token', volcengineResourceIdLabel: 'Resource ID', volcengineMappingNote: 'Secret Key 當前無需填寫。Resource ID 默認使用 volc.bigasr.sauc.duration。', + localAsrActiveNotice: '當前已啓用「{{name}}」,可在「高級」中切換或停用。', 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。', @@ -469,6 +471,22 @@ export const zhTW: typeof zhCN = { notWindows: '僅 Windows 可用。', }, }, + advanced: { + localAsrTitle: '本地 ASR 模型(實驗性)', + localAsrDesc: '把轉寫從雲端切到本機推理。僅推薦離線 / 隱私敏感場景。', + localAsrWarningShort: '本地 ASR 比雲端慢若干秒、準確率更低;配置不足時可能吞字(僅輸出部分語音)。', + qwen3Desc: 'Alibaba Qwen3-ASR,跨平臺。模型從 HuggingFace 下載。', + foundryDesc: 'Microsoft Foundry Local Whisper,僅 Windows。', + enable: '啓用', + alreadyActive: '已啓用', + disableLocalLabel: '停用本地 ASR', + disableLocalDesc: '切回雲端 ASR(默認火山引擎 bigasr)。', + disable: '停用', + platformNotSupported: '該平臺暫未支持本地 ASR 模型集成。', + confirmEnableLocalTitle: '啓用本地 ASR?', + confirmEnableLocalBody: '啓用「{{target}}」後,轉寫會比雲端慢若干秒,準確率更低;如果機器配置不足,可能出現吞字(僅輸出部分語音)。', + confirm: '確認啓用', + }, language: { title: '界面語言', desc: '切換 UI 顯示語言。當前會話即時生效,下次啓動自動沿用。', diff --git a/openless-all/app/src/pages/LocalAsr.tsx b/openless-all/app/src/pages/LocalAsr.tsx index 9d5ace15..135a94a4 100644 --- a/openless-all/app/src/pages/LocalAsr.tsx +++ b/openless-all/app/src/pages/LocalAsr.tsx @@ -7,7 +7,7 @@ // - 监听 `local-asr-download-progress` 事件实时刷新进度 // - Win 端引擎不可用时禁用下载按钮,提示见 issue #256 -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { isTauri, setActiveAsrProvider } from '../lib/ipc'; import { @@ -61,7 +61,16 @@ interface RemoteSize { error: string | null; } -export function LocalAsr() { +interface LocalAsrProps { + /// `embedded=true` 表示作为子组件嵌入「高级」设置页(Settings → Advanced); + /// 此时跳过外层 page padding/height、PageHeader 与独立警告 Card —— 这些由 + /// 宿主 AdvancedSection 决定(包括把警告统一到页面顶部的浮层 popup 上)。 + /// `embedded=false`(默认)保留原全屏页样式,供 v 旧版本的独立「模型设置」 + /// 页面入口使用——但当前代码里该入口已删,本分支会一并移除。 + embedded?: boolean; +} + +export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { const { t } = useTranslation(); const { prefs, updatePrefs } = useHotkeySettings(); const [settings, setSettings] = useState(null); @@ -553,20 +562,35 @@ export function LocalAsr() { ? t('localAsr.foundryRetryPrepare') : t('localAsr.foundryPrepare'); - return ( -
- - - {/* 性能/质量预期警告 —— 用户硬要求要写清楚 */} - -
- ⚠️ {t('localAsr.performanceWarning')} + // embedded=true 嵌入「高级」设置:跳过外层 page padding/height、PageHeader, + // 与独立警告 Card——AdvancedSection 自己负责标题与短警告 + 启用时的浮层 popup, + // LocalAsr 只输出实际功能 Cards(Foundry / Qwen3 模型状态 / 模型列表)。 + const Wrapper = embedded + ? (props: { children: ReactNode }) => <>{props.children} + : (props: { children: ReactNode }) => ( +
+ {props.children}
- + ); + + return ( + + {!embedded && ( + + )} + + {!embedded && ( + /* 性能/质量预期警告 —— embedded 模式下由 AdvancedSection 自己渲染,避免重复。 */ + +
+ ⚠️ {t('localAsr.performanceWarning')} +
+
+ )} {IS_WINDOWS && ( @@ -873,7 +897,7 @@ export function LocalAsr() { /> ))}
-
+ ); } diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 252d2648..e9bac082 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -8,6 +8,7 @@ import { Icon } from '../components/Icon'; import { ShortcutRecorder } from '../components/ShortcutRecorder'; import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoUpdate'; import { detectOS } from '../components/WindowChrome'; +import { LocalAsr } from './LocalAsr'; import { APP_VERSION_LABEL } from '../lib/appVersion'; import { isHotkeyModeMigrationNoticeActive } from '../lib/hotkeyMigration'; import { @@ -80,9 +81,12 @@ interface SettingsProps { initialSection?: SettingsSectionId; } // "关于" tab 已移除(内容并入外层 SettingsModal 的 About 页,避免设置内外重复入口)。 -export type SettingsSectionId = 'recording' | 'providers' | 'shortcuts' | 'permissions' | 'language'; +export type SettingsSectionId = 'recording' | 'providers' | 'shortcuts' | 'permissions' | 'language' | 'advanced'; -const SECTION_ORDER: SettingsSectionId[] = ['recording', 'providers', 'shortcuts', 'permissions', 'language']; +// 「高级」放最末——本地推理 / 实验性开关都集中到这一栏,避免新手用户在主流程 +// 里误开 CPU 推理(之前提案:把 local-qwen3 / foundry-local-whisper 从主 ASR +// 下拉藏进高级)。位置末尾也是「实验性」语义在 macOS 系统偏好里的惯用位置。 +const SECTION_ORDER: SettingsSectionId[] = ['recording', 'providers', 'shortcuts', 'permissions', 'language', 'advanced']; async function autostartIsEnabled(): Promise { const { invoke } = await import('@tauri-apps/api/core'); @@ -195,6 +199,7 @@ export function Settings({ embedded = false, initialSection = 'recording' }: Set {section === 'shortcuts' && } {section === 'permissions' && } {section === 'language' && } + {section === 'advanced' && }
@@ -1240,8 +1245,10 @@ function ProvidersSection() { const [llmModelRevision, setLlmModelRevision] = useState(0); const [asrModelRevision, setAsrModelRevision] = useState(0); const os = detectOS(); + // 主 ASR 下拉只列云端选项;本地推理(local-qwen3 / foundry-local-whisper) + // 移到「高级」标签页,防止新手误开 CPU 推理。详见 AdvancedSection。 const visibleAsrPresets = ASR_PRESETS.filter( - p => p.id !== 'foundry-local-whisper' || os === 'win', + p => p.id !== 'foundry-local-whisper' && p.id !== 'local-qwen3', ); useEffect(() => { @@ -1250,7 +1257,10 @@ function ProvidersSection() { const llmId = knownLlm ? knownLlm.id : 'custom'; setLlmProvider(llmId); setCommittedLlmProvider(llmId); - const knownAsr = visibleAsrPresets.find(x => x.id === prefs.activeAsrProvider); + // ASR 在 ALL ASR_PRESETS 里查(不是 visibleAsrPresets)——本地选项虽然 + // 从下拉里藏起来了,但若用户曾在「高级」里启用过 local-qwen3,主 Card + // 仍要识别出 active 是本地,并切到「正在使用本地 ASR」的 notice 渲染。 + const knownAsr = ASR_PRESETS.find(x => x.id === prefs.activeAsrProvider); const asrId = knownAsr ? knownAsr.id : 'volcengine'; setAsrProvider(asrId); setCommittedAsrProvider(asrId); @@ -1368,15 +1378,26 @@ function ProvidersSection() {
{t('settings.providers.asrDesc')}
- + {committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper' ? ( + // active 是本地 ASR 时,下拉里没有它的 + ))} + + )} {committedAsrProvider === 'volcengine' ? ( <> @@ -1405,7 +1426,10 @@ function ProvidersSection() { ) : committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper' ? ( - + // 用户已经在用本地 ASR——dropdown 行的 localAsrActiveNotice 已经把 + // "在高级中切换或禁用"讲清楚了,body 不再重复 LocalAsrProviderHint。 + // 模型管理 UI 唯一入口在 AdvancedSection 里的 。 + null ) : ( <> @@ -1436,6 +1460,181 @@ function ProvidersSection() { ); } +/// 「高级」标签页——本地推理 / 实验性开关都集中在这里。 +/// +/// 设计: +/// - 主 Providers 下拉只列云端选项;本地推理 (local-qwen3 / foundry-local-whisper) +/// 完全收纳到此栏,新手不会在主流程里误开 CPU 推理。 +/// - 启用本地推理时弹出**页面顶部**的 React 浮层 popup(不再用 Tauri 系统对话框, +/// 也不再常驻一段长警告文本)——一个框、一段短话、确认/取消。 +/// - 旧的「模型设置」独立页 (LocalAsr.tsx 作为 nav tab) 已下线,模型管理 UI +/// 通过 内嵌在此处统一管理。 +/// +/// 平台可见性: +/// - macOS: 仅 Qwen3-ASR +/// - Windows: Qwen3-ASR + Foundry Local Whisper +/// - Linux: 「该平台暂未支持本地 ASR 模型集成」 +function AdvancedSection() { + const { t } = useTranslation(); + const { prefs, updatePrefs } = useHotkeySettings(); + const os = detectOS(); + const platformSupported = os === 'mac' || os === 'win'; + const switchSeqRef = useRef(0); + const [busy, setBusy] = useState(false); + // 待确认的启用目标。!== null 时浮层 popup 显示在页面顶部;用户点确认 → 真切; + // 点取消 → 回到 null。一次只允许一个 popup(用户的"只一个框"硬要求)。 + const [pendingTarget, setPendingTarget] = useState(null); + + const activeAsrProvider = (prefs?.activeAsrProvider ?? 'volcengine') as AsrPresetId; + const isOnLocalQwen3 = activeAsrProvider === 'local-qwen3'; + const isOnFoundry = activeAsrProvider === 'foundry-local-whisper'; + const isOnAnyLocal = isOnLocalQwen3 || isOnFoundry; + + const requestEnable = (target: AsrPresetId) => { + setPendingTarget(target); + }; + + const performSwitch = async (target: AsrPresetId) => { + setBusy(true); + const seq = ++switchSeqRef.current; + try { + await setActiveAsrProvider(target); + if (seq !== switchSeqRef.current) return; + if (prefs) { + await updatePrefs({ ...prefs, activeAsrProvider: target }); + } + } finally { + if (seq === switchSeqRef.current) { + setBusy(false); + setPendingTarget(null); + } + } + }; + + const pendingNameKey = + pendingTarget === 'local-qwen3' ? 'asrLocalQwen3' + : pendingTarget === 'foundry-local-whisper' ? 'asrFoundryLocalWhisper' + : null; + + return ( + <> + {/* ─── 页面顶部确认浮层 ──────────────────────────────────────────── + 用户硬要求:启用本地推理时**只**弹一个框、直接出现在页面最上方。 + 琥珀边框 + 琥珀字醒目;不阻塞滚动;点取消返回不切。 */} + {pendingTarget && pendingNameKey && ( + +
+ ⚠️ {t('settings.advanced.confirmEnableLocalTitle')} +
+
+ {t('settings.advanced.confirmEnableLocalBody', { + target: t(`settings.providers.presets.${pendingNameKey}`), + })} +
+
+ void performSwitch(pendingTarget)}> + {t('settings.advanced.confirm')} + + setPendingTarget(null)}> + {t('common.cancel')} + +
+
+ )} + + +
+
{t('settings.advanced.localAsrTitle')}
+
+ {t('settings.advanced.localAsrDesc')} +
+
+ + {/* 常驻短警告——一行字够了,详细解释留给点 Enable 后的页面顶部 popup。 */} +
+ ⚠️ {t('settings.advanced.localAsrWarningShort')} +
+ + {!platformSupported ? ( +
+ {t('settings.advanced.platformNotSupported')} +
+ ) : ( + <> + + requestEnable('local-qwen3')}> + {isOnLocalQwen3 ? t('settings.advanced.alreadyActive') : t('settings.advanced.enable')} + + + + {os === 'win' && ( + + requestEnable('foundry-local-whisper')}> + {isOnFoundry ? t('settings.advanced.alreadyActive') : t('settings.advanced.enable')} + + + )} + + )} + + {/* 「禁用本地 ASR」按钮放在 platformSupported 分支之外——若用户在另一台 + 支持本地推理的机器上启用过 local-qwen3 / foundry,preferences 同步到 + 当前 Linux/不支持平台后会卡死(无 enable / disable UI 可点),polish + 走老路径必失败。这里始终在 isOnAnyLocal 时露出 disable 入口,给用户 + 退路(PR #400 pr_agent advisory 的修法)。 */} + {isOnAnyLocal && ( + + void performSwitch('volcengine')}> + {t('settings.advanced.disable')} + + + )} +
+ + {/* 模型管理 UI(镜像源 / 模型列表 / 下载 / 删除 / 设为默认 / Foundry Local) + inline 渲染——「模型设置」独立页已删,这里是唯一入口。 */} + {platformSupported && } + + ); +} + type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error'; function ProviderTools({ kind, modelAccount, onModelSelected }: { kind: 'llm' | 'asr'; modelAccount: string; onModelSelected: () => void }) { From fcb13f5c23c89d0a8d94cf62334ea33682e0615a Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 15:45:31 +0800 Subject: [PATCH 29/50] chore: ignore video-materials/ promo recordings video-materials/ holds ~2.2 GB of promo recordings (promo / promo-materials / promo-openless / promo-openless-v2 / SC). These are local working assets that don't belong in the repo. Add to .gitignore so future stash / commit operations can't accidentally pull the binary blob in. --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 772b2eda..fde2ecf0 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ promo-openless/ promo-openless-v2/ docs/old-promo/ .worktrees/ +# 宣传录屏 / 素材根目录(GB 级二进制,绝不入版本库) +video-materials/ # 派生产物(兜底):项目曾出现 promo-openless-v2/node_modules 等遗漏, # 这里全局通配,避免某子目录漏配 .gitignore 时把 build artifact 推进 PR。 From a74a5472b0eaa73112b6747b10d7efce9f15fd0a Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 15:46:03 +0800 Subject: [PATCH 30/50] docs: 2026-05-10 audit + beta logic review snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot two audit artifacts produced 2026-05-10: - docs/audit-2026-05-10-validated.md — 21 audit items run against the live source tree at openless-all/app/src-tauri/src/ with a CONFIRMED / FALSE_POSITIVE / PARTIAL verdict per item plus the exact file:line range that supports each verdict. - docs/logic-review-2026-05-10.md — 5-path logic review of origin/beta @ 400097ad covering startup, press/release, end-of- session, capsule emit, and shutdown. Notable: at 400097ad the emit_capsule fix from PR #389 was NOT yet an ancestor, so the cpal audio-callback → AppKit/Win32 SIGTRAP risk was still live on that snapshot (subsequent merges have addressed it; this doc remains the dated record). Both files are pure snapshots — no code changes here. --- docs/audit-2026-05-10-validated.md | 700 +++++++++++++++++++++++++++++ docs/logic-review-2026-05-10.md | 159 +++++++ 2 files changed, 859 insertions(+) create mode 100644 docs/audit-2026-05-10-validated.md create mode 100644 docs/logic-review-2026-05-10.md diff --git a/docs/audit-2026-05-10-validated.md b/docs/audit-2026-05-10-validated.md new file mode 100644 index 00000000..ab69555f --- /dev/null +++ b/docs/audit-2026-05-10-validated.md @@ -0,0 +1,700 @@ +# Audit Validation — 2026-05-10 + +Validation of the 21 audit items against the current source tree at +`openless-all/app/src-tauri/src/`. Every CONFIRMED finding cites the +exact file and line range read from the working copy on 2026-05-10. + +## Summary + +| ID | Severity | Status | One-line | +|----|----------|--------|----------| +| 2.2.1 | — | CONFIRMED | TS `UserPreferences` interface and `mockSettings` both miss `updateChannel` | +| 3.1.1 | 严重 | CONFIRMED | `MacHotkeyAdapter` does not override `HotkeyAdapter::shutdown`; `CFRunLoopRun` runs forever | +| 3.1.2 | 中 | CONFIRMED | `hotkey_supervisor_loop` is `loop { ... sleep(3s) }` with no exit signal | +| 3.1.3 | 中 | CONFIRMED | `start_listener_thread` spawns the listener and drops the `JoinHandle` | +| 3.2.1 | 高 | FALSE_POSITIVE | Channel is `std::sync::mpsc::channel()` (unbounded async); `tx.send` does not block | +| 3.2.2 | 高 | CONFIRMED | `emit_capsule` calls `window.show()/hide()` from the cpal `process_callback` thread | +| 3.2.3 | 中 | CONFIRMED | `inner.inserter.insert(...)` runs sync `arboard`+`enigo` from async `end_session` | +| 3.2.4 | 中 | CONFIRMED | `AudioMuteGuard::activate` shells out to `osascript` / `wpctl` / `pactl` synchronously | +| 3.2.5 | 低 | CONFIRMED (Linux only) | `probe_input_stream` calls `thread::sleep(120ms)` from the async permission gate | +| 3.3.1 | 高 | CONFIRMED | `handle_pressed_edge` routes to QA when `panel_visible=true` regardless of dictation phase | +| 3.3.2 | 中 | PARTIAL | Two bridge loops both touch `state` for the same modifier event; non-fatal contention, no integrity bug | +| 3.3.3 | 中 | FALSE_POSITIVE | Cancelled doesn't reset coordinator latch, but the OS-side `Shared::trigger_held` already gates auto-repeat | +| 3.3.4 | 中 | CONFIRMED | `open_qa_panel` always emits `CapsuleState::Idle`, clobbering any in-flight dictation capsule | +| 3.3.5 | 低 | CONFIRMED | `finish_cancel_session_state` skips `focus_target = None` when `phase == Processing` | +| 3.3.6 | 低 | FALSE_POSITIVE | `take_current_prepared_windows_ime_session_for_restore` removes the slot on first call; second call is a true no-op | +| 3.4.1 | 中 (advisory) | ADVISORY_ONLY | `Inner` carries 16 `Mutex` + 4 `AtomicBool` fields (20 concurrent fields) | +| 3.4.2 | 中 (advisory) | ADVISORY_ONLY | 66 of 67 `Ordering` usages in coordinator/hotkey are `SeqCst` | +| 3.4.3 | 低 (advisory) | ADVISORY_ONLY | ~102 `unsafe`/`unsafe fn`/`unsafe impl`/`unsafe extern` sites; many lack SAFETY comments | +| 3.4.4 | 低 | CONFIRMED | `start_dispatcher` in `global_hotkey_runtime.rs` is `loop {}` with no exit | +| 20 (NEW) | — | FALSE_POSITIVE | `read_or_default` already falls back to `UserPreferences::default()` on decode failure; `expect()` only fires on filesystem errors | +| 2.3.3 | — | CONFIRMED (no action) | All four backend events (`capsule:state`, `qa:state`, `qa:level`, `vocab:updated`) have matching frontend listeners | + +**Tally**: 11 CONFIRMED · 4 FALSE_POSITIVE · 1 PARTIAL · 3 ADVISORY_ONLY · 1 CONFIRMED-no-action · 1 CONFIRMED-Linux-only + +## Recommended PR groupings + +Group by file to minimize merge conflict risk. Suggested order: + +1. **PR A — `hotkey.rs` lifecycle** (3.1.1, 3.1.3): add `MacHotkeyAdapter::shutdown` (post a synthetic `CFRunLoopStop`/`CFRunLoopWakeUp` from `Drop`) and store the listener `JoinHandle` so panics surface. Same file, same review. +2. **PR B — TS type alignment** (2.2.1): add `updateChannel: UpdateChannel` to `src/lib/types.ts` and `mockSettings` in `src/lib/ipc.ts`. One file pair, trivial. +3. **PR C — coordinator hotkey supervisor exits** (3.1.2, 3.4.4): add an `AtomicBool` shutdown flag to `hotkey_supervisor_loop` and `global_hotkey_runtime::start_dispatcher`. Same module concern, no overlap with PR A. +4. **PR D — async hygiene** (3.2.3, 3.2.4, 3.2.5): wrap `inserter.insert`, `AudioMuteGuard::activate`, and `probe_input_stream` in `tokio::task::spawn_blocking`. Touches `coordinator/dictation.rs`, `coordinator/resources.rs`, and `coordinator.rs` — coordinate with PR E to avoid shared-line conflicts. +5. **PR E — QA / dictation routing race** (3.3.1, 3.3.4): make `handle_pressed_edge` consult dictation phase before routing to QA, and skip the `Idle` capsule clobber in `open_qa_panel` when dictation is active. Same file (`coordinator/qa.rs` + `coordinator/dictation.rs`). +6. **PR F — capsule UI thread marshaling** (3.2.2): bounce `window.show()/hide()` through `app.run_on_main_thread`; emit-only path can stay (Tauri marshals events internally). Touches `coordinator.rs::emit_capsule`. Independent of PR E. +7. **PR G — focus_target leak fix** (3.3.5): in `finish_cancel_session_state`, also clear `focus_target` when the cancelled phase is `Processing`. Pure `coordinator_state.rs` edit. + +Advisory items (3.4.1 / 3.4.2 / 3.4.3) need no PR; they are tracked here for future hardening. + +## Detail per item + +### 2.2.1 — `updateChannel` missing in TS types +**Status**: CONFIRMED +**Files**: `openless-all/app/src-tauri/src/types.rs:216-219`, `openless-all/app/src/lib/types.ts:118-177`, `openless-all/app/src/lib/ipc.ts:46-79` + +**Evidence (Rust source of truth)**: +```rust +// types.rs:216-219 +/// Auto-update 渠道偏好。stable = 跟正式版(默认);beta = Settings 里多 +/// 一个手动下载 Beta 的入口。不影响 plugin-updater 的自动检查路径。 +#[serde(default)] +pub update_channel: UpdateChannel, +``` + +**Evidence (TS gap)**: `UserPreferences` ends at `startMinimized: boolean;` (line 176). No `updateChannel` field. `mockSettings` (ipc.ts:46-79) ends at `startMinimized: false,`. No `updateChannel` key. + +**Notes**: Channel state today is read/written via separate `getUpdateChannel` / `setUpdateChannel` IPC commands (`ipc.ts:170-176`), so `getSettings()` still works — the TS shape is just lying about what the Rust backend actually serializes. Setting via `setSettings(prefs)` round-trips through Rust's `UserPreferencesWire` which `#[serde(default)]`-fills the field, so currently no data corruption, but the type is incorrect and any consumer that destructures `UserPreferences` will silently miss the field. Trivial fix. + +--- + +### 3.1.1 — `MacHotkeyAdapter::shutdown` empty [严重] +**Status**: CONFIRMED +**Files**: `openless-all/app/src-tauri/src/hotkey.rs:111` (default), `:301-325` (`MacHotkeyAdapter` impl), `:419-458` (`run_listen_loop`), contrast `:769-776` (Windows shutdown). + +**Evidence (default)**: +```rust +// hotkey.rs:102-112 +pub trait HotkeyAdapter: Send + Sync { + fn kind(&self) -> HotkeyAdapterKind; + fn update_binding(&self, binding: HotkeyBinding); + fn update_modifier_shortcuts(...); + fn reset_held_state(&self); + fn shutdown(&self) {} +} +``` + +**Evidence (mac adapter impl is silent on `shutdown`)**: +```rust +// hotkey.rs:305-325 +impl HotkeyAdapter for MacHotkeyAdapter { + fn kind(&self) -> HotkeyAdapterKind { ... } + fn update_binding(&self, binding: HotkeyBinding) { ... } + fn update_modifier_shortcuts(...) { ... } + fn reset_held_state(&self) { reset_shared_held_state(&self.shared); } + // <-- no shutdown override +} +``` + +**Evidence (no exit path)**: +```rust +// hotkey.rs:454-457 +log::info!("[hotkey] CGEventTap 已启动"); +let _ = status_tx.send(Ok(())); +CFRunLoopRun(); +// CFRunLoopRun never returns absent CFRunLoopStop; the listener thread leaks. +``` + +**Notes**: `Drop for HotkeyMonitor` does call `self.adapter.shutdown()` (line 170-174), but Mac falls through to the empty default. On every preference-driven monitor swap the old `CFRunLoop` thread + tap leak. Visible on macOS as a steady leak of background threads on long-running sessions that change hotkey bindings. + +**Fix sketch**: Store the `CfRunLoopRef` returned by `CFRunLoopGetCurrent()` (currently captured in `run_listen_loop` only) in the `MacHotkeyAdapter`, plus the `CfMachPortRef`. On `shutdown`, call `CGEventTapEnable(tap, false)` then `CFRunLoopStop(rl)`. Both are FFI-safe to call from any thread. Mirror the Windows pattern (`PostThreadMessageW(thread_id, WM_QUIT)`). Storing those refs needs `CallbackContext` exposed to the adapter, easiest via shared `parking_lot::Mutex`. + +--- + +### 3.1.2 — `hotkey_supervisor_loop` no-exit +**Status**: CONFIRMED +**Files**: `openless-all/app/src-tauri/src/coordinator.rs:766-833` + +**Evidence**: +```rust +// coordinator.rs:766-833 +fn hotkey_supervisor_loop(inner: Arc) { + let mut attempts: u32 = 0; + let capability = HotkeyMonitor::capability(); + loop { + let prefs = inner.prefs.get(); + if inner.hotkey.lock().is_some() { return; } + // ... try start, on failure: + std::thread::sleep(std::time::Duration::from_secs(3)); + } +} +``` + +**Notes**: The only successful exit is when the hotkey is already installed (line 772-774). On error the supervisor keeps spinning. There is no `AtomicBool` / `Sender<()>` shutdown signal exposed for app shutdown. Same pattern repeats in `qa_hotkey_supervisor_loop`, `combo_hotkey_supervisor_loop`, `translation_hotkey_supervisor_loop`, `action_hotkey_supervisor_loop`. Worth a single shared shutdown flag. + +--- + +### 3.1.3 — `start_listener_thread` drops `JoinHandle` +**Status**: CONFIRMED +**Files**: `openless-all/app/src-tauri/src/hotkey.rs:196-229` + +**Evidence**: +```rust +// hotkey.rs:218-228 +let (status_tx, status_rx) = mpsc::channel::>(); +std::thread::Builder::new() + .name(thread_name.into()) + .spawn(move || run_listen_loop(thread_shared, tx, status_tx)) + .map_err(|e| install_error("spawn_failed", format!("hotkey 线程启动失败: {e}")))?; + +match status_rx.recv_timeout(Duration::from_secs(3)) { ... } +``` + +**Notes**: The `Result` from `spawn(...)` is `?`'d for the spawn error, but the `JoinHandle` itself is silently dropped (the spawn return value isn't bound). `ListenerThread` only stores `shared` and a single `startup` value. If the listener panics (e.g. `parking_lot::RwLock` poisoning, FFI bug), there is no path for the supervisor to learn about it — the channel just stops receiving. Storing the handle and using `JoinHandle::is_finished()` (Rust 1.61+) or pairing with a "thread alive" `AtomicBool` would let the supervisor restart the listener on panic. + +--- + +### 3.2.1 — Blocking `tx.send` in event-tap callback +**Status**: FALSE_POSITIVE +**Files**: `openless-all/app/src-tauri/src/hotkey.rs:183-187`, `:218`, `coordinator.rs:650`, `:781` + +**Evidence**: +```rust +// hotkey.rs:183-187 +fn send_or_log(tx: &Sender, evt: HotkeyEvent) { + if let Err(e) = tx.send(evt) { + log::warn!("[hotkey] 事件发送失败: {e}"); + } +} +``` + +```rust +// coordinator.rs:650 (and :781) +let (tx, rx) = mpsc::channel::(); +``` + +**Notes**: `std::sync::mpsc::channel()` is the **unbounded asynchronous** variant — `Sender::send` only blocks-and-fails when the receiver has been dropped, in which case it returns `Err(SendError)` immediately. There is no "rendezvous" backpressure. The only way `tx.send` would block long enough to trip `kCGEventTapDisabledByTimeout` is if std mpsc internally allocated under contention, which would be milliseconds at worst, not the seconds-scale macOS uses for the tap-disabled timeout. The audit conflated `mpsc::channel()` with `mpsc::sync_channel(0)` (rendezvous). No fix needed. + +--- + +### 3.2.2 — `emit_capsule` runs `show()/hide()` on cpal callback thread +**Status**: CONFIRMED +**Files**: `openless-all/app/src-tauri/src/coordinator/dictation.rs:336-365`, `coordinator.rs:3617-3660`, `recorder.rs:458-490` + +**Evidence (call site is the audio callback)**: +```rust +// coordinator/dictation.rs:336-365 +let level_handler: Arc = Arc::new(move |level| { + // ...phase guard, throttle to ~30 Hz... + emit_capsule( + &inner_for_level, + CapsuleState::Recording, + level, + elapsed, + None, + None, + ); +}); +// ... +match Recorder::start(microphone_device_name, consumer, level_handler) { ... } +``` + +**Evidence (callback thread is cpal's `process_callback`)**: +```rust +// recorder.rs:458-482 (process_callback) +fn process_callback(...) { + // ...resampling, RMS computation... + level_handler(level); // synchronously invoked from cpal audio thread +} +``` + +**Evidence (`emit_capsule` directly touches the window)**: +```rust +// coordinator.rs:3637-3656 +if let Some(window) = app.get_webview_window("capsule") { + let visible = !matches!(state, CapsuleState::Idle); + maybe_position_capsule_bottom_center(inner, &window, payload.translation); + if show_capsule && visible { + if !show_capsule_window_no_activate(&app, &window) { + let _ = window.show(); + } + // ... + } else { + hide_capsule_window_if_present(); + let _ = window.hide(); + } +} +let _ = app.emit_to("capsule", "capsule:state", payload); +``` + +**Notes**: `app.emit_to` is fine — Tauri's event bus is thread-safe and marshals to the JS runtime internally. The risk is `window.show() / window.hide()` and the position helper, all of which call NSWindow / HWND APIs that expect the main thread. On macOS this can stall the audio callback (NSWindow ops grab the AppKit run loop), risking `kAudioUnitErr_TooManyFramesToProcess` if the callback misses its deadline. The throttle to ~30 Hz mitigates frequency but doesn't change the per-call risk. Worth bouncing the window-touching half through `app.run_on_main_thread`. + +**Fix sketch**: Split `emit_capsule` into `emit_capsule_event` (just `app.emit_to`, safe everywhere) and `apply_capsule_window_state` (called inside `app.run_on_main_thread` or only from already-main paths). The level-handler path only needs the event; window show/hide already happens in begin/end/cancel which run on the tokio runtime where `run_on_main_thread` is cheap. + +--- + +### 3.2.3 — Sync inserter from async `end_session` +**Status**: CONFIRMED +**Files**: `openless-all/app/src-tauri/src/coordinator/dictation.rs:900-913`, `insertion.rs:42-56`, `:74-95`, `:136-149` + +**Evidence**: +```rust +// coordinator/dictation.rs:900-913 +#[cfg(not(target_os = "windows"))] +{ + inner.inserter.insert(&polished, restore_clipboard) +} +// ... +} else if allow_non_tsf_insertion_fallback { + inner.inserter.copy_fallback(&polished) +} +``` + +```rust +// insertion.rs:42-46 (non-macOS impl) +pub fn insert(&self, text: &str, restore_clipboard_after_paste: bool) -> InsertStatus { + if text.is_empty() { return InsertStatus::CopiedFallback; } + insert_with_clipboard_restore(text, restore_clipboard_after_paste) +} +``` + +**Notes**: `end_session` is `async`. On Linux (and on macOS too — `simulate_paste()` is FFI-light but still sync), `insert` calls `arboard::Clipboard::new()` which can block on X11/wayland for tens of ms, then `enigo` keystroke synthesis which is also sync. Blocking the tokio worker for 50–200 ms isn't catastrophic but contributes to latency under load. macOS path uses `CGEventPost` via FFI — fast, non-blocking in practice; mostly a Linux/Windows concern. + +**Fix sketch**: Wrap the platform-specific `inserter.insert` / `inserter.copy_fallback` in `tokio::task::spawn_blocking(move || ...).await.unwrap_or(InsertStatus::Failed)`. `TextInserter` is `Sync`, so the move only needs `&self` cloned via `Arc` (already in `Inner`). + +--- + +### 3.2.4 — `AudioMuteGuard` shells out synchronously +**Status**: CONFIRMED +**Files**: `openless-all/app/src-tauri/src/audio_mute.rs:127-152` (macOS), `:202-249` (Linux), `coordinator/resources.rs:112-131`, `coordinator/dictation.rs:369` + +**Evidence (macOS `osascript`)**: +```rust +// audio_mute.rs:143-151 +let output = Command::new("osascript") + .args(["-e", script]) + .output() + .map_err(|e| format!("set output mute failed: {e}"))?; +``` + +**Evidence (Linux `wpctl`/`pactl` — same shape)**: +```rust +// audio_mute.rs:215-223 +let output = Command::new("wpctl") + .args(["set-mute", "@DEFAULT_AUDIO_SINK@", value]) + .output() + .map_err(|e| format!("wpctl set-mute failed: {e}"))?; +``` + +**Evidence (called from async `begin_session`)**: +```rust +// coordinator/dictation.rs:369 +acquire_recording_mute(inner, "dictation"); +match Recorder::start(microphone_device_name, consumer, level_handler) { ... } +``` + +```rust +// coordinator/resources.rs:117-127 +if mute.holders == 0 { + match crate::audio_mute::AudioMuteGuard::activate() { + Ok(guard) => { mute.guard = Some(guard); ... } + Err(err) => { ... } + } +} +``` + +**Notes**: `osascript` typically takes 100–300 ms to spawn + execute on macOS (AppleScript runtime startup). On a hot-key press → begin_session, this delays the recording start by exactly that amount, on the tokio worker thread. Windows path uses native COM (`IAudioEndpointVolume::SetMute`) which is fast and OK. Linux `wpctl`/`pactl` is similar to macOS osascript in cost. + +**Fix sketch**: Wrap `AudioMuteGuard::activate()` in `tokio::task::spawn_blocking`. Since `acquire_recording_mute` itself is sync and called from `begin_session` (async), the cleanest path is making `acquire_recording_mute` async and `.await`-ing the spawn_blocking. Drop path (`PlatformMuteGuard::restore`) also runs `osascript` and is currently called from `Drop` in `release_recording_mute`; moving that to a detached `tokio::spawn_blocking` is sufficient (release path doesn't need to await). + +--- + +### 3.2.5 — `thread::sleep(120ms)` in async permission probe +**Status**: CONFIRMED (Linux/non-macOS path only) +**Files**: `openless-all/app/src-tauri/src/permissions.rs:323-357`, `coordinator/dictation.rs:137`, `coordinator.rs:1732-1763` + +**Evidence**: +```rust +// permissions.rs:343-356 +let stream = match sample_format { + SampleFormat::F32 => build_probe!(f32), + // ... +}?; +stream.play().map_err(|e| e.to_string())?; +std::thread::sleep(Duration::from_millis(120)); +drop(stream); +Ok(()) +``` + +**Evidence (called from async)**: +```rust +// coordinator/dictation.rs:137 +if let Err(message) = ensure_microphone_permission(inner) { ... } +``` + +```rust +// coordinator.rs:1732-1763 +fn ensure_microphone_permission(_inner: &Arc) -> Result<(), String> { + #[cfg(target_os = "windows")] { ... return Ok(()); } // Windows skips probe + let status = permissions::check_microphone(); + // ... +} +``` + +**Notes**: On macOS `check_microphone` uses `AVAudioApplication` / `AVCaptureDevice` and never reaches `probe_input_stream` (that helper is in the `cfg(not(target_os = "macos"))` module). So this 120 ms blocking sleep only fires on **Linux** (and any other non-macOS, non-Windows path) when probing mic permission. On Linux dictation, every `begin_session` blocks the tokio worker for 120 ms before the recorder even starts. + +**Fix sketch**: `tokio::time::sleep(Duration::from_millis(120)).await` is the correct call but requires turning `probe_input_stream` async. Alternatively keep it sync and wrap the whole `check_microphone()` non-macOS path in `tokio::task::spawn_blocking`. The latter is mechanically simpler. + +--- + +### 3.3.1 — Pressed edge routes to QA without checking dictation phase +**Status**: CONFIRMED +**Files**: `openless-all/app/src-tauri/src/coordinator/dictation.rs:11-22`, `coordinator/qa.rs:64-77` + +**Evidence**: +```rust +// coordinator/dictation.rs:11-22 +pub(super) async fn handle_pressed_edge(inner: &Arc) { + let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst); + if !was_held { + // 路由: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 { + handle_pressed(inner).await; + } + } +} +``` + +**Evidence (QA path doesn't stop dictation)**: +```rust +// coordinator/qa.rs:64-77 +pub(super) async fn handle_qa_option_edge(inner: &Arc) { + let phase = inner.qa_state.lock().phase; + log::info!("[coord] QA option edge (phase={phase:?})"); + match phase { + QaPhase::Idle => { let _ = begin_qa_session(inner).await; } + QaPhase::Recording => { let _ = end_qa_session(inner).await; } + QaPhase::Processing => {} + } +} +``` + +**Notes**: `panel_visible` flips true via `open_qa_panel`, which is triggered by the QA hotkey (`Cmd+Shift+;` by default). If the user opens the QA panel mid-dictation (dictation `phase = Listening`, mic open, ASR session live), the next dictation-hotkey press routes into `begin_qa_session`. `begin_qa_session` will call `Recorder::start` again on the same mic device. cpal will reject the second `build_input_stream` on most platforms, but on Linux/PipeWire it sometimes succeeds and you end up with two concurrent capture streams. Even where it fails, the dictation session's recorder is still running and the user has no UX path to stop it from the QA panel. + +**Fix sketch**: In `handle_pressed_edge`, check `inner.state.lock().phase`. If `Listening` or `Starting`, route to `handle_pressed` (which is the dictation toggle path) regardless of `panel_visible`, and either close the QA panel or refuse to open it while dictation is active. Decision belongs to product, but the *technical* race is real. + +--- + +### 3.3.2 — Dual TranslationModifier handlers +**Status**: PARTIAL +**Files**: `openless-all/app/src-tauri/src/coordinator.rs:1130-1139`, `:1376-1384`, `:1402-1410` + +**Evidence**: +```rust +// coordinator.rs:1130-1139 (translation_hotkey_bridge_loop, runs on its own thread) +fn translation_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } + if matches!(evt, ComboHotkeyEvent::Pressed) { + mark_translation_modifier_seen(&inner); + } + } +} + +// coordinator.rs:1402-1410 (hotkey_bridge_loop, separate thread) +HotkeyEvent::TranslationModifierPressed => { + let translation_hotkey = inner_cloned.prefs.get().translation_hotkey; + if is_builtin_translation_shift(&translation_hotkey) + || crate::shortcut_binding::legacy_modifier_trigger(&translation_hotkey) + .is_some() + { + mark_translation_modifier_seen(&inner_cloned); + } +} + +// coordinator.rs:1376-1384 +fn mark_translation_modifier_seen(inner: &Arc) { + let phase = inner.state.lock().phase; + if matches!(phase, SessionPhase::Starting | SessionPhase::Listening) { + inner.translation_modifier_seen.store(true, Ordering::SeqCst); + } +} +``` + +**Notes**: Both bridge loops run on independent `std::thread`s and both ultimately call `mark_translation_modifier_seen`, which locks `inner.state`. They never run *racing on integrity* — they both write the same `AtomicBool::store(true)`, idempotent. The audit's framing of "Both lock `inner.state` independently" is technically true but not a bug — `state` is a `Mutex`, only one acquires at a time, and both write the same flag. Worst case is one log-line of `[coord] translation modifier seen during ...` printed twice for one Shift press. Not worth a code change unless 3.3.1's fix touches the same code. + +--- + +### 3.3.3 — Cancelled doesn't reset `hotkey_trigger_held` +**Status**: FALSE_POSITIVE +**Files**: `openless-all/app/src-tauri/src/coordinator.rs:1399-1401`, `hotkey.rs:530-538`, `coordinator/dictation.rs:11-22` + +**Evidence**: +```rust +// coordinator.rs:1399-1401 +HotkeyEvent::Cancelled => { + cancel_session(&inner_cloned); +} +``` + +**Why it doesn't actually wedge**: `HotkeyEvent::Cancelled` is emitted by the OS-side hotkey monitor only when the user presses **Esc** (`hotkey.rs:565-570` for macOS, `:867-871` for Windows), not when the dictation trigger key is released. The dictation trigger's "is currently held" state lives in `Shared::trigger_held` inside the platform monitor (`hotkey.rs:117`). That atomic gates re-emission of Pressed via the `is_active && !was_held` check (`hotkey.rs:530-538`). So even if `Inner::hotkey_trigger_held` stays `true` in the coordinator after Esc, the next Pressed edge from the OS will only fire when the user releases and re-presses the trigger key — and the OS path will set `was_held=false` again before sending Pressed. The coordinator's `hotkey_trigger_held` swap on the next Pressed will return `false` (because `handle_released_edge` has run between the previous press and this one, OR the user never released, in which case no new Pressed is queued). + +The audit confused two layers: the OS-edge dedupe in `hotkey.rs::Shared::trigger_held` (which is the gating thing) and the coordinator's `Inner::hotkey_trigger_held` (which is just a bookkeeping latch tied to Pressed/Released edges that already came in). Cancelled doesn't change either's correctness. + +**Notes**: There's a *cosmetic* asymmetry — after Esc, `Inner::hotkey_trigger_held=true` until the user releases the trigger. If the user keeps holding past the Esc, `Released` fires later and resets it. Defensive cleanup would be to also reset on Cancelled, but it doesn't fix any user-visible bug. + +--- + +### 3.3.4 — `open_qa_panel` clobbers Done capsule +**Status**: CONFIRMED +**Files**: `openless-all/app/src-tauri/src/coordinator/qa.rs:79-104` + +**Evidence**: +```rust +// coordinator/qa.rs:79-104 +pub(super) fn open_qa_panel(inner: &Arc) { + { + let mut state = inner.qa_state.lock(); + state.panel_visible = true; + state.phase = QaPhase::Idle; + // ... + } + // 先把胶囊清干净,避免主听写上一次 Done 状态残留的 message/insertedChars + // 在 QA Done 阶段被 capsule UI 错误复用("已之一粘贴这个 0" 那种)。 + emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None); + // ... +} +``` + +**Notes**: The comment shows the design intent is *intentional* — sweep stale Done state from a previous dictation. But it sweeps *any* in-flight capsule too. If the user opens the QA panel within the ~1.5 s `CAPSULE_AUTO_HIDE_DELAY_MS` window after dictation finishes, they lose the "已粘贴 N 字" toast. More importantly, if dictation is still in `Polishing` or `Inserting` phase (LLM hasn't returned yet), opening QA hides the polish-progress capsule mid-flight. The user sees their dictation "vanish" until insertion completes. + +**Fix sketch**: Before calling `emit_capsule(Idle, ...)`, check `inner.state.lock().phase`. Only sweep if dictation is in `Idle`. If dictation is mid-flight, leave the capsule visible — QA panel doesn't need the capsule cleared to function. Pairs cleanly with the 3.3.1 fix (same source files). + +--- + +### 3.3.5 — `focus_target` leaks on Processing-phase cancel +**Status**: CONFIRMED +**Files**: `openless-all/app/src-tauri/src/coordinator_state.rs:155-173`, `:347-374` (test that already proves the gap) + +**Evidence**: +```rust +// coordinator_state.rs:168-173 +pub(crate) fn finish_cancel_session_state(state: &mut SessionState, decision: CancelDecision) { + if decision.phase != SessionPhase::Processing { + state.phase = SessionPhase::Idle; + state.focus_target = None; + } +} +``` + +**Evidence (existing test acknowledges the gap)**: +```rust +// coordinator_state.rs:370-372 +if matches!(initial, SessionPhase::Starting | SessionPhase::Listening) { + assert!(state.focus_target.is_none(), "initial={initial:?}"); +} +// Note: no assertion that Processing-phase cancel clears focus_target. +``` + +**Notes**: When cancel hits `Processing`, `finish_cancel_session_state` deliberately keeps `phase=Processing` (the comment says "交给 end_session 自己收尾"), but it also keeps `focus_target` populated. `end_session` does eventually reset state via the `proceed_to_insert=false` branch (`coordinator/dictation.rs:862-878`) which sets `phase=Idle` but doesn't touch `focus_target`. Net result: stale `focus_target` (a `Vec` index, basically a plain `usize`) lives on into the next `begin_session_state`, which overwrites it (`coordinator_state.rs:80`). So the leak is bounded — next session clobbers it. Real risk is only between cancel and next begin, where `restore_focus_target_if_possible(focus_target)` could pick up a stale value if anyone reads it. Code review didn't surface a reader on that interval, so this is a low-impact correctness gap, not a user-facing bug. + +**Fix sketch**: In `finish_cancel_session_state`, set `state.focus_target = None` unconditionally (before the phase check). The Processing branch's existing semantic — "let `end_session` collapse to Idle" — doesn't depend on `focus_target` staying set. + +--- + +### 3.3.6 — Double-restore of prepared Windows IME session +**Status**: FALSE_POSITIVE +**Files**: `openless-all/app/src-tauri/src/coordinator.rs:1594-1633` + +**Evidence**: +```rust +// coordinator.rs:1594-1602 +fn take_matching_prepared_windows_ime_session( + slots: &mut Vec, + session_id: SessionId, +) -> Option { + let index = slots + .iter() + .position(|slot| slot.session_id == session_id)?; + Some(slots.remove(index).prepared) +} +``` + +```rust +// coordinator.rs:1620-1633 +fn restore_prepared_windows_ime_session(inner: &Arc, session_id: SessionId) { + let state = inner.state.lock(); + let prepared = { + let mut slot = inner.prepared_windows_ime_session.lock(); + take_current_prepared_windows_ime_session_for_restore( + &mut slot, session_id, state.session_id, + ) + }; + if let Some(prepared) = prepared { inner.windows_ime.restore_session(prepared); } +} +``` + +**Notes**: First call to `restore_prepared_windows_ime_session` for a given `session_id` does `slots.remove(index)` regardless of the freshness check on `current_session_id`. The slot is gone after that. Second call's `slots.iter().position(...)` returns `None`, the `?` short-circuits, the function silently no-ops. So even if `cancel_session → end_session` (or vice versa) both invoke `restore_prepared_windows_ime_session` with the same `session_id`, the IME state is restored at most once. The audit's worry is unfounded. + +--- + +### 3.4.1 — Inner has many lock fields +**Status**: ADVISORY_ONLY +**Files**: `openless-all/app/src-tauri/src/coordinator.rs:91-141` + +**Inventory**: 16 `Mutex<...>` + 4 `AtomicBool` (excluding `Arc<...>` indirection counts) plus an `Arc>>` for the windows IME slot vector. Specifically: + +| Field | Type | +|---|---| +| `app` | `Mutex>` | +| `state` | `Mutex` | +| `asr` | `Mutex>>` | +| `recorder` | `Mutex>>` | +| `recording_mute` | `Mutex` | +| `hotkey` | `Mutex>` | +| `hotkey_status` | `Mutex` | +| `hotkey_trigger_held` | `AtomicBool` | +| `shortcut_recording_active` | `AtomicBool` | +| `combo_hotkey` | `Mutex>` | +| `translation_hotkey` | `Mutex>` | +| `switch_style_hotkey` | `Mutex>` | +| `open_app_hotkey` | `Mutex>` | +| `translation_modifier_seen` | `AtomicBool` | +| `qa_hotkey` | `Mutex>` | +| `qa_state` | `Mutex` | +| `capsule_layout` | `Mutex>` | +| `qa_asr` | `Mutex>>` | +| `qa_recorder` | `Mutex>` | +| `qa_stream_cancelled` | `Arc` (one of two AtomicBools-in-Arc) | +| `prepared_windows_ime_session` (windows-only) | `Arc>>` | + +No deadlock pattern was found in the read paths — most call sites take one lock at a time. `mark_translation_modifier_seen` and `cancel_session` both touch `inner.state`, but in disjoint critical sections. Documenting only. + +--- + +### 3.4.2 — Heavy `Ordering::SeqCst` use +**Status**: ADVISORY_ONLY +**Files**: `coordinator.rs`, `coordinator/*.rs`, `hotkey.rs` + +**Evidence**: `grep -rn "Ordering::SeqCst" coordinator.rs coordinator/ hotkey.rs | wc -l` → 66. Total `Ordering::*` uses in those files: 67 (one `Relaxed` in `recorder.rs::process_callback`, 66 SeqCst). + +**Notes**: Most uses are simple set/load on independent `AtomicBool`s where `Ordering::Relaxed` would suffice. A few that gate cross-thread visibility (`hotkey_trigger_held` swap in `handle_pressed_edge` synchronizing with the audio thread reading session state) might justify Acquire/Release. `SeqCst` is correct everywhere — just over-strong. Not a bug. + +--- + +### 3.4.3 — Many `unsafe` blocks, audit SAFETY comments +**Status**: ADVISORY_ONLY +**Files**: cross-tree (predominantly `hotkey.rs`, `insertion.rs`, `windows_ime_*.rs`, `permissions.rs`) + +**Evidence**: `grep -rn "unsafe " src/ --include="*.rs" | grep -E "unsafe \{|unsafe fn|unsafe impl|unsafe extern"` → 102 sites. + +**Notes**: Almost all are platform FFI (CoreFoundation/CoreGraphics on macOS, win32 on Windows, msg_send! on macOS objc2). Sample inspected (`insertion.rs::send_text` near line 340 and `post_cmd_v` near line 420) — function-level invariants are documented at module level, but inline `// SAFETY:` comments are sparse. Same for `hotkey.rs::run_listen_loop` which leaks `Box::into_raw` for FFI context — a `// SAFETY: ctx is dropped only inside the listener after CFRunLoopRun returns; reentrancy guarded by ...` comment would help. Documentation-grade improvement, no soundness bug detected. + +--- + +### 3.4.4 — Global hotkey dispatcher loop has no exit +**Status**: CONFIRMED +**Files**: `openless-all/app/src-tauri/src/global_hotkey_runtime.rs:94-107` + +**Evidence**: +```rust +// global_hotkey_runtime.rs:94-107 +fn start_dispatcher(runtime: Arc) { + std::thread::Builder::new() + .name("openless-global-hotkey-dispatch".into()) + .spawn(move || { + let receiver = GlobalHotKeyEvent::receiver(); + loop { + match receiver.recv_timeout(Duration::from_millis(250)) { + Ok(event) => runtime.dispatch(event), + Err(_) => continue, + } + } + }) + .expect("spawn global hotkey dispatcher"); +} +``` + +**Notes**: `GlobalHotKeyEvent::receiver()` is process-global from the upstream `global-hotkey` crate. The 250 ms timeout means the loop wakes regularly but never checks an exit flag. On app shutdown the thread leaks; harmless for a single-instance app but trips `tokio::test` and any future `Drop`-based teardown (e.g. integration tests that spin coordinator up/down). + +**Fix sketch**: Pair with `Inner` shutdown flag added in 3.1.2 fix, or use a `parking_lot::RwLock>` "dispatcher alive" gate inside `GlobalHotkeyRuntime` that the loop reads each iteration. Same shape as the Windows hotkey thread's `WM_QUIT` plumbing. + +--- + +### 20 (NEW) — `PreferencesStore::new().expect(...)` panics on bad prefs +**Status**: FALSE_POSITIVE +**Files**: `openless-all/app/src-tauri/src/coordinator.rs:169`, `:210`, `persistence.rs:790-811`, `:146-156`, `types.rs:368-422` + +**Evidence (deserialization fallback already exists)**: +```rust +// persistence.rs:790-811 +impl PreferencesStore { + pub fn new() -> Result { + let dir = data_dir()?; + ensure_dir(&dir)?; + let path = dir.join(PREFERENCES_FILE); + let prefs = if path.exists() { + read_or_default::(&path).unwrap_or_else(|e| { + log::warn!( + "[prefs] load {} failed, using defaults: {}", + path.display(), + e + ); + UserPreferences::default() + }) + } else { + UserPreferences::default() + }; + Ok(Self { path, state: Mutex::new(prefs), }) + } +} +``` + +```rust +// persistence.rs:146-156 (read_or_default) +fn read_or_default Deserialize<'de> + Default>(path: &Path) -> Result { + if !path.exists() { return Ok(T::default()); } + let bytes = fs::read(path).with_context(|| format!("read failed: {}", path.display()))?; + if bytes.is_empty() { return Ok(T::default()); } + serde_json::from_slice::(&bytes) + .with_context(|| format!("decode failed: {}", path.display())) +} +``` + +**Why the audit was wrong**: The custom `Deserialize for UserPreferences` (types.rs:368-422) does call `default_dictation_hotkey_from_legacy(...).map_err(serde::de::Error::custom)?` which can return a serde error for `trigger == Custom` without `customComboHotkey`. That error bubbles through `serde_json::from_slice::` to `read_or_default`, which propagates it as `Result::Err`. But `PreferencesStore::new` then catches it at `.unwrap_or_else(|e| { log::warn!(...); UserPreferences::default() })`. So the `expect("preferences store init")` at coordinator.rs:169 only fires if `data_dir()?` or `ensure_dir(&dir)?` fails — i.e. the OS-level Application Support directory cannot be created/accessed, which is a legitimate fail-fast condition (no preferences-file storage, no point continuing). + +The audit conflated "deserialization fails" with "PreferencesStore::new returns Err". In the current code those are different. + +**Notes**: No fix needed. The "bad prefs file" case is already handled silently (log + default). If you want belt-and-braces against panic on the truly-impossible filesystem failure, you can add a final `.unwrap_or_else` that logs and returns a fully-default in-memory store, but that's defensive coding for a case where the user's machine is so broken that Application Support is unwritable. + +--- + +### 2.3.3 — Event-name alignment between backend emit and frontend listen +**Status**: CONFIRMED (no action) +**Files**: `coordinator.rs:3659`, `coordinator/qa.rs:94-101`, `coordinator/dictation.rs:929-931`, `src/components/Capsule.tsx:293`, `src/pages/QaPanel.tsx:55,116`, `src/pages/Vocab.tsx:51` + +**Notes**: Backend emits `capsule:state`, `qa:state`, `qa:level`, `vocab:updated`. Frontend listens to all four under matching names (Capsule, QaPanel, Vocab respectively). No mismatch. As stated in the audit prompt — already verified, retained here for completeness. + +## Files referenced + +- `openless-all/app/src-tauri/src/types.rs` (lines 57, 200-525, especially 216-219, 277-325, 368-422) +- `openless-all/app/src-tauri/src/hotkey.rs` (lines 1-250, 280-572, 698-870, 1126-1190) +- `openless-all/app/src-tauri/src/coordinator.rs` (lines 91-141, 156-313, 640-833, 837-940, 1130-1139, 1376-1419, 1582-1636, 3617-3700) +- `openless-all/app/src-tauri/src/coordinator/dictation.rs` (lines 1-160, 320-410, 810-1050) +- `openless-all/app/src-tauri/src/coordinator/qa.rs` (full file, 1-124) +- `openless-all/app/src-tauri/src/coordinator/resources.rs` (lines 1-160) +- `openless-all/app/src-tauri/src/coordinator_state.rs` (full file, 1-485) +- `openless-all/app/src-tauri/src/global_hotkey_runtime.rs` (full file, 1-107) +- `openless-all/app/src-tauri/src/audio_mute.rs` (lines 1-263) +- `openless-all/app/src-tauri/src/permissions.rs` (lines 200-360) +- `openless-all/app/src-tauri/src/insertion.rs` (lines 1-150, 300-450) +- `openless-all/app/src-tauri/src/persistence.rs` (lines 146-156, 785-811) +- `openless-all/app/src-tauri/src/recorder.rs` (lines 28-490) +- `openless-all/app/src-tauri/src/commands.rs` (lines 975-1000) +- `openless-all/app/src/lib/types.ts` (lines 118-275) +- `openless-all/app/src/lib/ipc.ts` (lines 40-176) +- `openless-all/app/src/components/Capsule.tsx` (line 293) +- `openless-all/app/src/pages/QaPanel.tsx` (lines 55, 116-120) +- `openless-all/app/src/pages/Vocab.tsx` (line 51) diff --git a/docs/logic-review-2026-05-10.md b/docs/logic-review-2026-05-10.md new file mode 100644 index 00000000..d8a95bac --- /dev/null +++ b/docs/logic-review-2026-05-10.md @@ -0,0 +1,159 @@ +# OpenLess beta — Logic Review (commit 400097ad) + +Audit branch: `origin/beta` @ `400097ad` (= "Merge PR #391 fix/audit-async-hygiene"). +Sources: `openless-all/app/src-tauri/src/{lib.rs, coordinator.rs, coordinator/{dictation,qa,resources}.rs, coordinator_state.rs, hotkey.rs, audio_mute.rs}`, `openless-all/app/src/lib/{ipc.ts,types.ts}`. + +> Caller's premise correction: the prompt asserts "PR #389 (emit_capsule main thread) is pending merge but the same change is already cherry-picked-equivalent on the current code." This is **false** at `400097ad`. Commits `faf02ad4` and `84ee3d96` exist on a side branch but are NOT ancestors of `400097ad` (verified with `git merge-base --is-ancestor`). The audio-thread → AppKit/Win32 SIGTRAP risk that PR #389 was written to fix is therefore **still live on this beta**. See P4. + +## Summary + +| Path | Verdict | Issues found | +|------|---------|--------------| +| P1 startup | OK | All 6 listeners paired (start in `setup`/`Ready`, stop in `Exit`); tray watcher signaled. | +| P2 press/release | OK with 1 ⚠️ | Routing + dedup correct; `acquire_recording_mute` correctly awaited in dictation start. | +| P3 end-of-session | 1 🚩 | `cancel_session` during Processing leaves `focus_target` set until next `begin_session` overwrites it. PR #387 contract is incomplete for the Processing branch. | +| P4 capsule UI emit | 1 🚩 (CRITICAL) | `emit_capsule` calls `window.show/hide` + `show_capsule_window_no_activate` directly from the cpal audio callback at ~30 Hz on `400097ad`. PR #389 is **not** merged here. | +| P5 shutdown | 1 🚩 | `acquire_recording_mute` at QA path (`coordinator.rs:2313`) is missing `.await` — return value is a dropped Future. PR #391 hygiene fix is incomplete. Compiler emits `unused_must_use`. | + +Net: **3 real bugs (🚩)**, 1 cross-PR composition concern, plus minor smells. Two of the three are direct consequences of merge-incomplete state of PR #389/#391. The Processing-cancel `focus_target` leak is a code-path PR #387 missed. + +## Findings (per path) + +### P1 — Startup + +- OK `lib.rs:316-358` — `RunEvent::Exit` stops all 6 hotkey listeners + signals tray watcher; matches the 6 starts at `lib.rs:226` (dictation, in `setup`) and `lib.rs:320-325` (QA / combo / translation / switch_style / open_app, in `RunEvent::Ready`). +- OK `coordinator.rs:344-358, 371-373, 383-385, 395-397, 407-409, 1313-1347` — every `stop_*_listener` for `global-hotkey`-backed monitors marshals the `Drop` to `app.run_on_main_thread`, matching the issue #169 contract for Carbon `RemoveEventHotKey`. `take_combo_hotkey_on_main_thread` / `take_translation_hotkey_on_main_thread` / `take_action_hotkey_on_main_thread` are the helpers; `stop_qa_hotkey_listener` inlines the same pattern. +- OK `coordinator.rs:330-332` + `hotkey.rs:344-355` — dictation `stop_hotkey_listener` is a plain `inner.hotkey.lock().take()`, but the Drop chain is `HotkeyMonitor::drop` → `MacHotkeyAdapter::shutdown` → `CGEventTapEnable(false)` + `CFRunLoopStop(rl)`. PR #388 fix in place; the comment at `hotkey.rs:311-314` correctly notes both APIs are documented thread-safe, so the lack of main-thread marshalling here is intentional. +- ℹ️ `coordinator.rs:316-318` + `global_hotkey_runtime.rs:60-63` — `request_shutdown` is `#[allow(dead_code)]` and never set in production; supervisor loops poll it but nothing flips it (matches PR #392's "passive infrastructure" promise). +- ℹ️ `global_hotkey_runtime.rs:19, 41-55` — `GlobalHotKeyManager` lives in `OnceCell>` and is therefore never Dropped in production. On macOS this means the Carbon event handler is reaped only at process exit. Acceptable but worth noting if anyone tries to add hot-restart later. + +### P2 — Press/release + +- OK `coordinator/dictation.rs:11-28` — `handle_pressed_edge` swap-dedups via `inner.hotkey_trigger_held.swap(true, SeqCst)`; routing checks `panel_visible && !dictation_active` (PR #390 fix). The `dictation_active = !matches!(phase, SessionPhase::Idle)` snapshot is the correct guard: it lets a hotkey press during an in-flight dictation flow fall through to `handle_pressed`, even when the QA panel happens to be visible. +- OK `coordinator/dictation.rs:53-67` — `handle_released_edge` symmetric: `panel_visible && !dictation_active → return`. If dictation_active was true when pressed (so it routed to `handle_pressed`), released will also bypass the QA short-circuit and reach `handle_released`. No mismatched-edge leak. +- OK `coordinator/dictation.rs:30-51, 69-85` — Hold/Toggle phase matrix correct: `(Toggle, Idle) → begin`, `(Toggle, Listening) → end`, `(Toggle, Starting) → request_stop_during_starting`, `(Hold, Idle) → begin`, `(Hold, Listening released) → end`, `(Hold, Starting released) → request_stop_during_starting`. Other combinations no-op. +- OK `coordinator/dictation.rs:87-96` + `coordinator_state.rs:87-93` — `request_stop_during_starting_state` only flips `pending_stop` when phase is exactly `Starting`; `finish_starting_session_state` consumes the bit at the Listening transition (`coordinator_state.rs:118-124`) and triggers immediate `end_session` via `BeginOutcome::PendingStop` at `dictation.rs:587-590`. +- OK `coordinator/dictation.rs:451` — `acquire_recording_mute(inner, "dictation").await;` is properly awaited (PR #391 fix applied to dictation path). +- ⚠️ `coordinator/dictation.rs:418-447` (level_handler) — runs on the cpal audio callback thread and calls `emit_capsule` synchronously. See P4 for the concrete bug; flagged here because P2's recorder-start path is the producer. + +### P3 — End-of-session pipeline + +- OK `coordinator/dictation.rs:595-602` + `coordinator_state.rs:178-184` — `start_processing_if_listening` only transitions `Listening → Processing`; if phase is anything else (`Idle`, `Starting`, `Inserting`, already-Processing), `end_session` returns Ok(()) immediately. Guards against stale pending_stop or duplicate IPC. +- OK `coordinator/dictation.rs:607-620` — recorder + ASR are taken with session-id matching (`take_recorder_for_session` / `take_asr_for_session`), so a stale callback that lands after a session has been re-bumped won't pick up the wrong recorder. +- OK `coordinator/dictation.rs:984-1005` — atomic Inserting transition: same `state.lock()` checks `cancelled` and flips phase to `Inserting`. Once `Inserting`, `cancel_session` rejects (`coordinator_state.rs:155-159` — `Idle | Inserting` → `None`). This is the audit HIGH #2 contract. +- OK `coordinator/dictation.rs:1007-1031` — `paste_shortcut = prefs.paste_shortcut` flows into `inner.inserter.insert(&polished, restore_clipboard, paste_shortcut)` for non-Windows and into `insert_with_windows_ime_first(..., paste_shortcut, ime_target)` for Windows. PR #377 wiring confirmed; corresponding signature in `coordinator.rs:1673-1680, 1731-1745` and `insertion.rs:43-89`. +- OK `coordinator/dictation.rs:1122-1126` — happy-path end_session clears `state.focus_target = None` before scheduling capsule idle. + +- 🚩 `coordinator/dictation.rs:843-849` + `coordinator/dictation.rs:1153-1178` + `coordinator_state.rs:171-176` — **`focus_target` is not cleared when cancel hits during `Processing`.** + - `cancel_session` in the `Processing` branch (`dictation.rs:1171-1173`) deliberately does NOT call `finish_cancel_session_state`, leaving phase + focus_target as-is so `end_session` can finish unwinding. + - `end_session`'s "ASR-finished, cancelled" exit (`dictation.rs:845-849`) restores Windows IME, sets `phase = Idle`, returns Ok(()) — but never touches `focus_target`. + - PR #387 (`ce82fcd9`) was framed as "clear `focus_target` on cancel regardless of phase", but the only code path that gained the unconditional clear is `finish_cancel_session_state` at `coordinator_state.rs:172`, which the Processing branch skips. + - Concrete consequence: between cancel-mid-Processing and the next `begin_session`, the cached AX `focus_target` (a stale `usize` slot) is reachable by anyone reading `state.focus_target` (logs, debug dumps, future readers). It's overwritten by `begin_session_state` at `coordinator_state.rs:80`, so user-visible insertion uses the right value. Severity: minor leak / contract violation rather than user-visible breakage. Tests at `coordinator_state.rs:362-385` only validate the cancel happy paths via `finish_cancel_session_state`, so the regression slipped past PR #387's guard test. + +- ⚠️ `coordinator/dictation.rs:1175` — even when cancel fires during `Processing`, the user immediately sees `CapsuleState::Cancelled`, but `end_session` may still be inside the ASR await for several seconds. The phase is still `Processing` until `end_session` reaches the `state.cancelled` check, so a fast retry-press will be quietly dropped (`begin_session_state` requires `Idle`). Not a bug per se — matches the design comment at `dictation.rs:1169-1174` — but worth a UX note. + +- ⚠️ `coordinator/dictation.rs:1153-1163` — `cancel_session` swallows the result of `begin_cancel_session_state` for `Inserting`, only logging "cancel ignored". Acceptable, but there's no UI signal back to the user that their Esc didn't take. Minor. + +### P4 — Capsule UI emission + +- 🚩🚩 **`coordinator.rs:3684-3727`** — **`emit_capsule` does NOT marshal `window.show/hide` to the main thread on `400097ad`.** Verbatim from current source: + + ```rust + fn emit_capsule(...) { + ... + if let Some(window) = app.get_webview_window("capsule") { + ... + let visible = !matches!(state, CapsuleState::Idle); + maybe_position_capsule_bottom_center(inner, &window, payload.translation); + if show_capsule && visible { + if !show_capsule_window_no_activate(&app, &window) { + let _ = window.show(); + } + #[cfg(target_os = "macos")] + crate::restore_main_window_key_if_active(&app); + } else { + hide_capsule_window_if_present(); + let _ = window.hide(); + } + } + let _ = app.emit_to("capsule", "capsule:state", payload); + } + ``` + + This is the *pre-PR-#389* shape. PR #389's fix (`faf02ad4`, then `84ee3d96`) wraps the `if let Some(window)` block in `app.run_on_main_thread(move || { ... })`. Verified that neither commit is an ancestor of `400097ad`: + + ``` + $ git merge-base --is-ancestor faf02ad4 400097ad ; echo $? → 1 (NOT ancestor) + $ git merge-base --is-ancestor 84ee3d96 400097ad ; echo $? → 1 (NOT ancestor) + ``` + + Reproduction reasoning: `coordinator/dictation.rs:418-447` builds `level_handler` as `Arc` and hands it to `Recorder::start`. cpal calls it from the audio process callback thread; the handler then calls `emit_capsule(...)` (line 439-446). On macOS, `WebviewWindow::show()` / `hide()` and the `show_capsule_window_no_activate` (which calls `NSWindow.orderFrontRegardless`) hit AppKit assertions (`dispatch_assert_queue_fail` → SIGTRAP) when invoked off the main thread. The 33 ms throttle at `dictation.rs:417, 426-432` only limits frequency — every individual call is at the same thread-safety risk. + + The same risk applies to QA's level_handler at `coordinator.rs:2282-2309`, which also calls `emit_capsule` directly from the cpal callback (line 2301-2308). + + Severity: high. SIGTRAP would crash the app on long recordings; less catastrophic outcomes are stuttering audio (the audio callback misses its deadline waiting for AppKit) and `kAudioUnitErr_TooManyFramesToProcess`. PR #389 needs to land or be cherry-picked before this beta is shipped. + +- OK `coordinator.rs:3726` — `app.emit_to("capsule", "capsule:state", payload)` stays on the calling thread; Tauri's event bus is internally thread-safe. No change needed regardless of PR #389. +- OK `coordinator.rs:3739-3765` — `maybe_position_capsule_bottom_center` is the OS-level call inside the `if let Some(window)` block; it would be moved into the same `run_on_main_thread` closure once PR #389 lands. + +### P5 — App shutdown + +- OK `lib.rs:347-355` (RunEvent::Exit) — calls `stop_hotkey_listener` (Mac CGEventTap path, safe to invoke any thread per `hotkey.rs:344-355`), plus the 5 `global-hotkey`-backed `stop_*_listener`s that all marshal via `app.run_on_main_thread` (`coordinator.rs:344-358, 1313-1347`). +- OK `lib.rs:348` — `TRAY_MICROPHONE_WATCHER_STOPPING.store(true, Relaxed)` correctly signals the watcher loop spawned at `lib.rs:540-548`. +- ⚠️ `coordinator.rs:344-358, 1313-1347` — `app.run_on_main_thread` is fire-and-forget; the queued `Drop` may not run before the process exits. In practice this is fine (process exit reaps everything), but if Tauri's main-loop teardown beats the queued closure, Carbon `RemoveEventHotKey` is skipped. Same model as the pre-existing PR #169 fix for `qa_hotkey`, so flagging as an inherited limitation, not a regression. + +- 🚩 **`coordinator.rs:2313` — `acquire_recording_mute(inner, "qa");` is missing `.await`.** + - PR #391 (`6171df61`) made `acquire_recording_mute` `async fn` (`coordinator/resources.rs:122`) and updated the dictation call site to `.await` (`coordinator/dictation.rs:451`). The QA call site was missed. + - Effect: the function returns an `impl Future` that is dropped on the next line. `spawn_blocking` is never scheduled, so `mute.holders` doesn't increment, the system audio mute is never engaged for QA, and the `[audio-mute] acquired by qa` log is never written. The corresponding `release_recording_mute(inner, "qa")` calls (e.g. `resources.rs:194`, `coordinator.rs:2324`) decrement holders that were never incremented (early `return` at `resources.rs:174` because `holders == 0`). + - Compiler confirms it: `cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml` emits + + ``` + warning: unused implementer of `futures_util::Future` that must be used + --> src/coordinator.rs:2313:5 + 2313 | acquire_recording_mute(inner, "qa"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = note: futures do nothing unless you `.await` or poll them + ``` + + - User-visible consequence: when the user opted into "Mute system output during recording" and triggers QA via Option, system audio is NOT muted (e.g. a YouTube playback continues). Dictation behaves correctly. Fix: insert `.await` on the call. + +- OK `coordinator/resources.rs:184-188` — `release_recording_mute` falls back to synchronous `work()` when no tokio handle is present, so the recorder error monitor (a plain `std::thread::spawn`) can release safely. Drop of `AudioMuteGuard` shells out to `osascript` / `wpctl`, blocking on a std thread is OK. + +## Cross-PR composition risks + +1. **PR #391 + #389 incomplete merge.** The current `400097ad` contains PR #391 (which added the async hygiene the rest of the audit fixes assume) but is missing PR #389. Net result: the audio-thread → AppKit risk PR #389 fixes is still present *and* the QA codepath has a half-applied PR #391 (missing `.await` at `coordinator.rs:2313`). Both must land before this commit becomes a release candidate. +2. **PR #387 + Processing branch interaction.** PR #387 introduced `state.focus_target = None` inside `finish_cancel_session_state`, but `cancel_session` deliberately skips that helper for `Processing` (`dictation.rs:1171-1173`) so the scheduled `end_session` can drive its own teardown. `end_session`'s cancelled-after-ASR exit (`dictation.rs:845-849`) was not updated to clear `focus_target`. The contract "clear focus_target on cancel regardless of phase" is therefore violated for cancel-during-Processing. Fix can be either (a) clear `focus_target` in the cancel-after-ASR branch of `end_session`, or (b) move the clear into `cancel_session` even for Processing (does not interfere with `end_session`'s own writes). +3. **PR #390 + multi-bridge `hotkey_trigger_held`.** The dedup atomic is process-global. With both `hotkey` and `combo_hotkey` monitors running, the legacy modifier-only adapter and the custom-combo adapter share `inner.hotkey_trigger_held`. Currently exclusive (custom-combo only runs when trigger == Custom — see `coordinator.rs:413-426`), so no cross-contamination, but anything that allows them to coexist would corrupt the dedup. ℹ️ informational. +4. **PR #392 (passive flag) is dormant**, as the prompt indicates. No interaction risk; calling out only that supervisor loops gracefully ignore `shutdown=false` so future RunEvent::Exit hookup is safe to land in a follow-up PR. + +## Manual-verification checklist for the user + +After cherry-picking PR #389 + fixing the missing `.await` in P5, verify on a running build: + +**P4 — capsule main-thread (PR #389 confirmation)** +- [ ] macOS arm64 build, dev profile, run a 3-minute continuous toggle dictation. App must not crash with SIGTRAP / `dispatch_assert_queue_fail`. Tail `~/Library/Logs/OpenLess/openless.log` while recording. +- [ ] On macOS, capsule still appears once the first PCM frame is captured (50–200 ms after Recorder::start) and disappears 1.5 s after Done/Cancelled/Error. +- [ ] On Windows, no SendMessage deadlock against the GUI thread during recording start/stop (capsule transitions complete within ~50 ms of phase changes). + +**P5 — QA mute fix** +- [ ] Set `prefs.muteDuringRecording = true` (Settings → Recording). +- [ ] Play YouTube in Safari/Chrome. +- [ ] Open QA panel via `Cmd+Shift+;`. Press Right Option to start QA recording. Audio playback **must** mute. Log line `[audio-mute] acquired by qa; holders=1` must appear in `openless.log`. (Without the fix, no log line + audio keeps playing.) +- [ ] Release Right Option. Audio playback resumes; log line `[audio-mute] released by qa; holders=0` + `system output mute restored after recording` must appear. + +**P3 — focus_target on Processing-cancel (PR #387 completeness)** +- [ ] Start dictation, speak briefly, release hotkey to enter Processing. While ASR is awaiting result (within ~1 s window), press Esc to trigger `cancel_dictation`. End_session bails at `cancelled` check. +- [ ] Inspect debug logs / state dump (or add a temporary log at `dictation.rs:849`): `state.focus_target` should be `None`. With current `400097ad` it remains `Some(...)`. +- [ ] Start a fresh dictation in a different window. Insertion should still target the new window — this works today via `begin_session_state` overwrite (`coordinator_state.rs:80`), so the bug is silent until something else reads stale `focus_target` between the cancel and the next begin. + +**P2 — pressed/released routing (PR #390 confirmation)** +- [ ] Open QA panel (`Cmd+Shift+;`). Confirm Option starts QA recording. +- [ ] Close QA panel. Start a normal dictation (Option). While dictation is running, open QA panel via `Cmd+Shift+;` (panel becomes visible while dictation_active=true). Press and release Option once — dictation must end normally (insert text), QA must NOT capture this Option edge. +- [ ] Hold mode: Set HotkeyMode::Hold. Hold Option for 2 s, release. End_session must trigger on release (not on press). +- [ ] Toggle mode: Set HotkeyMode::Toggle. Tap Option twice rapidly during Starting phase (within the 50–200 ms cpal init window) — verify `request_stop_during_starting` queues then end_session fires on the Listening transition (search log for `applying pending_stop edge → end_session immediately`). + +**P1 — listener teardown sanity** +- [ ] Quit the app via tray menu. `openless.log` should show ordered `stop_*_listener` calls; no panic / SIGTRAP at exit. +- [ ] Relaunch and grant Accessibility (after prior reset). Confirm `[hotkey] CGEventTap 已启动` returns within 3 s; first hotkey press still works without app restart. + +If any of the P4/P5 checks fail, this beta is **not** build-quality. From f3894c1f7be8c1bd8772483fef936316398bf1e5 Mon Sep 17 00:00:00 2001 From: TRIP <1933142963@qq.com> Date: Sun, 10 May 2026 18:18:24 +0800 Subject: [PATCH 31/50] feat(llm): Google Gemini provider + fix LLM provider switch (#398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: ignore video-materials/ promo recordings video-materials/ holds ~2.2 GB of promo recordings (promo / promo-materials / promo-openless / promo-openless-v2 / SC). These are local working assets that don't belong in the repo. Add to .gitignore so future stash / commit operations can't accidentally pull the binary blob in. * feat(llm): Google Gemini provider with forced thinking-off + fix provider-switch credentials Add Google Gemini as a native LLM provider hitting v1beta :generateContent (single-shot) and :streamGenerateContent?alt=sse (QA chat) with x-goog-api-key auth. Per-model thinkingConfig is injected to keep thinking off for every supported ID — exact matrix verified against ai.google.dev/gemini-api/docs/thinking: gemini-2.5-flash, gemini-2.5-flash-lite thinkingBudget: 0 gemini-2.5-pro field omitted (Google: cannot disable) gemini-3*-pro* thinkingLevel: "low" gemini-3*-flash*, unknown future IDs thinkingLevel: "minimal" Why native, not the OpenAI-compat shim: shim is beta and for 3.x needs an extra_body workaround for thinking control; auth header and contents shape differ enough that reusing OpenAICompatibleLLMProvider would have been the worse compromise. fix(settings): switching LLM preset previously only auto-filled endpoint/model when those slots were empty. All providers share ark.endpoint / ark.model_id (per-provider entries were never introduced — see persistence.rs lookup_account), so for returning users with non-empty slots, switching the dropdown updated active provider but kept the OLD endpoint — polish silently kept calling the previous provider. Now switching to any non-custom preset overwrites endpoint/model to that preset's defaults. Trade-off: user-customized endpoints within a preset are dropped on switch; silent wrong-routing was a worse failure mode. Backend: - New module llm_gemini.rs (15 unit tests for thinking-config table, URL builder, response parser, SSE shape, history role mapping) - polish.rs extracts compose_polish_prompts / compose_translate_prompts / compose_qa_system_prompt as pub(crate) helpers shared by both LLM paths so prompt assembly can't drift - coordinator.rs's polish_text / translate_text / answer_chat_dispatch open with `if get_active_llm() == "gemini"` early-return; existing OpenAI-compat callsite stays untouched Frontend: - LLM_PRESETS gains gemini entry (baseUrl v1beta, model placeholder gemini-2.5-flash) - ProviderTools hidden when committedLlmProvider === 'gemini' (its "Fetch models" button assumes OpenAI /models shape; native /v1beta/models response shape doesn't match) - Static geminiModelHint lists the 7 available IDs so users pick correctly without auto-fetch - 5 locales (zh-CN, zh-TW, en, ja, ko) gain presets.gemini and geminiModelHint Test plan: - cargo test --lib 200/200 pass - npm run build (tsc + vite) green - Manual: switch to Gemini in Settings, paste Google AI Studio key, dictate; verify polish reaches v1beta and respects the per-model thinking-off contract. * docs: 2026-05-10 audit + beta logic review snapshots Snapshot two audit artifacts produced 2026-05-10: - docs/audit-2026-05-10-validated.md — 21 audit items run against the live source tree at openless-all/app/src-tauri/src/ with a CONFIRMED / FALSE_POSITIVE / PARTIAL verdict per item plus the exact file:line range that supports each verdict. - docs/logic-review-2026-05-10.md — 5-path logic review of origin/beta @ 400097ad covering startup, press/release, end-of- session, capsule emit, and shutdown. Notable: at 400097ad the emit_capsule fix from PR #389 was NOT yet an ancestor, so the cpal audio-callback → AppKit/Win32 SIGTRAP risk was still live on that snapshot (subsequent merges have addressed it; this doc remains the dated record). Both files are pure snapshots — no code changes here. * fix(llm-gemini): buffer SSE bytes across chunks to handle multibyte UTF-8 split PR #398 pr_agent flagged: reqwest::chunk() can split a multi-byte UTF-8 codepoint (CJK / emoji) across two network chunks, but the streaming code decoded each chunk independently with from_utf8 → half a 你 in the tail of one chunk would surface as "non-utf8 SSE chunk" and abort the whole stream. This breaks Gemini QA streaming for any Chinese-language answer that doesn't happen to align with packet boundaries. Fix: buffer raw bytes (Vec) until we see the SSE delimiter b"\n\n", and only then decode the complete event content as UTF-8. The delimiter \n\n is two ASCII bytes (0x0A 0x0A) which can never appear inside a multi-byte UTF-8 sequence, so byte-level splitting at that boundary is always safe. Extracted the parsing as drain_complete_sse_events(&mut Vec) so the invariant is testable without a TcpListener. Three new unit tests cover: - normal split-on-delimiter + remainder buffering - multibyte UTF-8 split across chunks (the actual regression) - invalid-UTF-8 event content gracefully skipped (warn, don't crash) The same byte-level pattern fix applies to polish.rs's chat_completion_history_streaming — left untouched here to keep this PR scoped to the Gemini diff pr_agent reviewed. polish.rs should get the same fix in a follow-up. * feat(llm-gemini): teach ProviderTools to fetch native Gemini /v1beta/models Drop the verbose static geminiModelHint paragraph and restore the normal ProviderTools "Fetch models" button when the active provider is Gemini. Users now read live model IDs from Google instead of staring at a wall of i18n text below their model field. Backend fetch_provider_models now branches on the base URL: - Google's generativelanguage.googleapis.com → x-goog-api-key auth + parse_gemini_model_ids() that walks `models[].name` and strips the `models/` prefix so the result is a plain `gemini-2.5-flash` style ID directly writable to ark.model_id. - Anything else → unchanged Bearer auth + OpenAI `data[].id` shape. Tests: parse_gemini_model_ids_strips_models_prefix_and_dedups + is_gemini_base_url_matches_official_domain. cargo test --lib green (32/32 in commands::tests subset). i18n: settings.providers.geminiModelHint removed from all 5 locales. The TS `typeof zhCN` constraint enforces the consistent removal. * fix(llm-gemini): pr_agent #398 follow-ups — filter non-chat models + CRLF SSE delim Two follow-up fixes for pr_agent advisories on PR #398: 1. parse_gemini_model_ids now filters by `supportedGenerationMethods`. Google's /v1beta/models lists every family — embeddings, TTS-image, predict-only — alongside generateContent-capable chat models. Without filtering, a "Fetch models" click would surface ids like `gemini-embedding-2`, and a user picking one would write it into ark.model_id and have polish silently fail on every dictation. Field is conservatively included when missing (some preview models don't expose it yet). 2. drain_complete_sse_events now accepts both `\n\n` and `\r\n\r\n` as event delimiters. Some HTTP/2 middleware and CDNs normalize line endings to CRLF; the previous LF-only implementation would never drain a complete event from those servers and report the stream as empty even when data was received. Tied positions can't happen by construction (\r\n\r\n contains no \n\n substring), so "earliest match wins" is unambiguous. Tests: - parse_gemini_model_ids_filters_out_non_generate_content_families - drain_complete_sse_events_handles_crlf_delimiter - drain_complete_sse_events_picks_earliest_delimiter_when_mixed --------- Co-authored-by: baiqing --- openless-all/app/src-tauri/src/commands.rs | 125 ++- openless-all/app/src-tauri/src/coordinator.rs | 80 ++ openless-all/app/src-tauri/src/lib.rs | 1 + openless-all/app/src-tauri/src/llm_gemini.rs | 758 ++++++++++++++++++ openless-all/app/src-tauri/src/polish.rs | 128 ++- openless-all/app/src/i18n/en.ts | 1 + openless-all/app/src/i18n/ja.ts | 1 + openless-all/app/src/i18n/ko.ts | 1 + openless-all/app/src/i18n/zh-CN.ts | 1 + openless-all/app/src/i18n/zh-TW.ts | 1 + openless-all/app/src/pages/Settings.tsx | 30 +- 11 files changed, 1080 insertions(+), 47 deletions(-) create mode 100644 openless-all/app/src-tauri/src/llm_gemini.rs diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index c9cd9b6e..5a21232c 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -410,6 +410,9 @@ fn llm_provider_default_endpoint(provider: &str) -> Option<&'static str> { "deepseek" => Some("https://api.deepseek.com/v1"), "siliconflow" => Some("https://api.siliconflow.cn/v1"), "openai" => Some("https://api.openai.com/v1"), + // 谷歌 Gemini 原生 API(v1beta)。后端 llm_gemini.rs 会拼成 + // `{baseUrl}/models/{model}:generateContent`,认证用 x-goog-api-key 头。 + "gemini" => Some("https://generativelanguage.googleapis.com/v1beta"), "mimo" => Some("https://api.xiaomimimo.com/v1"), "cometapi" => Some("https://api.cometapi.com/v1"), "openrouterFree" => Some("https://openrouter.ai/api/v1"), @@ -807,14 +810,21 @@ fn encode_wav_16k_mono_silence(duration_ms: u32) -> Vec { async fn fetch_provider_models(config: &ProviderConfig) -> Result, String> { let url = models_url(&config.base_url); - log::info!("[provider-check] GET {url}"); + let is_gemini = is_gemini_base_url(&config.base_url); + log::info!("[provider-check] GET {url} (gemini={is_gemini})"); let client = reqwest::Client::builder() .timeout(Duration::from_secs(15)) .build() .map_err(|e| format!("HTTP client 初始化失败: {e}"))?; let mut request = client.get(&url); if !config.api_key.trim().is_empty() { - request = request.header("Authorization", format!("Bearer {}", config.api_key)); + // 谷歌原生 generativelanguage.googleapis.com 不识别 Bearer Authorization, + // 必须用 x-goog-api-key 头。其它 OpenAI 兼容 provider 仍走 Bearer。 + if is_gemini { + request = request.header("x-goog-api-key", config.api_key.as_str()); + } else { + request = request.header("Authorization", format!("Bearer {}", config.api_key)); + } } let response = request.send().await.map_err(|e| { if e.is_timeout() { @@ -831,7 +841,15 @@ async fn fetch_provider_models(config: &ProviderConfig) -> Result, S if !status.is_success() { return Err(format!("providerHttpStatus:{}", status.as_u16())); } - parse_model_ids(&body) + if is_gemini { + parse_gemini_model_ids(&body) + } else { + parse_model_ids(&body) + } +} + +fn is_gemini_base_url(base_url: &str) -> bool { + base_url.contains("generativelanguage.googleapis.com") } fn models_url(base_url: &str) -> String { @@ -864,6 +882,43 @@ fn parse_model_ids(body: &str) -> Result, String> { Ok(models) } +/// 谷歌 v1beta/models 响应形状:`{models: [{name: "models/gemini-2.5-flash", +/// supportedGenerationMethods: ["generateContent", ...], ...}, ...]}`。 +/// 与 OpenAI `{data: [{id: "..."}]}` 不兼容,所以单独解析;name 字段去掉 +/// "models/" 前缀后即是 ProviderTools「拉取模型」按钮可直接写入 ark.model_id +/// 的字符串。 +/// +/// 过滤:只保留声明支持 `generateContent` 的模型——Google 的 model list 同时 +/// 暴露 embedding (`gemini-embedding-2`)、TTS、image 等不支持 +/// generateContent 的家族;用户选中那种 ID 后 polish 必失败(PR #398 pr_agent +/// 漏洞反馈)。`supportedGenerationMethods` 字段缺失时保守保留——某些 preview +/// 模型可能未暴露这个字段,宁误显示也不要把新模型挡在外面。 +fn parse_gemini_model_ids(body: &str) -> Result, String> { + let json: Value = + serde_json::from_str(body).map_err(|e| format!("模型列表不是有效 JSON: {e}"))?; + let models = json + .get("models") + .and_then(|v| v.as_array()) + .ok_or_else(|| "Gemini 模型列表缺少 models 数组".to_string())?; + let mut ids = models + .iter() + .filter(|item| { + match item.get("supportedGenerationMethods").and_then(|v| v.as_array()) { + Some(methods) => methods + .iter() + .any(|m| m.as_str() == Some("generateContent")), + None => true, // 字段缺失:保守包含 + } + }) + .filter_map(|item| item.get("name").and_then(|n| n.as_str())) + .map(|name| name.strip_prefix("models/").unwrap_or(name).trim().to_string()) + .filter(|id| !id.is_empty()) + .collect::>(); + ids.sort(); + ids.dedup(); + Ok(ids) +} + fn parse_account(s: &str) -> Result { match s { "volcengine.app_key" => Ok(CredentialAccount::VolcengineAppKey), @@ -1838,10 +1893,11 @@ 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_provider, local_asr_release_plan_for_provider, models_url, - normalize_foundry_language_hint, parse_latest_beta_from_atom, parse_model_ids, - persist_settings, release_foundry_runtime_if_inactive, validate_foundry_model_alias, - ProviderConfig, SettingsWriter, + is_gemini_base_url, llm_configured_for_provider, local_asr_release_plan_for_provider, + models_url, normalize_foundry_language_hint, parse_gemini_model_ids, + parse_latest_beta_from_atom, parse_model_ids, persist_settings, + release_foundry_runtime_if_inactive, validate_foundry_model_alias, ProviderConfig, + SettingsWriter, }; use crate::persistence::CredentialsSnapshot; use crate::types::{ @@ -2129,6 +2185,61 @@ mod tests { assert_eq!(models, vec!["a".to_string(), "b".to_string()]); } + #[test] + fn parse_gemini_model_ids_strips_models_prefix_and_dedups() { + // Google v1beta/models 真实响应的子集——name 字段带 `models/` 前缀, + // ProviderTools 选中后写入 ark.model_id 时不能带这个前缀(generateContent + // URL 拼接已经会加 `models/`,不去前缀就会变成 `models/models/...`)。 + // 字段缺失时保守保留(视为支持 generateContent)。 + let body = r#"{"models":[ + {"name":"models/gemini-2.5-pro"}, + {"name":"models/gemini-2.5-flash"}, + {"name":"models/gemini-2.5-flash"}, + {"name":"models/gemini-3-flash-preview"} + ]}"#; + let ids = parse_gemini_model_ids(body).unwrap(); + assert_eq!( + ids, + vec![ + "gemini-2.5-flash".to_string(), + "gemini-2.5-pro".to_string(), + "gemini-3-flash-preview".to_string(), + ] + ); + } + + #[test] + fn parse_gemini_model_ids_filters_out_non_generate_content_families() { + // 真实 Google v1beta/models 响应里同时有 generateContent / embedContent / + // generateMessage 等多种家族。用户选中 embedding/TTS/image 模型写入 + // ark.model_id → polish 必败。这里是 PR #398 pr_agent advisory 的回归用例: + // 只把 supportedGenerationMethods 里含 generateContent 的过滤出来。 + let body = r#"{"models":[ + {"name":"models/gemini-2.5-flash","supportedGenerationMethods":["generateContent","streamGenerateContent","countTokens"]}, + {"name":"models/gemini-embedding-2","supportedGenerationMethods":["embedContent"]}, + {"name":"models/text-embedding-004","supportedGenerationMethods":["embedContent","countTextTokens"]}, + {"name":"models/gemini-2.5-pro-preview-tts","supportedGenerationMethods":["generateContent"]}, + {"name":"models/gemini-2.5-flash-image","supportedGenerationMethods":["predict"]} + ]}"#; + let ids = parse_gemini_model_ids(body).unwrap(); + // 只剩两条声明 generateContent 的;embedding 与 image (predict-only) 必须被过滤。 + assert_eq!( + ids, + vec![ + "gemini-2.5-flash".to_string(), + "gemini-2.5-pro-preview-tts".to_string(), + ] + ); + } + + #[test] + fn is_gemini_base_url_matches_official_domain() { + assert!(is_gemini_base_url("https://generativelanguage.googleapis.com/v1beta")); + assert!(is_gemini_base_url("https://generativelanguage.googleapis.com/v1beta/")); + assert!(!is_gemini_base_url("https://api.openai.com/v1")); + assert!(!is_gemini_base_url("https://ark.cn-beijing.volces.com/api/v3")); + } + #[test] fn persist_settings_refreshes_both_hotkey_pipelines() { let writer = FakeSettingsWriter::default(); diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 283bc7a6..e2e16754 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -36,6 +36,7 @@ use crate::persistence::{ PreferencesStore, }; +use crate::llm_gemini::{GeminiConfig, GeminiProvider}; use crate::polish::{OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor}; use crate::recorder::{Recorder, RecorderError}; @@ -2046,6 +2047,27 @@ async fn polish_text( front_app: Option<&str>, prior_turns: &[(String, String)], ) -> anyhow::Result { + // 谷歌 Gemini 分支:所有 LLM provider 共用 ark.* 凭据槽,唯独 Gemini 走原生 + // generateContent / 自带 thinking-off 控制,不走 OpenAI 兼容协议;按 active + // provider id 把请求路由到 GeminiProvider 即可,其余 provider(ark/deepseek/ + // openai/...)保持原 OpenAICompatibleLLMProvider 路径不动。 + if CredentialsVault::get_active_llm() == "gemini" { + let (api_key, model, base_url) = read_gemini_credentials()?; + let provider = GeminiProvider::new(GeminiConfig::new(api_key, model, base_url)); + return Ok(provider + .polish( + raw, + mode, + hotwords, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + prior_turns, + ) + .await?); + } + let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default(); let model = CredentialsVault::get(CredentialAccount::ArkModelId)? .filter(|s| !s.is_empty()) @@ -2108,6 +2130,22 @@ async fn translate_text( output_language_preference: OutputLanguagePreference, front_app: Option<&str>, ) -> anyhow::Result { + // 见 polish_text 顶部注释——同样的 Gemini 路由逻辑。 + if CredentialsVault::get_active_llm() == "gemini" { + let (api_key, model, base_url) = read_gemini_credentials()?; + let provider = GeminiProvider::new(GeminiConfig::new(api_key, model, base_url)); + return Ok(provider + .translate_to( + raw, + target_language, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + ) + .await?); + } + let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default(); let model = CredentialsVault::get(CredentialAccount::ArkModelId)? .filter(|s| !s.is_empty()) @@ -2680,6 +2718,24 @@ where F: Fn(&str) + Send + Sync, C: Fn() -> bool + Send + Sync, { + // 见 polish_text 顶部注释——同样的 Gemini 路由逻辑,QA 流式回答走 Gemini + // 原生 :streamGenerateContent?alt=sse。 + if CredentialsVault::get_active_llm() == "gemini" { + let (api_key, model, base_url) = read_gemini_credentials()?; + let provider = GeminiProvider::new(GeminiConfig::new(api_key, model, base_url)); + return Ok(provider + .answer_chat_streaming( + messages, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + on_delta, + should_cancel, + ) + .await?); + } + let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default(); let model = CredentialsVault::get(CredentialAccount::ArkModelId)? .filter(|s| !s.is_empty()) @@ -2704,6 +2760,30 @@ where .await?) } +/// 读 Gemini 凭据。所有 LLM provider 共用 ark.* 槽位(persistence 没做 per-provider +/// 隔离),所以这里也是从 `ArkApiKey` / `ArkModelId` / `ArkEndpoint` 三个槽读, +/// 但回退默认值改成谷歌的:base_url 默认 `https://generativelanguage.googleapis.com/v1beta`, +/// 模型默认 `gemini-2.5-flash`。Settings.tsx::onLlmProviderChange 在用户切到 gemini +/// 时会强制把 endpoint/model 覆盖为这两个默认值,所以 99% 情况下槽里读出来就是 +/// 这两个;这里的 `unwrap_or_else` 是给极端情况兜底(如旧版本切换 bug 留下的脏数据)。 +/// +/// base_url 末尾去掉 `/`,让 `llm_gemini::generate_content_url` 拼接稳定。 +/// 不去 `/chat/completions` 后缀——OpenAI 兼容路径才会有那个后缀,原生 Gemini 不会。 +fn read_gemini_credentials() -> anyhow::Result<(String, String, String)> { + let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default(); + let model = CredentialsVault::get(CredentialAccount::ArkModelId)? + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "gemini-2.5-flash".to_string()); + let base_url = CredentialsVault::get(CredentialAccount::ArkEndpoint)? + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string()); + if api_key.trim().is_empty() { + anyhow::bail!("API Key 为空"); + } + let base_url = base_url.trim_end_matches('/').to_string(); + Ok((api_key, model, base_url)) +} + fn resolve_ark_endpoint(api_key: &str) -> anyhow::Result { let endpoint = CredentialsVault::get(CredentialAccount::ArkEndpoint)?.filter(|s| !s.is_empty()); resolve_ark_endpoint_with_policy(api_key, endpoint) diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 0f076f84..6b82383f 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ mod correction; mod global_hotkey_runtime; mod hotkey; mod insertion; +mod llm_gemini; mod permissions; mod persistence; mod polish; diff --git a/openless-all/app/src-tauri/src/llm_gemini.rs b/openless-all/app/src-tauri/src/llm_gemini.rs new file mode 100644 index 00000000..bb17a310 --- /dev/null +++ b/openless-all/app/src-tauri/src/llm_gemini.rs @@ -0,0 +1,758 @@ +//! 谷歌 Gemini 原生 generateContent / streamGenerateContent 客户端。 +//! +//! 为什么不复用 `polish.rs::OpenAICompatibleLLMProvider`: +//! 1. **思考模式控制**——Gemini 系列必须按模型 family 注入对的字段 +//! (`thinkingBudget` 给 2.5 / `thinkingLevel` 给 3.x),且两个字段不能同发。 +//! OpenAI 兼容协议没有原生 thinking control 字段;谷歌的 OpenAI 兼容 shim +//! 虽然支持 `reasoning_effort`,但仍是 beta、3.x 上要走 `extra_body` 绕路, +//! 且官方文档对"是否真的关掉思考"措辞模糊。原生 endpoint 的契约最直接。 +//! 2. **认证机制**——原生用 `x-goog-api-key` header(Bearer 不被识别), +//! OpenAICompatibleLLMProvider 写死了 Bearer Authorization。 +//! 3. **请求/响应 shape**——原生 `contents` 走 `role: user|model`,没有 +//! chat completions 的 system role;要走 `systemInstruction` 字段。 +//! +//! prompt 装配 (system_prompt / user_prompt / qa system_prompt) 复用 +//! `polish.rs::compose_*` pub(crate) 装配函数,避免两路 LLM 客户端漂移。 +//! `clean_polish_output` 也复用——polish 提示词禁的"以下是整理后的内容" +//! 前缀只有走它才能在原生路径上同样剥离。 + +use std::time::Duration; + +use serde_json::{json, Value}; + +use crate::polish::{ + clean_polish_output, compose_polish_prompts, compose_qa_system_prompt, + compose_translate_prompts, safe_str_slice, LLMError, +}; +use crate::types::{ChineseScriptPreference, OutputLanguagePreference, PolishMode, QaChatMessage}; + +const DEFAULT_TEMPERATURE: f32 = 0.3; +const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; +const BODY_PREVIEW_LIMIT: usize = 200; + +#[derive(Clone, Debug)] +pub struct GeminiConfig { + pub api_key: String, + pub model: String, + /// e.g. `https://generativelanguage.googleapis.com/v1beta`。允许末尾带 `/`。 + /// 后端拼成 `{base_url}/models/{model}:generateContent`。 + pub base_url: String, + pub temperature: f32, + pub request_timeout_secs: u64, +} + +impl GeminiConfig { + pub fn new( + api_key: impl Into, + model: impl Into, + base_url: impl Into, + ) -> Self { + Self { + api_key: api_key.into(), + model: model.into(), + base_url: base_url.into(), + temperature: DEFAULT_TEMPERATURE, + request_timeout_secs: DEFAULT_REQUEST_TIMEOUT_SECS, + } + } +} + +pub struct GeminiProvider { + config: GeminiConfig, + client: reqwest::Client, +} + +impl GeminiProvider { + pub fn new(config: GeminiConfig) -> Self { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(config.request_timeout_secs)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + Self { config, client } + } + + pub async fn polish( + &self, + raw_text: &str, + mode: PolishMode, + hotwords: &[String], + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + front_app: Option<&str>, + prior_turns: &[(String, String)], + ) -> Result { + let (system_prompt, user_prompt) = compose_polish_prompts( + raw_text, + mode, + hotwords, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + !prior_turns.is_empty(), + ); + + let contents = build_polish_history_contents(prior_turns, &user_prompt); + let body = self.build_generate_body(&system_prompt, contents); + let url = generate_content_url(&self.config.base_url, &self.config.model); + + log::info!( + "[llm] POST {} provider=gemini model={} prior_turns={}", + url, + self.config.model, + prior_turns.len() + ); + + let body_text = self.send_unary(&url, &body).await?; + let raw = extract_assistant_content(&body_text)?; + Ok(clean_polish_output(&raw)) + } + + pub async fn translate_to( + &self, + raw_text: &str, + target_language: &str, + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + _output_language_preference: OutputLanguagePreference, + front_app: Option<&str>, + ) -> Result { + let (system_prompt, user_prompt) = compose_translate_prompts( + raw_text, + target_language, + working_languages, + chinese_script_preference, + front_app, + ); + + let contents = vec![user_content(&user_prompt)]; + let body = self.build_generate_body(&system_prompt, contents); + let url = generate_content_url(&self.config.base_url, &self.config.model); + + log::info!( + "[llm] POST {} provider=gemini model={} translate=true", + url, self.config.model + ); + + let body_text = self.send_unary(&url, &body).await?; + let raw = extract_assistant_content(&body_text)?; + Ok(clean_polish_output(&raw)) + } + + /// 划词语音问答的流式回答。Gemini 原生 SSE: `:streamGenerateContent?alt=sse`, + /// 每个 `data: {...}` 帧里 `candidates[0].content.parts[0].text` 是 delta; + /// 流结束没有 `[DONE]` sentinel,stream 自然终止。 + pub async fn answer_chat_streaming( + &self, + messages: &[QaChatMessage], + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + front_app: Option<&str>, + on_delta: F, + should_cancel: C, + ) -> Result + where + F: Fn(&str) + Send + Sync, + C: Fn() -> bool + Send + Sync, + { + let system_prompt = compose_qa_system_prompt( + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + ); + + let contents = qa_messages_to_contents(messages); + let body = self.build_generate_body(&system_prompt, contents); + let url = stream_generate_content_url(&self.config.base_url, &self.config.model); + + log::info!( + "[llm] POST {} provider=gemini model={} chat_turns={} stream=true", + url, + self.config.model, + messages.len() + ); + + self.send_streaming(&url, &body, on_delta, should_cancel) + .await + } + + /// `generationConfig` 注入:温度 + 按模型 family 的 thinkingConfig。 + fn build_generate_body(&self, system_prompt: &str, contents: Vec) -> Value { + let mut generation_config = json!({ "temperature": self.config.temperature }); + if let Some(thinking) = thinking_config_for(&self.config.model) { + generation_config["thinkingConfig"] = thinking; + } + json!({ + "systemInstruction": system_instruction(system_prompt), + "contents": contents, + "generationConfig": generation_config, + }) + } + + async fn send_unary(&self, url: &str, body: &Value) -> Result { + let mut request = self + .client + .post(url) + .header("Content-Type", "application/json"); + if !self.config.api_key.trim().is_empty() { + request = request.header("x-goog-api-key", self.config.api_key.as_str()); + } + let request = request.json(body); + + let response = match request.send().await { + Ok(r) => r, + Err(e) => { + if e.is_timeout() { + return Err(LLMError::Timeout); + } + return Err(LLMError::Network(e.to_string())); + } + }; + + let status = response.status(); + let body_text = response + .text() + .await + .map_err(|e| LLMError::Network(e.to_string()))?; + + let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len()); + let preview = safe_str_slice(&body_text, preview_end); + log::info!("[llm] HTTP {} body={}", status.as_u16(), preview); + + if !status.is_success() { + return Err(LLMError::InvalidResponse { + status: status.as_u16(), + body: preview.to_string(), + }); + } + + Ok(body_text) + } + + async fn send_streaming( + &self, + url: &str, + body: &Value, + on_delta: F, + should_cancel: C, + ) -> Result + where + F: Fn(&str) + Send + Sync, + C: Fn() -> bool + Send + Sync, + { + let mut request = self + .client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream"); + if !self.config.api_key.trim().is_empty() { + request = request.header("x-goog-api-key", self.config.api_key.as_str()); + } + let request = request.json(body); + + let response = match request.send().await { + Ok(r) => r, + Err(e) => { + if e.is_timeout() { + return Err(LLMError::Timeout); + } + return Err(LLMError::Network(e.to_string())); + } + }; + + let status = response.status(); + if !status.is_success() { + let body_text = response + .text() + .await + .map_err(|e| LLMError::Network(e.to_string()))?; + let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len()); + let preview = safe_str_slice(&body_text, preview_end); + log::error!("[llm] HTTP {} body={}", status.as_u16(), preview); + return Err(LLMError::InvalidResponse { + status: status.as_u16(), + body: preview.to_string(), + }); + } + + let mut response = response; + // 字节级缓冲——`reqwest::chunk()` 可能在多字节 UTF-8 字符(CJK / emoji) + // 中间切开,对每个 chunk 独立 from_utf8 会把合法的 SSE 流当成 + // "non-utf8 SSE chunk" 直接 fail(PR #398 pr_agent 实测漏洞)。 + // SSE 帧分隔符 `\n\n` 两字节都是 ASCII (0x0A),永远不会落在多字节字符中部, + // 所以按字节定位完整 event、再对完整 event 做 from_utf8 永远安全。 + let mut byte_buffer: Vec = Vec::new(); + let mut full_text = String::new(); + loop { + // 与 polish.rs streaming 同款取消旗标——用户取消 / 关浮窗时立即 break, + // 不再 drain HTTP body 烧 quota。 + if should_cancel() { + log::info!("[llm] gemini stream cancelled by caller; breaking SSE loop"); + break; + } + let chunk_opt = response + .chunk() + .await + .map_err(|e| LLMError::Network(e.to_string()))?; + let Some(chunk) = chunk_opt else { break }; + byte_buffer.extend_from_slice(&chunk); + + for event in drain_complete_sse_events(&mut byte_buffer) { + for line in event.lines() { + let Some(payload) = line + .strip_prefix("data: ") + .or_else(|| line.strip_prefix("data:")) + else { + continue; + }; + let payload = payload.trim(); + if payload.is_empty() { + continue; + } + let v: Value = match serde_json::from_str(payload) { + Ok(v) => v, + Err(e) => { + log::warn!( + "[llm] gemini SSE parse skip: {e}; payload preview: {}", + safe_str_slice(payload, 80) + ); + continue; + } + }; + // Gemini SSE: candidates[0].content.parts[*].text + if let Some(parts) = v["candidates"][0]["content"]["parts"].as_array() { + for part in parts { + if let Some(delta) = part["text"].as_str() { + if !delta.is_empty() { + full_text.push_str(delta); + on_delta(delta); + } + } + } + } + } + } + } + + log::info!( + "[llm] HTTP 200 gemini stream done; total chars={}", + full_text.chars().count() + ); + + if full_text.is_empty() { + return Err(LLMError::InvalidResponse { + status: 200, + body: "empty stream".to_string(), + }); + } + Ok(full_text) + } +} + +// ─────────────────────── 内部辅助 ─────────────────────── + +fn user_content(text: &str) -> Value { + json!({ "role": "user", "parts": [{ "text": text }] }) +} + +fn model_content(text: &str) -> Value { + json!({ "role": "model", "parts": [{ "text": text }] }) +} + +fn system_instruction(system_prompt: &str) -> Value { + json!({ "parts": [{ "text": system_prompt }] }) +} + +/// 从字节缓冲里取出所有以 SSE 帧分隔符(`\n\n` 或 `\r\n\r\n`)分隔的完整 +/// event;剩余不完整字节留在 buffer 里等下一次 chunk 拼接。 +/// +/// 不变量:两种分隔符的所有字节都是 ASCII(0x0A / 0x0D),永远不会出现在 +/// UTF-8 多字节字符的中部位置,所以 +/// 1. 按字节查找分隔符 100% 安全; +/// 2. 对完整 event 字节区间 (event_start..delim_start) 做 from_utf8 永远不会因 +/// chunk 边界把多字节字符切开而失败; +/// 3. CRLF 与 LF 不会在同一位置都匹配(\r\n\r\n 内部不含 \n\n),按"最早出现" +/// 选取分隔符不会歧义。 +/// +/// 这是 PR #398 pr_agent 指出的两个 SSE 漏洞的合修: +/// (a) 原代码对每个网络 chunk 独立 from_utf8,遇到 CJK / emoji 跨 chunk 切分时 +/// 直接报错让流挂掉; +/// (b) 原代码只识别 `\n\n`,碰到走 CRLF 风格的服务器流(个别 HTTP/2 中间层、 +/// CDN 会做行尾标准化)会以为流是空的——文档没强制 LF only,必须兼容。 +fn drain_complete_sse_events(buffer: &mut Vec) -> Vec { + let mut events = Vec::new(); + loop { + let crlf = buffer.windows(4).position(|w| w == b"\r\n\r\n"); + let lf = buffer.windows(2).position(|w| w == b"\n\n"); + let (end, delim_len) = match (crlf, lf) { + (Some(c), Some(l)) => { + if c <= l { + (c, 4) + } else { + (l, 2) + } + } + (Some(c), None) => (c, 4), + (None, Some(l)) => (l, 2), + (None, None) => break, + }; + let event_str = match std::str::from_utf8(&buffer[..end]) { + Ok(s) => s.to_string(), + Err(e) => { + // 完整 event 自身 UTF-8 不合法(极少见,可能是上游异常):丢弃此 event 不让流挂掉。 + log::warn!( + "[llm] gemini SSE event has invalid UTF-8 (skipping): {e}" + ); + buffer.drain(..end + delim_len); + continue; + } + }; + events.push(event_str); + buffer.drain(..end + delim_len); + } + events +} + +/// 多轮 polish 的 contents 序列。 +/// 输入约定:`prior_turns` 与 polish.rs 一致(最新在前 newest-first), +/// chat 时间序为 oldest-first,所以这里 `iter().rev()` 反转。 +fn build_polish_history_contents(prior_turns: &[(String, String)], user_prompt: &str) -> Vec { + let mut contents: Vec = Vec::with_capacity(prior_turns.len() * 2 + 1); + for (raw, polished) in prior_turns.iter().rev() { + contents.push(user_content(&crate::polish::prompts::user_prompt(raw))); + contents.push(model_content(polished)); + } + contents.push(user_content(user_prompt)); + contents +} + +/// QA chat messages → Gemini contents:assistant role 重命名为 model。 +/// QaChatMessage.role 在 polish.rs OpenAI 路径里是 `"user" | "assistant"`; +/// 这里把 `assistant` 翻成 Gemini 的 `model`,其它原样保留。 +fn qa_messages_to_contents(messages: &[QaChatMessage]) -> Vec { + messages + .iter() + .map(|m| { + let role = if m.role == "assistant" { "model" } else { "user" }; + json!({ "role": role, "parts": [{ "text": m.content }] }) + }) + .collect() +} + +/// 按模型 ID 选 thinkingConfig。返回 None 表示该模型官方明示无法关思考 +/// (目前仅 gemini-2.5-pro),交由模型默认行为。 +/// +/// 字段对应矩阵(已对 ai.google.dev/gemini-api/docs/thinking 交叉核对): +/// - `gemini-2.5-flash`, `gemini-2.5-flash-lite` → `thinkingBudget = 0`(完全关) +/// - `gemini-2.5-pro` → 不可关(官方 N/A),不下发字段 +/// - `gemini-3*-pro*` → `thinkingLevel = "low"`(最低档) +/// - `gemini-3*-flash*` → `thinkingLevel = "minimal"`(Flash-only) +/// - 其它未知 → `thinkingLevel = "minimal"`("尽量关"兜底) +/// +/// 注意:thinkingBudget 与 thinkingLevel 不能同发——文档没明示但属未定义行为。 +fn thinking_config_for(model: &str) -> Option { + let m = model.to_ascii_lowercase(); + if m.starts_with("gemini-2.5-pro") { + return None; + } + if m.starts_with("gemini-2.5-flash") { + return Some(json!({ "thinkingBudget": 0 })); + } + if m.starts_with("gemini-3") { + if m.contains("pro") { + return Some(json!({ "thinkingLevel": "low" })); + } + return Some(json!({ "thinkingLevel": "minimal" })); + } + Some(json!({ "thinkingLevel": "minimal" })) +} + +fn generate_content_url(base_url: &str, model: &str) -> String { + let trimmed = base_url.trim().trim_end_matches('/'); + format!("{trimmed}/models/{model}:generateContent") +} + +fn stream_generate_content_url(base_url: &str, model: &str) -> String { + let trimmed = base_url.trim().trim_end_matches('/'); + format!("{trimmed}/models/{model}:streamGenerateContent?alt=sse") +} + +fn extract_assistant_content(body: &str) -> Result { + let json: Value = serde_json::from_str(body) + .map_err(|e| LLMError::ParseError(format!("not valid JSON: {}", e)))?; + let candidates = json + .get("candidates") + .and_then(|v| v.as_array()) + .ok_or_else(|| LLMError::ParseError("missing candidates array".into()))?; + let first = candidates + .first() + .ok_or_else(|| LLMError::ParseError("candidates array is empty".into()))?; + let parts = first + .get("content") + .and_then(|c| c.get("parts")) + .and_then(|p| p.as_array()) + .ok_or_else(|| LLMError::ParseError("missing content.parts".into()))?; + // 把所有 part.text 拼起来。模型在 thinking on 时可能产出多段,但我们已禁 + // 思考;仍按 array 处理是为了不被 future-proof 单 part vs 多 part 的差异坑到。 + let mut buf = String::new(); + for part in parts { + if let Some(t) = part.get("text").and_then(|v| v.as_str()) { + buf.push_str(t); + } + } + if buf.is_empty() { + return Err(LLMError::ParseError( + "candidates[0].content.parts[*].text 为空".into(), + )); + } + Ok(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn thinking_config_2_5_flash_disables_thinking_via_budget_zero() { + let v = thinking_config_for("gemini-2.5-flash").unwrap(); + assert_eq!(v, json!({ "thinkingBudget": 0 })); + // flash-lite 同款 + let v2 = thinking_config_for("gemini-2.5-flash-lite").unwrap(); + assert_eq!(v2, json!({ "thinkingBudget": 0 })); + } + + #[test] + fn thinking_config_2_5_pro_returns_none_because_cannot_disable() { + assert!(thinking_config_for("gemini-2.5-pro").is_none()); + } + + #[test] + fn thinking_config_3_x_pro_uses_low_thinking_level() { + let v = thinking_config_for("gemini-3.1-pro-preview").unwrap(); + assert_eq!(v, json!({ "thinkingLevel": "low" })); + } + + #[test] + fn thinking_config_3_x_flash_uses_minimal() { + let v = thinking_config_for("gemini-3-flash-preview").unwrap(); + assert_eq!(v, json!({ "thinkingLevel": "minimal" })); + let v2 = thinking_config_for("gemini-3.1-flash-lite").unwrap(); + assert_eq!(v2, json!({ "thinkingLevel": "minimal" })); + } + + #[test] + fn thinking_config_unknown_falls_back_to_minimal_level() { + // 未来未知 ID 兜底到 minimal——尽量"关",避免默认放出长思考。 + let v = thinking_config_for("gemini-99-future-model").unwrap(); + assert_eq!(v, json!({ "thinkingLevel": "minimal" })); + } + + #[test] + fn thinking_config_never_emits_both_budget_and_level() { + // 不变量:任一返回值最多含 `thinkingBudget` 或 `thinkingLevel` 中的一个, + // 不能同发——文档没明示但属未定义行为,回归就立刻暴露。 + for model in [ + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + "gemini-3.1-pro-preview", + "gemini-3-flash-preview", + "gemini-3.1-flash-lite", + "gemini-3.1-flash-lite-preview", + "gemini-99-future-model", + ] { + let v = thinking_config_for(model).unwrap(); + let obj = v.as_object().unwrap(); + let has_budget = obj.contains_key("thinkingBudget"); + let has_level = obj.contains_key("thinkingLevel"); + assert!( + has_budget ^ has_level, + "model {model} 同时下发 budget 与 level,违反单字段不变量: {v}" + ); + } + } + + #[test] + fn generate_content_url_handles_trailing_slash_in_base_url() { + let a = generate_content_url("https://x/v1beta", "gemini-2.5-flash"); + let b = generate_content_url("https://x/v1beta/", "gemini-2.5-flash"); + assert_eq!(a, "https://x/v1beta/models/gemini-2.5-flash:generateContent"); + assert_eq!(b, "https://x/v1beta/models/gemini-2.5-flash:generateContent"); + } + + #[test] + fn stream_generate_content_url_appends_alt_sse() { + let a = stream_generate_content_url("https://x/v1beta", "gemini-2.5-flash"); + assert_eq!( + a, + "https://x/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + } + + #[test] + fn extract_assistant_content_concatenates_multiple_parts() { + let body = r#"{"candidates":[{"content":{"parts":[{"text":"hello "},{"text":"world"}]}}]}"#; + assert_eq!(extract_assistant_content(body).unwrap(), "hello world"); + } + + #[test] + fn extract_assistant_content_empty_array_errors() { + let body = r#"{"candidates":[]}"#; + assert!(extract_assistant_content(body).is_err()); + } + + #[test] + fn build_polish_history_contents_orders_oldest_to_newest_and_uses_model_role() { + // prior_turns 入参约定 newest-first(与 polish.rs::build_polish_history_messages + // 同源约定);这里反转为 chat 时间序 oldest-first 喂给 Gemini。 + // assistant role 的 polished 历史必须挂在 Gemini 的 `model` role 上。 + let prior = vec![ + ("raw-newest".into(), "polished-newest".into()), + ("raw-mid".into(), "polished-mid".into()), + ("raw-oldest".into(), "polished-oldest".into()), + ]; + let contents = build_polish_history_contents(&prior, "USER_NOW"); + // 3×(user/model) + 1 当前 user = 7 + assert_eq!(contents.len(), 7); + assert_eq!(contents[0]["role"], "user"); + assert!(contents[0]["parts"][0]["text"] + .as_str() + .unwrap() + .contains("raw-oldest")); + assert_eq!(contents[1]["role"], "model"); + assert_eq!(contents[1]["parts"][0]["text"], "polished-oldest"); + assert_eq!(contents[5]["role"], "model"); + assert_eq!(contents[5]["parts"][0]["text"], "polished-newest"); + assert_eq!(contents[6]["role"], "user"); + assert_eq!(contents[6]["parts"][0]["text"], "USER_NOW"); + } + + #[test] + fn qa_messages_assistant_role_is_remapped_to_model() { + let messages = vec![ + QaChatMessage { + role: "user".into(), + content: "选区是什么意思".into(), + }, + QaChatMessage { + role: "assistant".into(), + content: "这是一段示例文本".into(), + }, + QaChatMessage { + role: "user".into(), + content: "继续问".into(), + }, + ]; + let contents = qa_messages_to_contents(&messages); + assert_eq!(contents[0]["role"], "user"); + assert_eq!(contents[1]["role"], "model"); + assert_eq!(contents[2]["role"], "user"); + } + + #[test] + fn build_generate_body_2_5_flash_includes_thinking_budget_zero() { + let cfg = GeminiConfig::new("k", "gemini-2.5-flash", "https://x/v1beta"); + let provider = GeminiProvider::new(cfg); + let body = provider.build_generate_body("SYS", vec![user_content("hi")]); + assert_eq!( + body["generationConfig"]["thinkingConfig"], + json!({ "thinkingBudget": 0 }) + ); + assert_eq!(body["systemInstruction"]["parts"][0]["text"], "SYS"); + assert_eq!(body["contents"][0]["role"], "user"); + } + + #[test] + fn build_generate_body_2_5_pro_omits_thinking_config() { + let cfg = GeminiConfig::new("k", "gemini-2.5-pro", "https://x/v1beta"); + let provider = GeminiProvider::new(cfg); + let body = provider.build_generate_body("SYS", vec![user_content("hi")]); + assert!( + body["generationConfig"].get("thinkingConfig").is_none(), + "2.5 Pro 不能关思考,generationConfig 不应含 thinkingConfig 字段" + ); + } + + #[test] + fn drain_complete_sse_events_splits_full_event_at_delimiter() { + let mut buf = b"data: {\"a\":1}\n\ndata: {\"b\":2}\n\ndata: incompl".to_vec(); + let events = drain_complete_sse_events(&mut buf); + assert_eq!(events, vec!["data: {\"a\":1}", "data: {\"b\":2}"]); + // 不完整的最后一段保留在 buffer 里等下次 chunk 拼接 + assert_eq!(buf, b"data: incompl"); + } + + #[test] + fn drain_complete_sse_events_handles_multibyte_split_across_chunks() { + // 回归 PR #398 pr_agent UTF-8 SSE 漏洞: + // "你好" 的 UTF-8 字节是 e4 bd a0 e5 a5 bd(共 6 字节)。 + // 模拟 reqwest::chunk() 把这段切在 e4 bd 后(即第一个汉字的 1/3 处), + // 旧代码立刻 from_utf8(&chunk) 报错让整条流挂掉;新代码累积字节直到拿到 + // 完整 event (\n\n) 才解码,应当无损。 + let event_bytes = b"data: {\"text\":\"\xe4\xbd\xa0\xe5\xa5\xbd\"}\n\n"; + let cut = 17; // 切在 e4 bd 之后、a0 之前——多字节字符内部 + assert!(cut < event_bytes.len() && event_bytes[cut] == 0xa0); + + let mut buf = Vec::new(); + buf.extend_from_slice(&event_bytes[..cut]); + let events_round_1 = drain_complete_sse_events(&mut buf); + assert!( + events_round_1.is_empty(), + "尚未收到 \\n\\n,不能产生 event;同时 buffer 不应因半截多字节字符报错" + ); + + buf.extend_from_slice(&event_bytes[cut..]); + let events_round_2 = drain_complete_sse_events(&mut buf); + assert_eq!(events_round_2.len(), 1, "拼齐后应产生 1 个完整 event"); + assert!( + events_round_2[0].contains("你好"), + "中文必须在拼齐后完好解出;旧实现这里会丢字" + ); + assert!(buf.is_empty(), "处理完后 buffer 应清空"); + } + + #[test] + fn drain_complete_sse_events_handles_crlf_delimiter() { + // 回归 PR #398 pr_agent advisory:部分服务器/CDN 用 \r\n\r\n 分隔 SSE 帧, + // 旧实现只认 \n\n 会把整条流当空流。新实现按字节同时查 \r\n\r\n 与 \n\n, + // 取最早位置。Rust str::lines() 在 event 内自动剥 \r,所以 line 处理无需改。 + let mut buf = b"data: {\"a\":1}\r\n\r\ndata: {\"b\":2}\r\n\r\n".to_vec(); + let events = drain_complete_sse_events(&mut buf); + assert_eq!(events, vec!["data: {\"a\":1}", "data: {\"b\":2}"]); + assert!(buf.is_empty()); + } + + #[test] + fn drain_complete_sse_events_picks_earliest_delimiter_when_mixed() { + // 同一 buffer 里既有 LF 风格也有 CRLF 风格——按出现顺序处理,不漏 event。 + let mut buf = b"data: lf-event\n\ndata: crlf-event\r\n\r\nrest".to_vec(); + let events = drain_complete_sse_events(&mut buf); + assert_eq!(events, vec!["data: lf-event", "data: crlf-event"]); + assert_eq!(buf, b"rest"); + } + + #[test] + fn drain_complete_sse_events_skips_invalid_utf8_event_without_failing_stream() { + // 极端情况:完整 event 自身字节序列就 UTF-8 不合法(上游脏数据)。 + // 旧实现会 ? 直接 fail 让流挂掉;新实现降级为 warn + skip。 + let mut buf: Vec = b"data: ok\n\n".to_vec(); + buf.extend_from_slice(&[0xff, 0xfe, b'\n', b'\n']); // 不合法 event + buf.extend_from_slice(b"data: ok2\n\n"); + let events = drain_complete_sse_events(&mut buf); + assert_eq!(events, vec!["data: ok", "data: ok2"]); + assert!(buf.is_empty()); + } + + #[test] + fn build_generate_body_3_x_pro_emits_low_thinking_level() { + let cfg = GeminiConfig::new("k", "gemini-3.1-pro-preview", "https://x/v1beta"); + let provider = GeminiProvider::new(cfg); + let body = provider.build_generate_body("SYS", vec![]); + assert_eq!( + body["generationConfig"]["thinkingConfig"], + json!({ "thinkingLevel": "low" }) + ); + } +} diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 296358a7..2ffc1509 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -95,25 +95,16 @@ impl OpenAICompatibleLLMProvider { front_app: Option<&str>, prior_turns: &[(String, String)], ) -> Result { - let mut system_prompt = compose_system_prompt(mode, hotwords); - if let Some(premise) = context_premise( + let (system_prompt, user_prompt) = compose_polish_prompts( + raw_text, + mode, + hotwords, working_languages, chinese_script_preference, output_language_preference, front_app, - ) { - system_prompt = format!("{}\n\n{}", premise, system_prompt); - } - // 多轮上下文模式:把"上一轮的指令是什么、不要复读上一轮答案"明确写进 - // system prompt,配合 chat structure 让 LLM 自然不重复历史输出。 - if !prior_turns.is_empty() { - system_prompt = format!( - "{}\n\n{}", - system_prompt, - prompts::polish_context_instruction() - ); - } - let user_prompt = prompts::user_prompt(raw_text); + !prior_turns.is_empty(), + ); if prior_turns.is_empty() { self.chat_completion(&system_prompt, &user_prompt).await } else { @@ -140,15 +131,12 @@ impl OpenAICompatibleLLMProvider { F: Fn(&str) + Send + Sync, C: Fn() -> bool + Send + Sync, { - let mut system_prompt = prompts::qa_system_prompt(); - if let Some(premise) = context_premise( + let system_prompt = compose_qa_system_prompt( working_languages, chinese_script_preference, output_language_preference, front_app, - ) { - system_prompt = format!("{}\n\n{}", premise, system_prompt); - } + ); self.chat_completion_history_streaming(&system_prompt, messages, on_delta, should_cancel) .await } @@ -164,18 +152,13 @@ impl OpenAICompatibleLLMProvider { _output_language_preference: OutputLanguagePreference, front_app: Option<&str>, ) -> Result { - let mut system_prompt = prompts::translate_system_prompt(target_language); - // 翻译模式必须以 target_language 为唯一输出语言约束,避免和 UI 驱动的 - // output_language_preference 发生冲突(例如 UI=ja, target=en)。 - if let Some(premise) = context_premise( + let (system_prompt, user_prompt) = compose_translate_prompts( + raw_text, + target_language, working_languages, chinese_script_preference, - OutputLanguagePreference::Auto, front_app, - ) { - system_prompt = format!("{}\n\n{}", premise, system_prompt); - } - let user_prompt = prompts::user_prompt(raw_text); + ); self.chat_completion(&system_prompt, &user_prompt).await } @@ -433,7 +416,7 @@ impl OpenAICompatibleLLMProvider { } /// Slice up to `end` bytes off `s`, but don't split a UTF-8 codepoint. -fn safe_str_slice(s: &str, end: usize) -> &str { +pub(crate) fn safe_str_slice(s: &str, end: usize) -> &str { if end >= s.len() { return s; } @@ -560,6 +543,86 @@ fn context_premise( Some(lines.join("\n")) } +/// 把 polish 输入参数装配成 `(system_prompt, user_prompt)` 二元组。 +/// +/// 抽出来是为了让 OpenAI 兼容客户端 (本文件) 和谷歌原生 Gemini 客户端 +/// (`llm_gemini.rs`) 共享同一套 prompt 装配规则——不再担心两路 LLM +/// 在 `system_prompt` 拼接顺序、context_premise 注入时机、 +/// polish_context_instruction 追加条件上慢慢漂移。 +pub(crate) fn compose_polish_prompts( + raw_text: &str, + mode: PolishMode, + hotwords: &[String], + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + front_app: Option<&str>, + has_prior_turns: bool, +) -> (String, String) { + let mut system_prompt = compose_system_prompt(mode, hotwords); + if let Some(premise) = context_premise( + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + ) { + system_prompt = format!("{}\n\n{}", premise, system_prompt); + } + // 多轮上下文模式:把"上一轮的指令是什么、不要复读上一轮答案"明确写进 + // system prompt,配合 chat structure 让 LLM 自然不重复历史输出。 + if has_prior_turns { + system_prompt = format!( + "{}\n\n{}", + system_prompt, + prompts::polish_context_instruction() + ); + } + let user_prompt = prompts::user_prompt(raw_text); + (system_prompt, user_prompt) +} + +/// 翻译路径的 `(system_prompt, user_prompt)` 装配——和 polish 一样供两路 LLM 客户端共用。 +/// 翻译模式以 `target_language` 为唯一输出语言约束,OutputLanguagePreference 在这里被 +/// 强制设为 Auto 以避免 UI 偏好(如 ja)与 target_language(如 en)冲突。 +pub(crate) fn compose_translate_prompts( + raw_text: &str, + target_language: &str, + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + front_app: Option<&str>, +) -> (String, String) { + let mut system_prompt = prompts::translate_system_prompt(target_language); + if let Some(premise) = context_premise( + working_languages, + chinese_script_preference, + OutputLanguagePreference::Auto, + front_app, + ) { + system_prompt = format!("{}\n\n{}", premise, system_prompt); + } + let user_prompt = prompts::user_prompt(raw_text); + (system_prompt, user_prompt) +} + +/// QA 划词问答的 system_prompt 装配。两路 LLM 客户端共用。 +pub(crate) fn compose_qa_system_prompt( + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + front_app: Option<&str>, +) -> String { + let mut system_prompt = prompts::qa_system_prompt(); + if let Some(premise) = context_premise( + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + ) { + system_prompt = format!("{}\n\n{}", premise, system_prompt); + } + system_prompt +} + fn compose_system_prompt(mode: PolishMode, hotwords: &[String]) -> String { let base = prompts::system_prompt(mode); let cleaned: Vec = hotwords @@ -605,7 +668,10 @@ fn extract_assistant_content(body: &str) -> Result { /// and strips them. We don't have the `regex` crate, so we use prefix checks plus /// an iterative trim — if the model stacks two boilerplate sentences we'll still /// strip both. -fn clean_polish_output(content: &str) -> String { +/// +/// `pub(crate)` because `llm_gemini` 也要在它自己的解析路径上跑同一套清洗, +/// 否则 polish prompt 已经禁用的"以下是整理后的内容"前缀只在 OpenAI 兼容路径生效。 +pub(crate) fn clean_polish_output(content: &str) -> String { let without_thinking = strip_thinking_blocks(content); let trimmed = without_thinking.trim(); let stripped = strip_markdown_fence(trimmed); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 1dea5474..bf1a4631 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -363,6 +363,7 @@ export const en: typeof zhCN = { deepseek: 'DeepSeek', siliconflow: 'SiliconFlow', openai: 'OpenAI', + gemini: 'Google Gemini', mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter (free models)', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 31dae950..45c2836a 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -365,6 +365,7 @@ export const ja: typeof zhCN = { deepseek: 'DeepSeek', siliconflow: 'SiliconFlow', openai: 'OpenAI', + gemini: 'Google Gemini', mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(無料モデル)', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 5974c69e..1fbf83a5 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -365,6 +365,7 @@ export const ko: typeof zhCN = { deepseek: 'DeepSeek', siliconflow: 'SiliconFlow', openai: 'OpenAI', + gemini: 'Google Gemini', mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(무료 모델)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index f9c1fcf5..d9da9665 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -361,6 +361,7 @@ export const zhCN = { deepseek: 'DeepSeek', siliconflow: '硅基流动', openai: 'OpenAI', + gemini: 'Google Gemini', mimo: '小米 MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(免费模型)', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 349e2dcc..d9417145 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -363,6 +363,7 @@ export const zhTW: typeof zhCN = { deepseek: 'DeepSeek', siliconflow: '硅基流動', openai: 'OpenAI', + gemini: 'Google Gemini', mimo: '小米 MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(免費模型)', diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index e9bac082..89124acb 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -1164,6 +1164,19 @@ const LLM_PRESETS = [ baseUrl: 'https://api.openai.com/v1', modelPlaceholder: 'gpt-4o', }, + { + // 谷歌官方 Gemini API(原生 generateContent,不走 OpenAI 兼容 shim)。 + // baseUrl 末尾 /v1beta 是当前 Generally Available 的 path(ai.google.dev/api)。 + // 后端 llm_gemini.rs 会拼成 `{baseUrl}/models/{model}:generateContent`, + // 并按模型 family 注入 thinkingConfig 强制关思考(2.5 flash 系列 thinkingBudget=0; + // 3.x pro 走 thinkingLevel="low";3.x flash 走 thinkingLevel="minimal"; + // 2.5 pro 官方明示无法关闭思考)。模型列表用 ProviderTools「拉取模型」按钮取, + // 由 commands.rs::fetch_provider_models 识别 generativelanguage 域名后按 Gemini shape 解析。 + id: 'gemini', + nameKey: 'gemini', + baseUrl: 'https://generativelanguage.googleapis.com/v1beta', + modelPlaceholder: 'gemini-2.5-flash', + }, { id: 'mimo', nameKey: 'mimo', @@ -1283,18 +1296,17 @@ function ProvidersSection() { if (seq !== llmSwitchSeqRef.current) return; } const preset = LLM_PRESETS.find(p => p.id === id); - if (preset?.baseUrl) { - const existing = await readCredential('ark.endpoint'); - if (seq !== llmSwitchSeqRef.current) return; - if (!existing) { + // 修 bug:所有 LLM provider 共用 `ark.endpoint` / `ark.model_id` 一对凭据槽 + // (persistence.rs 没做 per-provider 隔离)。旧逻辑只在槽空时填默认值, + // 老用户切换 preset 时槽里早有旧值——dropdown 看着切了,polish 实际还是 + // 打老 endpoint。改成:切到任何非 custom 预设都强制覆盖 endpoint 与 model + // 到该预设的默认值,让"切换"真切到位。custom 预设没有默认值,跳过。 + if (preset && preset.id !== 'custom') { + if (preset.baseUrl) { await setCredential('ark.endpoint', preset.baseUrl); if (seq !== llmSwitchSeqRef.current) return; } - } - if (preset?.modelPlaceholder) { - const existing = await readCredential('ark.model_id'); - if (seq !== llmSwitchSeqRef.current) return; - if (!existing) { + if (preset.modelPlaceholder) { await setCredential('ark.model_id', preset.modelPlaceholder); if (seq !== llmSwitchSeqRef.current) return; } From 8391e566c6c5b4af295225c0b458fd6ffa49e5dc Mon Sep 17 00:00:00 2001 From: bbossss <1551929492@qq.com> Date: Sun, 10 May 2026 22:04:25 +0800 Subject: [PATCH 32/50] Let OpenLess reuse Codex auth for low-latency polishing Codex OAuth should let users spend their existing Codex subscription on short dictation polish, translation, and QA without storing another API key in OpenLess. The provider reads the local Codex auth file, keeps OpenAI-compatible providers on their existing path, uses a lighter Spark default for latency, and broadens Codex auth discovery for Windows GUI environments. The same change keeps Volcengine on the duration-priced BigASR resource validated locally and preserves proxy behavior so Codex network paths can use system proxy settings while loopback/local provider checks avoid the proxy. Constraint: Codex OAuth credentials must remain sourced from the Codex auth file and must not be copied into OpenLess credential storage. Constraint: PR #401 is a fork PR from BBossss/openless:beta to appergb/openless:beta. Rejected: Require a separate OpenAI API key | the requested path was to reuse the existing Codex subscription. Rejected: Keep gpt-5.5 as the default | it is unnecessarily heavy for short dictation polish and was observed timing out. Rejected: Use only HOME for Codex auth lookup | Windows GUI sessions commonly expose USERPROFILE instead. Confidence: high Scope-risk: moderate Reversibility: clean Directive: Do not persist Codex OAuth tokens into OpenLess credentials; keep using the Codex auth file as the source of truth. Directive: Keep Codex OAuth defaults optimized for short low-latency dictation polishing unless product requirements change. Tested: git diff --cached --check Tested: cargo test --manifest-path src-tauri/Cargo.toml --lib Tested: npm run build Tested: PR #401 CI passed on Windows, macOS, and Linux before this history-only squash. Co-authored-by: OmX --- openless-all/app/src-tauri/Cargo.toml | 2 +- .../app/src-tauri/src/asr/local/download.rs | 4 +- .../app/src-tauri/src/asr/volcengine.rs | 4 +- openless-all/app/src-tauri/src/asr/whisper.rs | 12 +- openless-all/app/src-tauri/src/commands.rs | 77 +- openless-all/app/src-tauri/src/coordinator.rs | 66 +- openless-all/app/src-tauri/src/persistence.rs | 3 +- openless-all/app/src-tauri/src/polish.rs | 963 +++++++++++++++++- openless-all/app/src/i18n/en.ts | 2 + openless-all/app/src/i18n/ja.ts | 2 + openless-all/app/src/i18n/ko.ts | 2 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/i18n/zh-TW.ts | 2 + openless-all/app/src/pages/Overview.tsx | 1 + openless-all/app/src/pages/Settings.tsx | 23 +- 15 files changed, 1098 insertions(+), 67 deletions(-) diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index b819507b..a19cbad0 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -29,7 +29,7 @@ serde_json = "1" tokio = { version = "1", features = ["full"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] } futures-util = "0.3" -reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "native-tls", "stream"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "native-tls", "stream", "system-proxy"] } zip = "2" thiserror = "1" anyhow = "1" diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs index 2ddf53fb..b3ed1361 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -508,7 +508,9 @@ pub fn partial_actual_size(partial: &Path) -> u64 { let mut seen: HashSet = HashSet::new(); let mut total: u64 = 0; for line in content.lines() { - let Ok(idx) = line.trim().parse::() else { continue }; + let Ok(idx) = line.trim().parse::() else { + continue; + }; if !seen.insert(idx) { continue; } diff --git a/openless-all/app/src-tauri/src/asr/volcengine.rs b/openless-all/app/src-tauri/src/asr/volcengine.rs index 2fc0feac..7d28fc05 100644 --- a/openless-all/app/src-tauri/src/asr/volcengine.rs +++ b/openless-all/app/src-tauri/src/asr/volcengine.rs @@ -40,7 +40,7 @@ pub struct VolcengineCredentials { impl VolcengineCredentials { pub fn default_resource_id() -> &'static str { - "volc.seedasr.sauc.duration" + "volc.bigasr.sauc.duration" } } @@ -719,7 +719,7 @@ mod tests { fn default_resource_id_is_sauc_duration() { assert_eq!( VolcengineCredentials::default_resource_id(), - "volc.seedasr.sauc.duration" + "volc.bigasr.sauc.duration" ); } diff --git a/openless-all/app/src-tauri/src/asr/whisper.rs b/openless-all/app/src-tauri/src/asr/whisper.rs index 13c8264e..6cfc2e52 100644 --- a/openless-all/app/src-tauri/src/asr/whisper.rs +++ b/openless-all/app/src-tauri/src/asr/whisper.rs @@ -29,12 +29,7 @@ pub struct WhisperBatchASR { } impl WhisperBatchASR { - pub fn new( - api_key: String, - base_url: String, - model: String, - prompt: Option, - ) -> Self { + pub fn new(api_key: String, base_url: String, model: String, prompt: Option) -> Self { Self { api_key, base_url, @@ -205,7 +200,10 @@ mod tests { #[test] fn build_prompt_single_phrase() { let phrases = vec!["梁山泊".to_string()]; - assert_eq!(build_prompt_from_phrases(&phrases), Some("梁山泊.".to_string())); + assert_eq!( + build_prompt_from_phrases(&phrases), + Some("梁山泊.".to_string()) + ); } #[test] diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 5a21232c..bb8a1cea 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -1,7 +1,6 @@ //! Tauri command surface — every IPC entry the React UI invokes lives here. use std::sync::Arc; -use std::time::Duration; use parking_lot::Mutex; use serde::Serialize; @@ -16,7 +15,11 @@ use crate::asr::local::FoundryLocalRuntime; use crate::coordinator::Coordinator; use crate::permissions::{self, PermissionStatus}; use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVault}; -use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; +use crate::polish::{ + http_client_builder, CodexOAuthConfig, CodexOAuthCredentials, CodexOAuthLLMProvider, LLMError, + OpenAICompatibleConfig, OpenAICompatibleLLMProvider, CODEX_DEFAULT_MODEL, + CODEX_OAUTH_PROVIDER_ID, +}; use crate::recorder::{AudioConsumer, Recorder}; use crate::types::{ ChineseScriptPreference, ComboBinding, CorrectionRule, CredentialsStatus, DictationSession, @@ -392,6 +395,9 @@ fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bo } fn llm_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bool { + if provider == CODEX_OAUTH_PROVIDER_ID { + return CodexOAuthCredentials::load_default().is_ok(); + } let endpoint = snap.ark_endpoint.as_deref().unwrap_or_default(); let endpoint_and_model = configured(&snap.ark_endpoint) && configured(&snap.ark_model_id); if endpoint_and_model @@ -554,6 +560,16 @@ pub async fn list_provider_models(kind: String) -> Result Result { } async fn validate_llm_provider() -> Result<(), String> { + if CredentialsVault::get_active_llm() == CODEX_OAUTH_PROVIDER_ID { + let model = CredentialsVault::get(CredentialAccount::ArkModelId) + .map_err(|e| e.to_string())? + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| CODEX_DEFAULT_MODEL.to_string()); + let provider = CodexOAuthLLMProvider::new(CodexOAuthConfig::new(model)); + return provider + .polish( + "验证连接", + PolishMode::Raw, + &[], + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + None, + &[], + ) + .await + .map(|_| ()) + .map_err(|e| match e { + LLMError::InvalidResponse { status, .. } => { + format!("providerHttpStatus:{status}") + } + other => other.to_string(), + }); + } + let config = read_openai_provider_config("llm")?; let model = CredentialsVault::get(CredentialAccount::ArkModelId) .map_err(|e| e.to_string())? @@ -711,8 +754,7 @@ async fn validate_asr_transcription(config: &ProviderConfig, model: &str) -> Res let form = reqwest::multipart::Form::new() .part("file", wav_part) .text("model", model.to_string()); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(20)) + let client = http_client_builder(&url, 20) .build() .map_err(|_| "providerClientInitFailed".to_string())?; let response = client @@ -812,8 +854,7 @@ async fn fetch_provider_models(config: &ProviderConfig) -> Result, S let url = models_url(&config.base_url); let is_gemini = is_gemini_base_url(&config.base_url); log::info!("[provider-check] GET {url} (gemini={is_gemini})"); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(15)) + let client = http_client_builder(&config.base_url, 15) .build() .map_err(|e| format!("HTTP client 初始化失败: {e}"))?; let mut request = client.get(&url); @@ -903,7 +944,10 @@ fn parse_gemini_model_ids(body: &str) -> Result, String> { let mut ids = models .iter() .filter(|item| { - match item.get("supportedGenerationMethods").and_then(|v| v.as_array()) { + match item + .get("supportedGenerationMethods") + .and_then(|v| v.as_array()) + { Some(methods) => methods .iter() .any(|m| m.as_str() == Some("generateContent")), @@ -911,7 +955,12 @@ fn parse_gemini_model_ids(body: &str) -> Result, String> { } }) .filter_map(|item| item.get("name").and_then(|n| n.as_str())) - .map(|name| name.strip_prefix("models/").unwrap_or(name).trim().to_string()) + .map(|name| { + name.strip_prefix("models/") + .unwrap_or(name) + .trim() + .to_string() + }) .filter(|id| !id.is_empty()) .collect::>(); ids.sort(); @@ -2234,10 +2283,16 @@ mod tests { #[test] fn is_gemini_base_url_matches_official_domain() { - assert!(is_gemini_base_url("https://generativelanguage.googleapis.com/v1beta")); - assert!(is_gemini_base_url("https://generativelanguage.googleapis.com/v1beta/")); + assert!(is_gemini_base_url( + "https://generativelanguage.googleapis.com/v1beta" + )); + assert!(is_gemini_base_url( + "https://generativelanguage.googleapis.com/v1beta/" + )); assert!(!is_gemini_base_url("https://api.openai.com/v1")); - assert!(!is_gemini_base_url("https://ark.cn-beijing.volces.com/api/v3")); + assert!(!is_gemini_base_url( + "https://ark.cn-beijing.volces.com/api/v3" + )); } #[test] diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index e2e16754..c3fe72f8 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -37,7 +37,10 @@ use crate::persistence::{ }; use crate::llm_gemini::{GeminiConfig, GeminiProvider}; -use crate::polish::{OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; +use crate::polish::{ + ActiveLLMProvider, CodexOAuthConfig, CodexOAuthLLMProvider, OpenAICompatibleConfig, + OpenAICompatibleLLMProvider, CODEX_DEFAULT_MODEL, CODEX_OAUTH_PROVIDER_ID, +}; use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor}; use crate::recorder::{Recorder, RecorderError}; use crate::selection::capture_selection; @@ -2068,18 +2071,7 @@ async fn polish_text( .await?); } - let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default(); - let model = CredentialsVault::get(CredentialAccount::ArkModelId)? - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "deepseek-v3-2".to_string()); - let endpoint = resolve_ark_endpoint(&api_key)?; - let base_url = endpoint - .trim_end_matches("/chat/completions") - .trim_end_matches('/') - .to_string(); - - let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model); - let provider = OpenAICompatibleLLMProvider::new(config); + let provider = build_active_llm_provider()?; Ok(provider .polish( raw, @@ -2146,18 +2138,7 @@ async fn translate_text( .await?); } - let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default(); - let model = CredentialsVault::get(CredentialAccount::ArkModelId)? - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "deepseek-v3-2".to_string()); - let endpoint = resolve_ark_endpoint(&api_key)?; - let base_url = endpoint - .trim_end_matches("/chat/completions") - .trim_end_matches('/') - .to_string(); - - let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model); - let provider = OpenAICompatibleLLMProvider::new(config); + let provider = build_active_llm_provider()?; Ok(provider .translate_to( raw, @@ -2736,17 +2717,7 @@ where .await?); } - let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default(); - let model = CredentialsVault::get(CredentialAccount::ArkModelId)? - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "deepseek-v3-2".to_string()); - let endpoint = resolve_ark_endpoint(&api_key)?; - let base_url = endpoint - .trim_end_matches("/chat/completions") - .trim_end_matches('/') - .to_string(); - let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model); - let provider = OpenAICompatibleLLMProvider::new(config); + let provider = build_active_llm_provider()?; Ok(provider .answer_chat_streaming( messages, @@ -2784,6 +2755,29 @@ fn read_gemini_credentials() -> anyhow::Result<(String, String, String)> { Ok((api_key, model, base_url)) } +fn build_active_llm_provider() -> anyhow::Result { + let active = CredentialsVault::get_active_llm(); + let model = + CredentialsVault::get(CredentialAccount::ArkModelId)?.filter(|s| !s.trim().is_empty()); + if active == CODEX_OAUTH_PROVIDER_ID { + let config = + CodexOAuthConfig::new(model.unwrap_or_else(|| CODEX_DEFAULT_MODEL.to_string())); + return Ok(ActiveLLMProvider::Codex(CodexOAuthLLMProvider::new(config))); + } + + let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default(); + let model = model.unwrap_or_else(|| "deepseek-v3-2".to_string()); + let endpoint = resolve_ark_endpoint(&api_key)?; + let base_url = endpoint + .trim_end_matches("/chat/completions") + .trim_end_matches('/') + .to_string(); + let config = OpenAICompatibleConfig::new(active, "OpenLess LLM", base_url, api_key, model); + Ok(ActiveLLMProvider::OpenAI(OpenAICompatibleLLMProvider::new( + config, + ))) +} + fn resolve_ark_endpoint(api_key: &str) -> anyhow::Result { let endpoint = CredentialsVault::get(CredentialAccount::ArkEndpoint)?.filter(|s| !s.is_empty()); resolve_ark_endpoint_with_policy(api_key, endpoint) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index f5984202..43f45e7d 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -796,8 +796,7 @@ impl HistoryStore { // Prepend so the newest session is at index 0, matching the Swift impl. sessions.insert(0, session); if retention_days > 0 { - let cutoff = - chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days)); + let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days)); sessions.retain(|s| { chrono::DateTime::parse_from_rfc3339(&s.created_at) .map(|t| t.with_timezone(&chrono::Utc) >= cutoff) diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 2ffc1509..b7da2e24 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -5,7 +5,10 @@ use std::borrow::Cow; use std::collections::HashMap; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; use std::time::Duration; +use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::{json, Value}; use thiserror::Error; @@ -15,6 +18,10 @@ use crate::types::{ChineseScriptPreference, OutputLanguagePreference, PolishMode const DEFAULT_TEMPERATURE: f32 = 0.3; const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; const BODY_PREVIEW_LIMIT: usize = 200; +pub const CODEX_OAUTH_PROVIDER_ID: &str = "codex_oauth"; +pub const CODEX_DEFAULT_BASE_URL: &str = "https://chatgpt.com/backend-api"; +pub const CODEX_DEFAULT_MODEL: &str = "gpt-5.3-codex-spark"; +const CODEX_MIN_TOKEN_TTL_SECS: u64 = 60; #[derive(Clone, Debug)] pub struct OpenAICompatibleConfig { @@ -61,6 +68,139 @@ pub enum LLMError { InvalidResponse { status: u16, body: String }, #[error("parse error: {0}")] ParseError(String), + #[error("codex oauth credentials unavailable: {0}")] + CodexAuth(String), +} + +pub enum ActiveLLMProvider { + OpenAI(OpenAICompatibleLLMProvider), + Codex(CodexOAuthLLMProvider), +} + +impl ActiveLLMProvider { + pub async fn polish( + &self, + raw_text: &str, + mode: PolishMode, + hotwords: &[String], + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + front_app: Option<&str>, + prior_turns: &[(String, String)], + ) -> Result { + match self { + Self::OpenAI(provider) => { + provider + .polish( + raw_text, + mode, + hotwords, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + prior_turns, + ) + .await + } + Self::Codex(provider) => { + provider + .polish( + raw_text, + mode, + hotwords, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + prior_turns, + ) + .await + } + } + } + + pub async fn translate_to( + &self, + raw_text: &str, + target_language: &str, + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + front_app: Option<&str>, + ) -> Result { + match self { + Self::OpenAI(provider) => { + provider + .translate_to( + raw_text, + target_language, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + ) + .await + } + Self::Codex(provider) => { + provider + .translate_to( + raw_text, + target_language, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + ) + .await + } + } + } + + pub async fn answer_chat_streaming( + &self, + messages: &[QaChatMessage], + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + front_app: Option<&str>, + on_delta: F, + should_cancel: C, + ) -> Result + where + F: Fn(&str) + Send + Sync, + C: Fn() -> bool + Send + Sync, + { + match self { + Self::OpenAI(provider) => { + provider + .answer_chat_streaming( + messages, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + on_delta, + should_cancel, + ) + .await + } + Self::Codex(provider) => { + provider + .answer_chat_streaming( + messages, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + on_delta, + should_cancel, + ) + .await + } + } + } } pub struct OpenAICompatibleLLMProvider { @@ -73,8 +213,7 @@ impl OpenAICompatibleLLMProvider { // Build reqwest client with the configured timeout. If client construction // fails for some reason (it should not on a normal target), fall back to // the default client so we still surface a useful error at request time. - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(config.request_timeout_secs)) + let client = http_client_builder(&config.base_url, config.request_timeout_secs) .build() .unwrap_or_else(|_| reqwest::Client::new()); Self { config, client } @@ -415,6 +554,329 @@ impl OpenAICompatibleLLMProvider { } } +#[derive(Clone, Debug)] +pub struct CodexOAuthConfig { + pub base_url: String, + pub model: String, + pub auth_path: Option, + pub reasoning_effort: Option, + pub text_verbosity: Option, + pub request_timeout_secs: u64, +} + +impl CodexOAuthConfig { + pub fn new(model: impl Into) -> Self { + Self { + base_url: CODEX_DEFAULT_BASE_URL.to_string(), + model: normalize_codex_model(model.into().as_str()), + auth_path: None, + reasoning_effort: Some("medium".to_string()), + text_verbosity: Some("medium".to_string()), + request_timeout_secs: DEFAULT_REQUEST_TIMEOUT_SECS, + } + } + + pub fn with_base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into(); + self + } + + pub fn with_auth_path(mut self, auth_path: PathBuf) -> Self { + self.auth_path = Some(auth_path); + self + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CodexOAuthCredentials { + pub access_token: String, + pub account_id: String, + pub expires_at_unix_secs: u64, +} + +impl CodexOAuthCredentials { + pub fn load_default() -> Result { + Self::load_from_path(&default_codex_auth_path()) + } + + pub fn load_from_path(path: &Path) -> Result { + let body = std::fs::read_to_string(path).map_err(|e| { + LLMError::CodexAuth(format!("无法读取 Codex 登录文件 {}: {}", path.display(), e)) + })?; + let json: Value = serde_json::from_str(&body) + .map_err(|e| LLMError::CodexAuth(format!("Codex 登录文件不是合法 JSON: {}", e)))?; + let tokens = json + .get("tokens") + .and_then(|v| v.as_object()) + .ok_or_else(|| LLMError::CodexAuth("Codex 登录文件缺少 tokens 对象".into()))?; + let access_token = tokens + .get("access_token") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| LLMError::CodexAuth("Codex 登录文件缺少 access_token".into()))?; + let account_id = tokens + .get("account_id") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| LLMError::CodexAuth("Codex 登录文件缺少 account_id".into()))?; + + let payload = decode_jwt_payload(access_token)?; + let expires_at_unix_secs = payload + .get("exp") + .and_then(|v| v.as_u64()) + .ok_or_else(|| LLMError::CodexAuth("Codex access token 缺少 exp".into()))?; + let claim_account_id = payload + .get("https://api.openai.com/auth.chatgpt_account_id") + .and_then(|v| v.as_str()) + .map(str::trim); + if claim_account_id.is_some_and(|claim| claim != account_id) { + return Err(LLMError::CodexAuth( + "Codex access token 的 account id 与 auth.json 不一致".into(), + )); + } + let now = unix_now_secs(); + if expires_at_unix_secs <= now + CODEX_MIN_TOKEN_TTL_SECS { + return Err(LLMError::CodexAuth( + "Codex access token 已过期或即将过期,请先在 Codex CLI/App 重新登录".into(), + )); + } + + Ok(Self { + access_token: access_token.to_string(), + account_id: account_id.to_string(), + expires_at_unix_secs, + }) + } +} + +pub struct CodexOAuthLLMProvider { + config: CodexOAuthConfig, + client: reqwest::Client, +} + +impl CodexOAuthLLMProvider { + pub fn new(config: CodexOAuthConfig) -> Self { + let client = http_client_builder(&config.base_url, config.request_timeout_secs) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + Self { config, client } + } + + pub fn config(&self) -> &CodexOAuthConfig { + &self.config + } + + pub async fn polish( + &self, + raw_text: &str, + mode: PolishMode, + hotwords: &[String], + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + front_app: Option<&str>, + prior_turns: &[(String, String)], + ) -> Result { + let mut system_prompt = compose_system_prompt(mode, hotwords); + if let Some(premise) = context_premise( + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + ) { + system_prompt = format!("{}\n\n{}", premise, system_prompt); + } + if !prior_turns.is_empty() { + system_prompt = format!( + "{}\n\n{}", + system_prompt, + prompts::polish_context_instruction() + ); + } + let user_prompt = prompts::user_prompt(raw_text); + let messages = build_polish_history_messages(&system_prompt, prior_turns, &user_prompt); + self.codex_responses(messages, |_| {}, || false).await + } + + pub async fn translate_to( + &self, + raw_text: &str, + target_language: &str, + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + _output_language_preference: OutputLanguagePreference, + front_app: Option<&str>, + ) -> Result { + let mut system_prompt = prompts::translate_system_prompt(target_language); + if let Some(premise) = context_premise( + working_languages, + chinese_script_preference, + OutputLanguagePreference::Auto, + front_app, + ) { + system_prompt = format!("{}\n\n{}", premise, system_prompt); + } + let messages = vec![ + json!({ "role": "system", "content": system_prompt }), + json!({ "role": "user", "content": prompts::user_prompt(raw_text) }), + ]; + self.codex_responses(messages, |_| {}, || false).await + } + + pub async fn answer_chat_streaming( + &self, + messages: &[QaChatMessage], + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + front_app: Option<&str>, + on_delta: F, + should_cancel: C, + ) -> Result + where + F: Fn(&str) + Send + Sync, + C: Fn() -> bool + Send + Sync, + { + let mut system_prompt = prompts::qa_system_prompt(); + if let Some(premise) = context_premise( + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + ) { + system_prompt = format!("{}\n\n{}", premise, system_prompt); + } + + let mut request_messages = Vec::with_capacity(messages.len() + 1); + request_messages.push(json!({ "role": "system", "content": system_prompt })); + for message in messages { + request_messages.push(json!({ "role": message.role, "content": message.content })); + } + self.codex_responses(request_messages, on_delta, should_cancel) + .await + } + + async fn codex_responses( + &self, + messages: Vec, + on_delta: F, + should_cancel: C, + ) -> Result + where + F: Fn(&str) + Send + Sync, + C: Fn() -> bool + Send + Sync, + { + let auth_path = self + .config + .auth_path + .clone() + .unwrap_or_else(default_codex_auth_path); + let creds = CodexOAuthCredentials::load_from_path(&auth_path)?; + let url = codex_responses_url(&self.config.base_url); + let mut body = json!({ + "model": normalize_codex_model(&self.config.model), + "store": false, + "stream": true, + "input": codex_input_from_chat_messages(&messages), + "include": ["reasoning.encrypted_content"], + "instructions": "You are OpenLess' text polishing assistant. Follow the developer messages exactly and return only the final user-visible text.", + }); + if let Some(effort) = self.config.reasoning_effort.as_deref() { + body["reasoning"] = json!({ "effort": effort }); + } + if let Some(verbosity) = self.config.text_verbosity.as_deref() { + body["text"] = json!({ "verbosity": verbosity }); + } + + log::info!( + "[llm] POST {} provider={} model={} stream=true", + url, + CODEX_OAUTH_PROVIDER_ID, + self.config.model + ); + + let request = self + .client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream") + .header("Authorization", format!("Bearer {}", creds.access_token)) + .header("chatgpt-account-id", creds.account_id) + .header("OpenAI-Beta", "responses=experimental") + .header("originator", "codex_cli_rs") + .json(&body); + let response = match request.send().await { + Ok(r) => r, + Err(e) => { + if e.is_timeout() { + return Err(LLMError::Timeout); + } + return Err(LLMError::Network(e.to_string())); + } + }; + + let status = response.status(); + if !status.is_success() { + let body_text = response + .text() + .await + .map_err(|e| LLMError::Network(e.to_string()))?; + let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len()); + let preview = safe_str_slice(&body_text, preview_end); + log::error!("[llm] codex HTTP {} body={}", status.as_u16(), preview); + return Err(LLMError::InvalidResponse { + status: status.as_u16(), + body: preview.to_string(), + }); + } + + let mut response = response; + let mut buffer = String::new(); + let mut full_text = String::new(); + let mut final_text = String::new(); + loop { + if should_cancel() { + log::info!("[llm] codex stream cancelled by caller; breaking SSE loop"); + break; + } + let chunk_opt = response + .chunk() + .await + .map_err(|e| LLMError::Network(e.to_string()))?; + let Some(chunk) = chunk_opt else { break }; + let s = std::str::from_utf8(&chunk) + .map_err(|e| LLMError::Network(format!("non-utf8 SSE chunk: {e}")))?; + buffer.push_str(s); + + while let Some(idx) = buffer.find("\n\n") { + let event = buffer[..idx].to_string(); + buffer.drain(..idx + 2); + handle_codex_sse_event(&event, &mut full_text, &mut final_text, &on_delta); + } + } + if !buffer.trim().is_empty() { + handle_codex_sse_event(&buffer, &mut full_text, &mut final_text, &on_delta); + } + + if full_text.is_empty() && !final_text.is_empty() { + full_text = final_text; + } + log::info!( + "[llm] codex HTTP 200 stream done; total chars={}", + full_text.chars().count() + ); + if full_text.is_empty() { + return Err(LLMError::InvalidResponse { + status: 200, + body: "empty stream".to_string(), + }); + } + Ok(clean_polish_output(&full_text)) + } +} + /// Slice up to `end` bytes off `s`, but don't split a UTF-8 codepoint. pub(crate) fn safe_str_slice(s: &str, end: usize) -> &str { if end >= s.len() { @@ -465,6 +927,237 @@ fn chat_completions_url(base_url: &str) -> String { format!("{}/chat/completions", without_trailing) } +pub(crate) fn http_client_builder(base_url: &str, timeout_secs: u64) -> reqwest::ClientBuilder { + let builder = reqwest::Client::builder().timeout(Duration::from_secs(timeout_secs)); + if should_bypass_proxy_for_base_url(base_url) { + builder.no_proxy() + } else { + builder + } +} + +fn should_bypass_proxy_for_base_url(base_url: &str) -> bool { + let Ok(url) = reqwest::Url::parse(base_url.trim()) else { + return false; + }; + let Some(host) = url.host_str() else { + return false; + }; + if host.eq_ignore_ascii_case("localhost") { + return true; + } + host.parse::().is_ok_and(|ip| ip.is_loopback()) +} + +fn codex_responses_url(base_url: &str) -> String { + let trimmed = base_url.trim(); + if trimmed.ends_with("/codex/responses") { + return trimmed.to_string(); + } + let without_trailing = trimmed.strip_suffix('/').unwrap_or(trimmed); + format!("{}/codex/responses", without_trailing) +} + +fn default_codex_auth_path() -> PathBuf { + if let Ok(path) = std::env::var("OPENLESS_CODEX_AUTH_PATH") { + let trimmed = path.trim(); + if !trimmed.is_empty() { + return PathBuf::from(trimmed); + } + } + default_codex_home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".codex") + .join("auth.json") +} + +fn default_codex_home_dir() -> Option { + if let Some(home) = non_empty_env_path("HOME") { + return Some(home); + } + if let Some(userprofile) = non_empty_env_path("USERPROFILE") { + return Some(userprofile); + } + let drive = std::env::var_os("HOMEDRIVE")?; + let path = std::env::var_os("HOMEPATH")?; + let drive = drive.to_string_lossy(); + let path = path.to_string_lossy(); + if drive.trim().is_empty() || path.trim().is_empty() { + return None; + } + Some(PathBuf::from(format!("{drive}{path}"))) +} + +fn non_empty_env_path(key: &str) -> Option { + std::env::var_os(key) + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) +} + +fn normalize_codex_model(model: &str) -> String { + let trimmed = model.trim(); + let normalized = trimmed + .rsplit_once('/') + .map(|(_, tail)| tail.trim()) + .unwrap_or(trimmed); + if normalized.is_empty() { + CODEX_DEFAULT_MODEL.to_string() + } else { + normalized.to_string() + } +} + +fn codex_input_from_chat_messages(messages: &[Value]) -> Vec { + messages + .iter() + .filter_map(|message| { + let role = message.get("role").and_then(|v| v.as_str())?; + let text = message.get("content").and_then(|v| v.as_str())?; + let (codex_role, content_type) = match role { + "system" => ("developer", "input_text"), + "assistant" => ("assistant", "output_text"), + _ => ("user", "input_text"), + }; + Some(json!({ + "type": "message", + "role": codex_role, + "content": [{ "type": content_type, "text": text }], + })) + }) + .collect() +} + +fn handle_codex_sse_event( + event: &str, + full_text: &mut String, + final_text: &mut String, + on_delta: &F, +) where + F: Fn(&str) + Send + Sync, +{ + for line in event.lines() { + let Some(payload) = line + .strip_prefix("data: ") + .or_else(|| line.strip_prefix("data:")) + else { + continue; + }; + let payload = payload.trim(); + if payload.is_empty() || payload == "[DONE]" { + continue; + } + let v: Value = match serde_json::from_str(payload) { + Ok(v) => v, + Err(e) => { + log::warn!( + "[llm] codex SSE parse skip: {e}; payload preview: {}", + safe_str_slice(payload, 80) + ); + continue; + } + }; + if let Some(delta) = extract_codex_text_delta(&v) { + if !delta.is_empty() { + full_text.push_str(delta); + on_delta(delta); + } + } + let event_type = v.get("type").and_then(|t| t.as_str()).unwrap_or_default(); + if matches!(event_type, "response.done" | "response.completed") { + if let Some(text) = extract_codex_response_text(v.get("response").unwrap_or(&v)) { + *final_text = text; + } + } + } +} + +fn extract_codex_text_delta(event: &Value) -> Option<&str> { + let event_type = event + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + if !(event_type.ends_with("output_text.delta") || event_type.ends_with("text.delta")) { + return None; + } + event + .get("delta") + .and_then(|v| v.as_str()) + .or_else(|| event.get("text").and_then(|v| v.as_str())) +} + +fn extract_codex_response_text(response: &Value) -> Option { + if let Some(text) = response.get("output_text").and_then(|v| v.as_str()) { + return Some(clean_polish_output(text)); + } + + let mut pieces = Vec::new(); + let output = response.get("output").and_then(|v| v.as_array())?; + for item in output { + if item.get("type").and_then(|v| v.as_str()) != Some("message") { + continue; + } + let Some(content) = item.get("content").and_then(|v| v.as_array()) else { + continue; + }; + for part in content { + let text = part + .get("text") + .and_then(|v| v.as_str()) + .or_else(|| part.get("content").and_then(|v| v.as_str())); + if let Some(text) = text { + pieces.push(text); + } + } + } + if pieces.is_empty() { + None + } else { + Some(clean_polish_output(&pieces.join(""))) + } +} + +fn decode_jwt_payload(token: &str) -> Result { + let payload = token + .split('.') + .nth(1) + .ok_or_else(|| LLMError::CodexAuth("Codex access token 不是 JWT 格式".into()))?; + let bytes = decode_base64_url(payload) + .map_err(|e| LLMError::CodexAuth(format!("Codex access token payload 解码失败: {e}")))?; + serde_json::from_slice(&bytes) + .map_err(|e| LLMError::CodexAuth(format!("Codex access token payload 不是合法 JSON: {e}"))) +} + +fn decode_base64_url(input: &str) -> Result, String> { + let mut buffer = 0u32; + let mut bits = 0u8; + let mut out = Vec::with_capacity(input.len() * 3 / 4); + for byte in input.bytes() { + let value = match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a' + 26, + b'0'..=b'9' => byte - b'0' + 52, + b'-' => 62, + b'_' => 63, + b'=' => continue, + _ => return Err(format!("invalid base64url byte 0x{byte:02x}")), + }; + buffer = (buffer << 6) | u32::from(value); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push(((buffer >> bits) & 0xff) as u8); + } + } + Ok(out) +} + +fn unix_now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + /// 把 working_languages + front_app 拼成 system prompt 头部前提: /// # 上下文 /// 用户的工作语言:… @@ -1094,10 +1787,131 @@ pub mod prompts { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsString; use std::io::{Read, Write}; use std::net::TcpListener; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Mutex as StdMutex; use std::thread; + static CODEX_AUTH_FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0); + static ENV_LOCK: StdMutex<()> = StdMutex::new(()); + + struct EnvSnapshot { + values: Vec<(&'static str, Option)>, + } + + impl EnvSnapshot { + fn capture(keys: &[&'static str]) -> Self { + Self { + values: keys + .iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + } + } + } + + impl Drop for EnvSnapshot { + fn drop(&mut self) { + for (key, value) in &self.values { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + } + + fn unique_codex_auth_path(label: &str) -> PathBuf { + let id = CODEX_AUTH_FIXTURE_COUNTER.fetch_add(1, Ordering::SeqCst); + std::env::temp_dir().join(format!( + "openless-codex-{label}-{}-{}-{id}.json", + std::process::id(), + unix_now_secs() + )) + } + + fn write_codex_auth_fixture(account_id: &str, exp: u64) -> PathBuf { + let path = unique_codex_auth_path(&format!("auth-{account_id}")); + let token = fixture_access_token(account_id, exp); + std::fs::write( + &path, + format!( + r#"{{"tokens":{{"access_token":"{}","account_id":"{}"}}}}"#, + token, account_id + ), + ) + .unwrap(); + path + } + + fn fixture_access_token(account_id: &str, exp: u64) -> String { + let header = base64_url_no_pad(r#"{"alg":"none"}"#); + let payload = base64_url_no_pad(&format!( + r#"{{"exp":{},"https://api.openai.com/auth.chatgpt_account_id":"{}"}}"#, + exp, account_id + )); + format!("{}.{}.sig", header, payload) + } + + fn fixture_access_token_without_account_claim(exp: u64) -> String { + let header = base64_url_no_pad(r#"{"alg":"none"}"#); + let payload = base64_url_no_pad(&format!(r#"{{"exp":{}}}"#, exp)); + format!("{}.{}.sig", header, payload) + } + + fn base64_url_no_pad(input: &str) -> String { + const TABLE: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let bytes = input.as_bytes(); + let mut out = String::new(); + let mut i = 0; + while i < bytes.len() { + let b0 = bytes[i]; + let b1 = bytes.get(i + 1).copied().unwrap_or(0); + let b2 = bytes.get(i + 2).copied().unwrap_or(0); + out.push(TABLE[(b0 >> 2) as usize] as char); + out.push(TABLE[(((b0 & 0b0000_0011) << 4) | (b1 >> 4)) as usize] as char); + if i + 1 < bytes.len() { + out.push(TABLE[(((b1 & 0b0000_1111) << 2) | (b2 >> 6)) as usize] as char); + } + if i + 2 < bytes.len() { + out.push(TABLE[(b2 & 0b0011_1111) as usize] as char); + } + i += 3; + } + out + } + + fn read_http_request(stream: &mut std::net::TcpStream) -> Vec { + let mut buf = [0u8; 8192]; + let mut request = Vec::new(); + loop { + let n = stream.read(&mut buf).unwrap(); + if n == 0 { + break; + } + request.extend_from_slice(&buf[..n]); + let Some(header_end) = request.windows(4).position(|w| w == b"\r\n\r\n") else { + continue; + }; + let header_text = String::from_utf8_lossy(&request[..header_end + 4]); + let content_length = header_text + .lines() + .find_map(|line| { + line.strip_prefix("content-length:") + .or_else(|| line.strip_prefix("Content-Length:")) + }) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0); + if request.len() >= header_end + 4 + content_length { + break; + } + } + request + } + // ──────────────── 对话感知 polish 的 chat 消息构造 ──────────────── // 用户的核心顾虑:让 LLM 拿到上下文但**不要把上下文吐出来**。 // 这里的不变量保证「不复读」靠两层防御: @@ -1129,7 +1943,11 @@ mod tests { let msgs = build_polish_history_messages("SYS", &prior, "USER_NOW"); // 1 system + 3 turns × 2 + 1 current = 8 条 - assert_eq!(msgs.len(), 8, "应该是 system + 3×(user/assistant) + 当前 user"); + assert_eq!( + msgs.len(), + 8, + "应该是 system + 3×(user/assistant) + 当前 user" + ); // [0] system assert_eq!(msgs[0]["role"], "system"); @@ -1359,6 +2177,145 @@ mod tests { } } + #[test] + fn codex_oauth_reads_codex_app_auth_file_without_refresh() { + let exp = unix_now_secs() + 3600; + let auth_path = write_codex_auth_fixture("acct-openless", exp); + + let creds = CodexOAuthCredentials::load_from_path(&auth_path).unwrap(); + + assert_eq!( + creds.access_token, + fixture_access_token("acct-openless", exp) + ); + assert_eq!(creds.account_id, "acct-openless"); + assert!(creds.expires_at_unix_secs > unix_now_secs()); + + let _ = std::fs::remove_file(auth_path); + } + + #[test] + fn codex_oauth_accepts_real_auth_file_without_account_claim() { + let path = unique_codex_auth_path("auth-no-claim"); + let exp = unix_now_secs() + 3600; + let token = fixture_access_token_without_account_claim(exp); + std::fs::write( + &path, + format!( + r#"{{"tokens":{{"access_token":"{}","account_id":"acct-openless"}}}}"#, + token + ), + ) + .unwrap(); + + let creds = CodexOAuthCredentials::load_from_path(&path).unwrap(); + + assert_eq!(creds.account_id, "acct-openless"); + assert_eq!(creds.expires_at_unix_secs, exp); + let _ = std::fs::remove_file(path); + } + + #[test] + fn codex_oauth_rejects_mismatched_account_claim() { + let path = unique_codex_auth_path("auth-mismatch"); + let token = fixture_access_token("acct-a", unix_now_secs() + 3600); + std::fs::write( + &path, + format!( + r#"{{"tokens":{{"access_token":"{}","account_id":"acct-b"}}}}"#, + token + ), + ) + .unwrap(); + + let err = CodexOAuthCredentials::load_from_path(&path).unwrap_err(); + + assert!(matches!(err, LLMError::CodexAuth(_))); + let _ = std::fs::remove_file(path); + } + + #[test] + fn default_codex_auth_path_falls_back_to_userprofile_when_home_missing() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvSnapshot::capture(&[ + "OPENLESS_CODEX_AUTH_PATH", + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + ]); + let userprofile = std::env::temp_dir().join("openless-codex-userprofile"); + std::env::remove_var("OPENLESS_CODEX_AUTH_PATH"); + std::env::remove_var("HOME"); + std::env::set_var("USERPROFILE", &userprofile); + std::env::remove_var("HOMEDRIVE"); + std::env::remove_var("HOMEPATH"); + + assert_eq!( + default_codex_auth_path(), + userprofile.join(".codex").join("auth.json") + ); + } + + #[tokio::test] + async fn codex_oauth_provider_streams_text_from_codex_responses() { + let auth_path = write_codex_auth_fixture("acct-openless", unix_now_secs() + 3600); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + let request_text = String::from_utf8_lossy(&request); + let request_text_lower = request_text.to_ascii_lowercase(); + assert!(request_text.starts_with("POST /codex/responses HTTP/1.1")); + assert!(request_text_lower.contains("authorization: bearer ")); + assert!(request_text_lower.contains("chatgpt-account-id: acct-openless")); + assert!(request_text_lower.contains("openai-beta: responses=experimental")); + assert!(request_text_lower.contains("originator: codex_cli_rs")); + assert!(request_text.contains(r#""store":false"#)); + assert!(request_text.contains(r#""stream":true"#)); + assert!(request_text.contains(r#""role":"developer"#)); + assert!(request_text.contains(r#""type":"input_text"#)); + assert!(!request_text.contains(r#""temperature":"#)); + + let body = concat!( + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"最终\"}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"文本。\"}\n\n", + "data: {\"type\":\"response.completed\",\"response\":{\"output\":[]}}\n\n" + ); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes()).unwrap(); + }); + + let provider = CodexOAuthLLMProvider::new( + CodexOAuthConfig::new("gpt-5.5") + .with_base_url(format!("http://{}", addr)) + .with_auth_path(auth_path.clone()), + ); + let output = provider + .polish( + "原文", + PolishMode::Raw, + &[], + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + None, + &[], + ) + .await + .unwrap(); + + assert_eq!(output, "最终文本。"); + server.join().unwrap(); + let _ = std::fs::remove_file(auth_path); + } + #[tokio::test] async fn chat_completion_omits_authorization_when_api_key_is_empty() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index bf1a4631..07776e34 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -355,6 +355,7 @@ export const en: typeof zhCN = { providerLabel: 'Provider', llmProviderDesc: 'Selecting a preset auto-fills the default Base URL.', credentialStorageNotice: 'Credentials are stored in the OS credential vault. Legacy local JSON credentials are migrated into the vault and removed after a successful write.', + codexOAuthNotice: 'Codex OAuth uses the local Codex login state (~/.codex/auth.json). OpenLess does not store an API key or Base URL for this provider.', asrProviderDesc: 'Switching providers automatically loads the matching credentials.', asrTitle: 'ASR (transcription)', asrDesc: 'Used to turn speech into text in real time.', @@ -364,6 +365,7 @@ export const en: typeof zhCN = { siliconflow: 'SiliconFlow', openai: 'OpenAI', gemini: 'Google Gemini', + codexOAuth: 'Codex OAuth', mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter (free models)', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 45c2836a..8100df4b 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -357,6 +357,7 @@ export const ja: typeof zhCN = { providerLabel: 'サプライヤー', llmProviderDesc: '選択するとデフォルトの Base URL が自動入力されます。', credentialStorageNotice: '認証情報は OS の認証情報ストアに保存されます。旧バージョンのローカル JSON 認証情報はストアへ移行され、書き込み成功後に削除されます。', + codexOAuthNotice: 'Codex OAuth はローカルの Codex ログイン状態(~/.codex/auth.json)を使用します。OpenLess は API Key や Base URL を保存しません。', asrProviderDesc: '切り替えると対応する認証情報が自動選択されます。', asrTitle: 'ASR 音声(転写)', asrDesc: '口述をリアルタイムでテキストに転写。', @@ -366,6 +367,7 @@ export const ja: typeof zhCN = { siliconflow: 'SiliconFlow', openai: 'OpenAI', gemini: 'Google Gemini', + codexOAuth: 'Codex OAuth', mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(無料モデル)', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 1fbf83a5..31545f98 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -357,6 +357,7 @@ export const ko: typeof zhCN = { providerLabel: '공급자', llmProviderDesc: '선택 시 Base URL 기본값이 자동 입력됩니다.', credentialStorageNotice: '자격 증명은 OS 자격 증명 저장소에 저장됩니다. 이전 로컬 JSON 자격 증명은 저장소로 마이그레이션되고, 쓰기 성공 후 삭제됩니다.', + codexOAuthNotice: 'Codex OAuth는 로컬 Codex 로그인 상태(~/.codex/auth.json)를 사용합니다. OpenLess는 API Key나 Base URL을 저장하지 않습니다.', asrProviderDesc: '전환 시 해당하는 자격 증명이 자동 선택됩니다.', asrTitle: 'ASR 음성(전사)', asrDesc: '구술을 실시간으로 텍스트로 전사합니다.', @@ -366,6 +367,7 @@ export const ko: typeof zhCN = { siliconflow: 'SiliconFlow', openai: 'OpenAI', gemini: 'Google Gemini', + codexOAuth: 'Codex OAuth', mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(무료 모델)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index d9da9665..7a1765ac 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -353,6 +353,7 @@ export const zhCN = { providerLabel: '供应商', llmProviderDesc: '选择后将自动填入 Base URL 默认值。', credentialStorageNotice: '凭据保存在系统凭据库中。旧版本地 JSON 凭据会迁移到系统凭据库,并在成功写入后删除。', + codexOAuthNotice: 'Codex OAuth 使用本机 Codex 登录状态(~/.codex/auth.json),无需在 OpenLess 中保存 API Key 或 Base URL。', asrProviderDesc: '切换后将自动选用对应凭据。', asrTitle: 'ASR 语音(转写)', asrDesc: '用于将口述实时转写为文本。', @@ -362,6 +363,7 @@ export const zhCN = { siliconflow: '硅基流动', openai: 'OpenAI', gemini: 'Google Gemini', + codexOAuth: 'Codex OAuth', mimo: '小米 MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(免费模型)', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index d9417145..a838e2c2 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -355,6 +355,7 @@ export const zhTW: typeof zhCN = { providerLabel: '供應商', llmProviderDesc: '選擇後將自動填入 Base URL 默認值。', credentialStorageNotice: '憑據儲存在系統憑據庫中。舊版本機 JSON 憑據會遷移到系統憑據庫,並在成功寫入後刪除。', + codexOAuthNotice: 'Codex OAuth 使用本機 Codex 登入狀態(~/.codex/auth.json),無需在 OpenLess 中保存 API Key 或 Base URL。', asrProviderDesc: '切換後將自動選用對應憑據。', asrTitle: 'ASR 語音(轉寫)', asrDesc: '用於將口述實時轉寫爲文本。', @@ -364,6 +365,7 @@ export const zhTW: typeof zhCN = { siliconflow: '硅基流動', openai: 'OpenAI', gemini: 'Google Gemini', + codexOAuth: 'Codex OAuth', mimo: '小米 MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(免費模型)', diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 8316bb68..ea26b74b 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -39,6 +39,7 @@ const LLM_NAME_KEY_BY_ID: Record = { deepseek: 'deepseek', siliconflow: 'siliconflow', openai: 'openai', + codex_oauth: 'codexOAuth', mimo: 'mimo', cometapi: 'cometapi', openrouterFree: 'openrouterFree', diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 89124acb..5ae5f5d8 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -1177,6 +1177,12 @@ const LLM_PRESETS = [ baseUrl: 'https://generativelanguage.googleapis.com/v1beta', modelPlaceholder: 'gemini-2.5-flash', }, + { + id: 'codex_oauth', + nameKey: 'codexOAuth', + baseUrl: '', + modelPlaceholder: 'gpt-5.3-codex-spark', + }, { id: 'mimo', nameKey: 'mimo', @@ -1217,7 +1223,7 @@ const LLM_PRESETS = [ type LlmPresetId = typeof LLM_PRESETS[number]['id']; -const ASR_DEFAULT_RESOURCE_ID = 'volc.seedasr.sauc.duration'; +const ASR_DEFAULT_RESOURCE_ID = 'volc.bigasr.sauc.duration'; // `volcengine` / `bailian` 走自建流式客户端;其余走 OpenAI 兼容 // `/audio/transcriptions`(`coordinator.rs::is_whisper_compatible_provider`)。 @@ -1351,6 +1357,7 @@ function ProvidersSection() { // 否则受控
- - + {codexOAuthSelected ? ( +
+ {t('settings.providers.codexOAuthNotice')} +
+ ) : ( + <> + + + + )} setLlmModelRevision(v => v + 1)} /> From 3125ff4d202b90b8b7089a0badb760c88254b265 Mon Sep 17 00:00:00 2001 From: TRIP <1933142963@qq.com> Date: Mon, 11 May 2026 07:01:00 +0800 Subject: [PATCH 33/50] feat(settings): cross-platform parity for local ASR (#403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings): cross-platform parity for local ASR - AdvancedSection: warning collapses to inline 11px amber badge in the title row's top-right; the enable-confirm popup is now a centered fixed modal with backdrop blur(8px) and click-outside- to-cancel (busy-guarded). - Symmetric rows by platform — fixes the #400 bug where the Qwen3 row was rendered on Windows even though all qwen_engine / local_provider / cache modules are #[cfg(target_os = "macos")], so Enable on Windows would always error: macOS: Qwen3 row enabled / Foundry row hidden Windows: Foundry row enabled / Qwen3 row disabled with notSupportedHere desc - Providers ASR Card: replaces the plain-text localAsrActiveNotice branch with an always-rendered dropdown that includes the platform-native local engine (macOS=Qwen3, Windows=Foundry) as a disabled option, plus a takeover hint underneath. Entire dropdown locks when a local engine is active, so it can only be released via Advanced (matches "启动之后供应商将被接管" UX). - onLlmProviderChange / onAsrProviderChange now emit saving → saved through emitSaved. Data was already being persisted; the saved-toast was missing. - LocalAsr embedded: hide Qwen3 model management (mirror / engine-status / model list) on Windows — dead UI without the Qwen3 backend. The shared error Card is kept unconditional since Foundry handlers also call setError. - i18n × 5 (zh-CN/zh-TW/en/ja/ko): shorten localAsrWarningShort; rewrite qwen3Desc and foundryDesc to '启动之后,ASR 提供商将被接管'; add notSupportedHere and localAsrTakeoverHint. * fix(settings): clear saving toast on provider-switch failure pr_agent #403 caught: emitSaved('saving') with no try/catch — if setActiveLlmProvider / setActiveAsrProvider / any later credential write throws, the right-corner toast stays stuck on saving forever. Wrap both onLlmProviderChange and onAsrProviderChange bodies in try/catch. On error, emit 'failed' (and re-throw) only if seq still matches — stale calls superseded by a newer switch shouldn't overwrite the newer call's own saving state. * fix(settings): provider-card visual cleanup + log-export permission + pr_agent #403 Fixes piled up in user feedback during local verify of the previous commit; bundled into one commit to keep #403 small. - capabilities/default.json: add `dialog:default` permission. The About → Export error log button always failed because the Tauri 2 capabilities did not allow `tauri-plugin-dialog`'s `save()` command; the JS rejected before export_error_log was ever invoked. - ProvidersSection ASR Card (pr_agent #403 'Wrong active label'): when committedAsrProvider is a local engine, selectedValue now reflects the actually-active engine instead of being forced to platformLocalAsr. Cross-machine config sync (macOS profile with foundry-local-whisper / Windows profile with local-qwen3) used to make the dropdown lie about which backend is running. Anomalous local engines are added as disabled