diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 9218eef7..52b38614 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1752,7 +1752,7 @@ fn should_try_non_tsf_insertion_fallback( } #[cfg(target_os = "windows")] -fn insert_via_non_tsf_fallback( +pub(super) fn insert_via_non_tsf_fallback( inner: &Arc, polished: &str, _restore_clipboard: bool, @@ -2767,7 +2767,7 @@ mod tests { #[test] fn focus_restore_failure_uses_specific_error_code_when_insert_fails() { assert_eq!( - dictation_error_code(InsertStatus::Failed, false, false, false), + dictation_error_code(InsertStatus::Failed, false, false, false, false), Some("focusRestoreFailed") ); } @@ -2784,11 +2784,19 @@ mod tests { #[cfg(target_os = "windows")] fn tsf_required_failure_keeps_tsf_error_when_focus_was_ready() { assert_eq!( - dictation_error_code(InsertStatus::Failed, false, true, false), + dictation_error_code(InsertStatus::Failed, false, true, false, false), Some("windowsImeTsfRequired") ); } + #[test] + fn sendinput_only_mode_skips_tsf_required_error() { + assert_eq!( + dictation_error_code(InsertStatus::Failed, false, true, false, true), + None + ); + } + #[test] fn startup_race_check_treats_newer_session_as_stale() { let mut state = SessionState::default(); diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 00b5beec..f3b31d7b 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1068,9 +1068,11 @@ pub(super) async fn begin_session_as( }; #[cfg(target_os = "windows")] { - let prepared = inner.windows_ime.prepare_session(); - let mut slots = inner.prepared_windows_ime_session.lock(); - store_prepared_windows_ime_session(&mut slots, current_session_id, prepared); + if !inner.prefs.get().windows_sendinput_insertion_only { + let prepared = inner.windows_ime.prepare_session(); + let mut slots = inner.prepared_windows_ime_session.lock(); + store_prepared_windows_ime_session(&mut slots, current_session_id, prepared); + } } // 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。 inner @@ -2419,6 +2421,7 @@ pub(super) 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 windows_sendinput_insertion_only = prefs.windows_sendinput_insertion_only; let paste_shortcut = prefs.paste_shortcut; // 流式路径下,字符已经通过 Unicode keystroke 落到光标处,跳过 inserter.insert。 let status = if already_streamed { @@ -2441,17 +2444,30 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { if focus_ready_for_paste { #[cfg(target_os = "windows")] { - let ime_target = capture_ime_submit_target(); - insert_with_windows_ime_first( - inner, - current_session_id, - &polished, - restore_clipboard, - allow_non_tsf_insertion_fallback, - paste_shortcut, - ime_target, - ) - .await + if windows_sendinput_insertion_only { + if allow_non_tsf_insertion_fallback { + insert_via_non_tsf_fallback( + inner, + &polished, + restore_clipboard, + paste_shortcut, + ) + } else { + inner.inserter.insert_via_unicode_keystrokes(&polished) + } + } else { + let ime_target = capture_ime_submit_target(); + insert_with_windows_ime_first( + inner, + current_session_id, + &polished, + restore_clipboard, + allow_non_tsf_insertion_fallback, + paste_shortcut, + ime_target, + ) + .await + } } #[cfg(not(target_os = "windows"))] { @@ -2507,6 +2523,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { polish_error.is_some(), focus_ready_for_paste, allow_non_tsf_insertion_fallback, + windows_sendinput_insertion_only, ) .map(str::to_string); let tsf_required_insert_failed = error_code.as_deref() == Some("windowsImeTsfRequired"); @@ -2591,12 +2608,14 @@ pub(super) fn dictation_error_code( polish_failed: bool, focus_ready_for_paste: bool, allow_non_tsf_insertion_fallback: bool, + windows_sendinput_insertion_only: bool, ) -> Option<&'static str> { if !focus_ready_for_paste && status == InsertStatus::Failed { Some("focusRestoreFailed") } else if cfg!(target_os = "windows") && focus_ready_for_paste && !allow_non_tsf_insertion_fallback + && !windows_sendinput_insertion_only && status == InsertStatus::Failed { Some("windowsImeTsfRequired") diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index c63c65a1..1cff61b5 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -586,6 +586,14 @@ pub struct UserPreferences { /// 默认开启以保持可用性;关闭后可验证文本是否真正由 TSF 上屏。 #[serde(default = "default_true")] pub allow_non_tsf_insertion_fallback: bool, + /// Windows: 始终用 SendInput Unicode 插入,不切换 OpenLess TSF 输入法。 + /// 适用于输入法无法正确还原的用户。默认 false 保持 TSF 优先。 + #[serde( + default, + rename = "windowsSendInputInsertionOnly", + alias = "windowsSendinputInsertionOnly" + )] + pub windows_sendinput_insertion_only: bool, /// 用户的工作语言(多选,原生名)。会作为前提注入 LLM polish/translate 的 system prompt 头部, /// 让模型知道该用户在哪些语言间工作。详见 issue #4。 #[serde(default = "default_working_languages")] @@ -883,6 +891,12 @@ struct UserPreferencesWire { #[serde(default)] paste_shortcut: PasteShortcut, allow_non_tsf_insertion_fallback: bool, + #[serde( + default, + rename = "windowsSendInputInsertionOnly", + alias = "windowsSendinputInsertionOnly" + )] + windows_sendinput_insertion_only: bool, working_languages: Vec, translation_target_language: String, chinese_script_preference: ChineseScriptPreference, @@ -1004,6 +1018,7 @@ impl Default for UserPreferencesWire { 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, + windows_sendinput_insertion_only: prefs.windows_sendinput_insertion_only, working_languages: prefs.working_languages, translation_target_language: prefs.translation_target_language, chinese_script_preference: prefs.chinese_script_preference, @@ -1104,6 +1119,7 @@ impl<'de> Deserialize<'de> for UserPreferences { 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, + windows_sendinput_insertion_only: wire.windows_sendinput_insertion_only, working_languages: wire.working_languages, translation_target_language: wire.translation_target_language, chinese_script_preference: wire.chinese_script_preference, @@ -1847,6 +1863,7 @@ impl Default for UserPreferences { restore_clipboard_after_paste: true, paste_shortcut: PasteShortcut::default(), allow_non_tsf_insertion_fallback: true, + windows_sendinput_insertion_only: false, working_languages: default_working_languages(), translation_target_language: String::new(), chinese_script_preference: ChineseScriptPreference::Auto, @@ -2590,6 +2607,52 @@ mod tests { assert!(prefs.allow_non_tsf_insertion_fallback); } + #[test] + fn windows_sendinput_insertion_only_defaults_to_disabled() { + let prefs = UserPreferences::default(); + assert!(!prefs.windows_sendinput_insertion_only); + + let prefs: UserPreferences = serde_json::from_str("{}").unwrap(); + assert!(!prefs.windows_sendinput_insertion_only); + } + + #[test] + fn windows_sendinput_insertion_only_deserializes_frontend_wire_key() { + let prefs: UserPreferences = + serde_json::from_str(r#"{"windowsSendInputInsertionOnly": true}"#).unwrap(); + assert!(prefs.windows_sendinput_insertion_only); + } + + #[test] + fn windows_sendinput_insertion_only_deserializes_legacy_wrong_camel_key() { + let prefs: UserPreferences = + serde_json::from_str(r#"{"windowsSendinputInsertionOnly": true}"#).unwrap(); + assert!(prefs.windows_sendinput_insertion_only); + } + + #[test] + fn windows_sendinput_insertion_only_serializes_frontend_wire_key() { + let enabled = UserPreferences { + windows_sendinput_insertion_only: true, + ..UserPreferences::default() + }; + let json = serde_json::to_string(&enabled).unwrap(); + assert!(json.contains(r#""windowsSendInputInsertionOnly":true"#)); + assert!(!json.contains("windowsSendinputInsertionOnly")); + } + + #[test] + fn windows_sendinput_insertion_only_pref_round_trips_explicit_true() { + let enabled = UserPreferences { + windows_sendinput_insertion_only: true, + ..UserPreferences::default() + }; + let json = serde_json::to_string(&enabled).unwrap(); + assert!(json.contains(r#""windowsSendInputInsertionOnly":true"#)); + let restored: UserPreferences = serde_json::from_str(&json).unwrap(); + assert!(restored.windows_sendinput_insertion_only); + } + #[test] fn missing_audio_cue_on_record_pref_defaults_to_enabled() { // 老用户的 preferences.json 没有这个字段 → 应默认开启(按下录音即提示)。 diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 6a17fecf..67ac5d8e 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -684,6 +684,8 @@ export const en: typeof zhCN = { comboConflict: 'This shortcut combination is not available', allowNonTsfFallbackLabel: 'Allow non-TSF fallback', allowNonTsfFallbackDesc: 'Windows: when TSF insertion fails, use paced Unicode SendInput; if that still fails, copy the text to the clipboard.', + windowsSendInputOnlyLabel: 'Always use SendInput (no IME switch)', + windowsSendInputOnlyDesc: 'Do not switch to the OpenLess TSF IME during dictation; insert text via Unicode keystroke simulation instead. If insertion fails, the “Allow non-TSF fallback” option below still controls clipboard fallback. Some apps (e.g. Word) may be less reliable than TSF.', historyGroupTitle: 'History & context', historyRetentionLabel: 'History retention (days)', historyRetentionDesc: 'Entries older than this are pruned on new writes; 0 = no time-based pruning.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index f4c25e04..7997c293 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -686,6 +686,8 @@ export const ja: typeof zhCN = { comboConflict: 'このショートカットの組み合わせは使用できません', allowNonTsfFallbackLabel: '非 TSF フォールバックを許可', allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。', + windowsSendInputOnlyLabel: '常に SendInput を使用(IME 切替なし)', + windowsSendInputOnlyDesc: '聴写中に OpenLess TSF IME へ切り替えず、Unicode キー入力シミュレーションで直接挿入します。挿入に失敗した場合は、下の「非 TSF フォールバックを許可」でクリップボードへのコピー可否を制御します。一部のアプリ(Word など)は TSF より不安定な場合があります。', historyGroupTitle: '履歴とコンテキスト', historyRetentionLabel: '履歴保持期間(日)', historyRetentionDesc: '保持日数を超えた履歴は新規書き込み時に削除されます。0 = 時間で削除しない。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 95b74594..012919f8 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -686,6 +686,8 @@ export const ko: typeof zhCN = { comboConflict: '이 단축키 조합은 사용할 수 없습니다', allowNonTsfFallbackLabel: '비 TSF 폴백 허용', allowNonTsfFallbackDesc: 'Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.', + windowsSendInputOnlyLabel: '항상 SendInput 사용(입력기 전환 없음)', + windowsSendInputOnlyDesc: '받아쓰기 중 OpenLess TSF 입력기로 전환하지 않고 Unicode 키 입력 시뮬레이션으로 직접 삽입합니다. 삽입에 실패하면 아래 「비 TSF 폴백 허용」으로 클립보드 복사 여부를 제어합니다. 일부 앱(Word 등)은 TSF보다 불안정할 수 있습니다.', historyGroupTitle: '기록 및 컨텍스트', historyRetentionLabel: '기록 보관 기간(일)', historyRetentionDesc: '보관 기간을 초과한 기록은 새 항목 작성 시 정리됩니다. 0 = 시간 기반 정리 비활성화.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 2d87e8ab..7968593b 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -682,6 +682,8 @@ export const zhCN = { comboConflict: '该快捷键组合不可用', allowNonTsfFallbackLabel: '允许非 TSF 兜底', allowNonTsfFallbackDesc: 'Windows:TSF 失败时使用分批 Unicode SendInput;如果仍失败,再复制到剪贴板。', + windowsSendInputOnlyLabel: '始终使用 SendInput(不切换输入法)', + windowsSendInputOnlyDesc: '听写期间不切换到 OpenLess TSF 输入法,直接用 Unicode 按键模拟插入。若插入失败,仍受下方「允许非 TSF 兜底」控制是否复制到剪贴板。部分应用(如 Word)可能不如 TSF 稳定。', historyGroupTitle: '历史与上下文', historyRetentionLabel: '历史保留天数', historyRetentionDesc: '超过保留天数的历史在写入新条目时被清理;0 = 不按时间清理。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 4390c7d4..872b155d 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -684,6 +684,8 @@ export const zhTW: typeof zhCN = { pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)', allowNonTsfFallbackLabel: '允許非 TSF 兜底', allowNonTsfFallbackDesc: 'Windows:TSF 失敗時使用分批 Unicode SendInput;如果仍失敗,再複製到剪貼簿。', + windowsSendInputOnlyLabel: '始終使用 SendInput(不切換輸入法)', + windowsSendInputOnlyDesc: '聽寫期間不切換到 OpenLess TSF 輸入法,直接用 Unicode 按鍵模擬插入。若插入失敗,仍受下方「允許非 TSF 兜底」控制是否複製到剪貼簿。部分應用(如 Word)可能不如 TSF 穩定。', historyGroupTitle: '歷史與上下文', historyRetentionLabel: '歷史保留天數', historyRetentionDesc: '超過保留天數的歷史在寫入新條目時被清理;0 = 不按時間清理。', diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 402f8cc8..e5140168 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -49,6 +49,7 @@ export let mockSettings: UserPreferences = { restoreClipboardAfterPaste: true, pasteShortcut: "ctrlV", allowNonTsfInsertionFallback: true, + windowsSendInputInsertionOnly: false, workingLanguages: ["简体中文"], translationTargetLanguage: "", qaHotkey: defaultQaShortcut(), diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index d049a4a5..7ccb1a60 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -39,6 +39,7 @@ const previousPrefs: UserPreferences = { restoreClipboardAfterPaste: true, pasteShortcut: 'ctrlV', allowNonTsfInsertionFallback: true, + windowsSendInputInsertionOnly: false, workingLanguages: ['简体中文'], translationTargetLanguage: '', chineseScriptPreference: 'auto', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 17b52bd7..cc304f4f 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -268,6 +268,8 @@ export interface UserPreferences { pasteShortcut: PasteShortcut; /** Windows:TSF 失败后是否允许快捷键粘贴 / 剪贴板兜底。仅在剪贴板写失败时才再试 SendInput。关闭后可验证是否真实 TSF 上屏。 */ allowNonTsfInsertionFallback: boolean; + /** Windows:始终用 SendInput Unicode 插入,听写期间不切换 OpenLess TSF 输入法。 */ + windowsSendInputInsertionOnly: boolean; /** 用户的工作语言(多选,原生名);作为前提注入 LLM polish/translate prompt 头部。 */ workingLanguages: string[]; /** 翻译模式目标语言(单选,原生名);空串 = 不启用 Shift 翻译。详见 issue #4。 */ diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index c66530a9..fefc7cec 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -131,6 +131,8 @@ export function RecordingInputSection() { savePrefs({ ...prefs, pasteShortcut }); const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) => savePrefs({ ...prefs, allowNonTsfInsertionFallback }); + const onWindowsSendInputOnlyChange = (windowsSendInputInsertionOnly: boolean) => + savePrefs({ ...prefs, windowsSendInputInsertionOnly }); const onStartMinimizedChange = (startMinimized: boolean) => savePrefs({ ...prefs, startMinimized }); const onAutoUpdateCheckChange = (autoUpdateCheck: boolean) => @@ -290,6 +292,17 @@ export function RecordingInputSection() { /> )} + {capability.adapter === 'windowsLowLevel' && ( + + + + )} {capability.adapter === 'windowsLowLevel' && (