diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 22f3c55b3..3fa076789 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -599,11 +599,14 @@ struct Inner { /// 预览确认模式暂存的结果和原选区目标;仅在用户确认时才允许插入。 #[cfg(not(mobile))] selection_polish_preview: Mutex>, - /// 翻译模式触发标志。每次 begin_session 重置为 false;hotkey 监听器在 - /// Listening / Starting 阶段看到 Shift down 边沿时 set true。 - /// end_session 在调 polish/translate 前读这个 flag + translation_target_language - /// 决定走哪条管线。详见 issue #4。 - translation_modifier_seen: AtomicBool, + /// 「本次会话真的要翻译」。每次 begin_session 重置为 false;hotkey 监听器在 + /// Listening / Starting 阶段看到 Shift down 边沿(或安卓浮层请求)时,经 + /// `arm_translation_if_effective` 判定翻译确实会生效(设了目标语言、且不等于唯一工作语言) + /// 后才 set true。 + /// + /// 判定收在写入侧:读取侧之一是音频回调线程上的 emit_capsule,不能碰偏好锁。 + /// 胶囊提示与 end_session 的 polish 分派因此读到同一个真值。详见 issue #4。 + translation_active: AtomicBool, /// 划词语音问答(issue #118):与 dictation hotkey 平行的全局快捷键 /// 监听器(global-hotkey crate)。`None` 表示功能关闭或还没成功安装。 qa_hotkey: Mutex>, @@ -826,7 +829,7 @@ impl Coordinator { selection_polish_hotkey: Mutex::new(None), #[cfg(not(mobile))] selection_polish_preview: Mutex::new(None), - translation_modifier_seen: AtomicBool::new(false), + translation_active: AtomicBool::new(false), qa_hotkey: Mutex::new(None), coding_agent_modifier_hotkey: Mutex::new(None), coding_agent_combo_hotkey: Mutex::new(None), @@ -944,7 +947,7 @@ impl Coordinator { selection_polish_hotkey: Mutex::new(None), #[cfg(not(mobile))] selection_polish_preview: Mutex::new(None), - translation_modifier_seen: AtomicBool::new(false), + translation_active: AtomicBool::new(false), qa_hotkey: Mutex::new(None), coding_agent_modifier_hotkey: Mutex::new(None), coding_agent_combo_hotkey: Mutex::new(None), @@ -1724,10 +1727,10 @@ impl Coordinator { pub async fn start_dictation_with_translation(&self) -> Result<(), String> { begin_session(&self.inner).await?; - self.inner - .translation_modifier_seen - .store(true, Ordering::SeqCst); - log::info!("[coord] android overlay translation dictation started"); + // 与桌面 Shift 走同一个 gate:目标语言没设 / 与唯一工作语言相同时不置位, + // 避免安卓浮层也出现「提示在翻译、实际没翻」。 + let translation_armed = arm_translation_if_effective(&self.inner); + log::info!("[coord] android overlay dictation started (translation={translation_armed})"); Ok(()) } @@ -1741,7 +1744,7 @@ impl Coordinator { pub async fn stop_dictation_with_translation(&self, translation: bool) -> Result<(), String> { if translation { - mark_translation_modifier_seen(&self.inner); + arm_translation_if_effective(&self.inner); } self.stop_dictation().await } diff --git a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs index ce49b36c0..e9d1e30b2 100644 --- a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs +++ b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs @@ -517,7 +517,7 @@ fn emit_capsule_with_context_locked( return event_epoch; }; // 选区润色不属于语音翻译 / Less Computer,会话之间残留的标志不能带进其提示。 - let translation = !selection_polish && inner.translation_modifier_seen.load(Ordering::SeqCst); + let translation = !selection_polish && inner.translation_active.load(Ordering::SeqCst); let operating = !selection_polish && inner.state.lock().voice_agent; // 预备态只对 Recording 有意义:麦克风还没吐第一帧 PCM 时(capsule_warming=true)把 // warming 打成 true,前端渲染「待命」光效;level_handler 首触发后翻 false → 光条点亮。 diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 0e607e056..f37aede90 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1613,10 +1613,8 @@ pub(super) async fn begin_session_as( store_prepared_windows_ime_session(&mut slots, current_session_id, prepared); } } - // 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。 - inner - .translation_modifier_seen - .store(false, Ordering::SeqCst); + // 翻译生效标志重置;修饰键按下或安卓浮层请求时经 arm_translation_if_effective 置位。 + inner.translation_active.store(false, Ordering::SeqCst); #[cfg(any(debug_assertions, test))] if hotkey_injection_dry_run_enabled() { @@ -3601,8 +3599,11 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { ); let raw_uses_llm = mode == PolishMode::Raw && super::raw_style_pack_uses_llm(&pack); let translation_target = prefs.translation_target_language.trim().to_string(); - let translation_active = - inner.translation_modifier_seen.load(Ordering::SeqCst) && !translation_target.is_empty(); + let translation_active = crate::types::translation_effective( + inner.translation_active.load(Ordering::SeqCst), + &translation_target, + &working_languages, + ); log::info!( "[style-pack] runtime dispatch scope=asr session_id={} active_pack={} kind={:?} mode={:?} raw_chars={} prompt_chars={} raw_uses_llm={} translation_active={} hotwords={} working_languages={:?}", current_session_id, diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index a071e5366..ffa24b829 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -994,7 +994,7 @@ pub(super) fn translation_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiv continue; } if matches!(evt, ComboHotkeyEvent::Pressed { .. }) { - mark_translation_modifier_seen(&inner); + arm_translation_if_effective(&inner); } } } @@ -1286,14 +1286,38 @@ pub(super) fn modifier_shortcut_triggers( (qa_trigger, selection_polish_trigger, translation_trigger) } -pub(super) fn mark_translation_modifier_seen(inner: &Arc) { +/// 在这里、而不是在读取侧判定「翻译是否真的会发生」:本函数在桥接线程(翻译热键事件 / +/// 主热键循环)和安卓 overlay 命令路径上调用,均非音频回调线程,读一次 prefs 无妨; +/// 而 `translation_active` 的读取侧之一是 emit_capsule —— 它在音频回调线程按帧执行, +/// 不能碰偏好锁(见 capsule_focus.rs 注释)。 +/// +/// 收紧后这个 flag 的语义从「按过 Shift」变成「本次会话真的要翻译」,胶囊提示与 polish +/// 分派读同一个值,不会再出现「胶囊说正在翻译、后端其实没翻」的漂移(用户未设目标语言 +/// 时按 Shift 就会撞上)。返回 true 表示本次会话翻译已置位。 +pub(super) fn arm_translation_if_effective(inner: &Arc) -> bool { let phase = inner.state.lock().phase; - if matches!(phase, SessionPhase::Starting | SessionPhase::Listening) { - inner - .translation_modifier_seen - .store(true, Ordering::SeqCst); - log::info!("[coord] translation modifier seen during {phase:?}"); + if !matches!(phase, SessionPhase::Starting | SessionPhase::Listening) { + return false; + } + let prefs = inner.prefs.get(); + if !crate::types::translation_effective( + true, + &prefs.translation_target_language, + &prefs.working_languages, + ) { + // 明确记录「按了但不翻」的原因,否则用户只能看到胶囊不提示、无从判断是没生效 + // 还是没按到。 + log::info!( + "[coord] translation requested during {phase:?} but translation is a no-op \ + (target={:?} working={:?}); staying in plain polish", + prefs.translation_target_language, + prefs.working_languages + ); + return false; } + inner.translation_active.store(true, Ordering::SeqCst); + log::info!("[coord] translation active during {phase:?}"); + true } pub(super) fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { @@ -1332,7 +1356,7 @@ pub(super) fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver { diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 7dad6dbe9..f27693f57 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -461,6 +461,33 @@ impl Default for StylePack { } } +/// 本次会话是否真的会走翻译管线。**唯一判定入口**——写入侧(arm_translation_if_effective) +/// 与 end_session 的 polish 分派都经它判定,否则两边会漂移(此前胶囊只看 +/// `modifier_seen`,用户没设目标语言按下 Shift 也会看到「正在翻译」,而后端根本没翻)。 +/// 胶囊本身只读经它置位的原子标志,不在音频回调线程触碰偏好锁。 +/// +/// 三个条件: +/// 1. 会话期间按下过翻译修饰键; +/// 2. 设了翻译目标语言(空串 = 功能未启用); +/// 3. 目标语言不等于用户「唯一的」工作语言——此时源语言必定就是目标语言,翻译是可证 +/// 的空操作,白花一次 LLM 往返。工作语言有多个时不拦:中/英双语用户把目标设成英文 +/// 是正常用法(说中文出英文)。简体/繁体是列表里的两个独立条目,按字面比较即可, +/// 简→繁仍会照常翻译。 +pub fn translation_effective( + modifier_seen: bool, + translation_target_language: &str, + working_languages: &[String], +) -> bool { + if !modifier_seen { + return false; + } + let target = translation_target_language.trim(); + if target.is_empty() { + return false; + } + !(working_languages.len() == 1 && working_languages[0].trim() == target) +} + pub const BUILTIN_STYLE_PACK_RAW_ID: &str = "builtin.raw"; pub const BUILTIN_STYLE_PACK_LIGHT_ID: &str = "builtin.light"; pub const BUILTIN_STYLE_PACK_STRUCTURED_ID: &str = "builtin.structured"; @@ -2976,6 +3003,74 @@ pub struct QaChatMessage { pub selection_text: Option, } +#[cfg(test)] +mod translation_effective_tests { + use super::translation_effective; + + fn langs(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn requires_the_modifier() { + assert!(!translation_effective( + false, + "English", + &langs(&["简体中文"]) + )); + } + + #[test] + fn unset_target_language_is_not_translation() { + // 用户没在翻译页选目标语言就按 Shift:此前胶囊照样显示「正在翻译」, + // 而后端走的是普通润色。 + assert!(!translation_effective(true, "", &langs(&["简体中文"]))); + assert!(!translation_effective(true, " ", &langs(&["简体中文"]))); + } + + #[test] + fn target_equal_to_the_only_working_language_is_a_no_op() { + // 工作语言只有中文、目标也是中文 —— 源语言必定就是目标语言,翻译是空操作。 + assert!(!translation_effective( + true, + "简体中文", + &langs(&["简体中文"]) + )); + // 前后空白不该让它逃过判定。 + assert!(!translation_effective( + true, + " 简体中文 ", + &langs(&["简体中文"]) + )); + } + + #[test] + fn simplified_to_traditional_still_translates() { + // 简体/繁体是语言列表里两个独立条目,简→繁是真实转换,不能按「同一种中文」拦掉。 + assert!(translation_effective( + true, + "繁体中文", + &langs(&["简体中文"]) + )); + } + + #[test] + fn multiple_working_languages_are_never_blocked() { + // 中/英双语用户把目标设成英文是正常用法(说中文出英文),源语言无法预先判定, + // 不能因为目标语言出现在工作语言里就拦。 + assert!(translation_effective( + true, + "English", + &langs(&["简体中文", "English"]) + )); + } + + #[test] + fn empty_working_languages_still_translates() { + assert!(translation_effective(true, "English", &[])); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index a21c84899..44a308f3a 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -572,6 +572,7 @@ export const en: typeof zhCN = { title: 'Translation target language', desc: 'Press Shift during recording to trigger translation. "Disabled" makes Shift a no-op.', disabled: 'Disabled (Shift does nothing)', + sameAsWorking: 'The target matches your only working language, so translation cannot take effect — Shift will just run a normal polish. Pick a different target, or add another working language above.', }, save: { workingFailed: 'Failed to save working languages. Please try again.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index d990bdef2..a6cfdf845 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -574,6 +574,7 @@ export const ja: typeof zhCN = { title: '翻訳ターゲット言語', desc: '録音中に Shift で翻訳を起動。「無効」で Shift 無効化。', disabled: '無効(Shift で翻訳を発動しない)', + sameAsWorking: 'ターゲット言語が唯一の作業言語と同じため、翻訳は発動しません(Shift を押しても通常の整文になります)。別のターゲットを選ぶか、上で作業言語を追加してください。', }, save: { workingFailed: '作業言語の保存に失敗しました。もう一度お試しください。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index d761444e7..cc6dad6bf 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -574,6 +574,7 @@ export const ko: typeof zhCN = { title: '번역 대상 언어', desc: '녹음 중 Shift 로 번역 실행. "비활성화" 시 Shift 무효.', disabled: '비활성화 (Shift 로 번역 발동 안 함)', + sameAsWorking: '대상 언어가 유일한 작업 언어와 같아 번역이 실행되지 않습니다. Shift 를 눌러도 일반 정리로 처리됩니다. 다른 대상 언어를 고르거나 위에서 작업 언어를 추가하세요.', }, save: { workingFailed: '작업 언어 저장에 실패했습니다. 다시 시도하세요.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index b4515d57a..37121aa04 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -570,6 +570,7 @@ export const zhCN = { title: '翻译目标语言', desc: '录音时按 Shift 触发翻译。选「不启用」则 Shift 无效。', disabled: '不启用(Shift 按下不触发翻译)', + sameAsWorking: '目标语言与你唯一的工作语言相同,翻译不会生效:按 Shift 仍按普通润色处理。换一个目标语言,或在上方多勾选一个工作语言。', }, save: { workingFailed: '工作语言保存失败,请重试。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 490c9a9ce..784bc65a5 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -572,6 +572,7 @@ export const zhTW: typeof zhCN = { title: '翻譯目標語言', desc: '錄音時按 Shift 觸發翻譯。選「不啟用」則 Shift 無效。', disabled: '不啓用(Shift 按下不觸發翻譯)', + sameAsWorking: '目標語言與你唯一的工作語言相同,翻譯不會生效:按 Shift 仍按普通潤色處理。換一個目標語言,或在上方多勾選一個工作語言。', }, save: { workingFailed: '工作語言保存失敗,請重試。', diff --git a/openless-all/app/src/lib/translationTarget.test.ts b/openless-all/app/src/lib/translationTarget.test.ts new file mode 100644 index 000000000..66b335a55 --- /dev/null +++ b/openless-all/app/src/lib/translationTarget.test.ts @@ -0,0 +1,42 @@ +import { isTranslationEnabled, isTranslationTargetRedundant } from './translationTarget'; + +function assert(condition: boolean, message: string) { + if (!condition) throw new Error(message); +} + +// 未选目标语言 = 功能未启用。 +assert(isTranslationEnabled('') === false, 'empty target should read as disabled'); +assert(isTranslationEnabled(' ') === false, 'blank target should read as disabled'); +assert(isTranslationEnabled('English') === true, 'a chosen target should read as enabled'); + +// 目标 = 唯一工作语言:翻译是空操作,页面必须提示。 +assert( + isTranslationTargetRedundant('简体中文', ['简体中文']) === true, + 'target equal to the only working language should be flagged redundant', +); +assert( + isTranslationTargetRedundant(' 简体中文 ', ['简体中文']) === true, + 'surrounding whitespace should not hide a redundant target', +); + +// 简→繁是真实转换,不能误判成空操作。 +assert( + isTranslationTargetRedundant('繁体中文', ['简体中文']) === false, + 'simplified to traditional is a real conversion', +); + +// 多工作语言不拦:说中文出英文是正常用法。 +assert( + isTranslationTargetRedundant('English', ['简体中文', 'English']) === false, + 'multiple working languages should never be flagged', +); + +// 没选目标语言时走「未启用」提示,不该同时报「冗余」。 +assert( + isTranslationTargetRedundant('', ['简体中文']) === false, + 'an unset target is disabled, not redundant', +); +assert( + isTranslationTargetRedundant('English', []) === false, + 'no working languages means nothing to compare against', +); diff --git a/openless-all/app/src/lib/translationTarget.ts b/openless-all/app/src/lib/translationTarget.ts new file mode 100644 index 000000000..1e167c73c --- /dev/null +++ b/openless-all/app/src/lib/translationTarget.ts @@ -0,0 +1,24 @@ +// 翻译目标语言的可用性判定,与后端 `types.rs::translation_effective` 保持同一套规则。 +// 后端在按下翻译修饰键时用它决定是否进入翻译管线;这里只负责在翻译页提前把「设了但 +// 不会生效」的组合告诉用户,避免出现「按了 Shift 却什么也没翻」的沉默失败。 + +/** 未选择目标语言 = 翻译功能未启用(Shift 无效)。 */ +export function isTranslationEnabled(translationTargetLanguage: string): boolean { + return translationTargetLanguage.trim() !== ''; +} + +/** + * 目标语言与用户「唯一的」工作语言相同 —— 源语言必定就是目标语言,翻译是可证的空操作。 + * + * 工作语言有多个时返回 false:中/英双语用户把目标设成英文是正常用法(说中文出英文), + * 源语言无法预先判定,不能拦。简体/繁体是语言列表里两个独立条目,按字面比较即可, + * 简→繁不会被误判成空操作。 + */ +export function isTranslationTargetRedundant( + translationTargetLanguage: string, + workingLanguages: readonly string[], +): boolean { + const target = translationTargetLanguage.trim(); + if (target === '') return false; + return workingLanguages.length === 1 && workingLanguages[0].trim() === target; +} diff --git a/openless-all/app/src/pages/Translation.tsx b/openless-all/app/src/pages/Translation.tsx index cb54afa7c..f38cb8250 100644 --- a/openless-all/app/src/pages/Translation.tsx +++ b/openless-all/app/src/pages/Translation.tsx @@ -10,6 +10,7 @@ import { Card, PageHeader } from './_atoms'; import { SavedToast } from '../components/SavedToast'; import { SelectLite } from '../components/ui/SelectLite'; import { SUPPORTED_LANGUAGES } from '../lib/types'; +import { isTranslationEnabled, isTranslationTargetRedundant } from '../lib/translationTarget'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { formatComboLabel } from '../lib/hotkey'; import type { UserPreferences } from '../lib/types'; @@ -119,7 +120,13 @@ export function Translation() { const triggerLabel = formatComboLabel(prefs.dictationHotkey); const translationHotkeyLabel = formatComboLabel(prefs.translationHotkey); - const enabled = prefs.translationTargetLanguage.trim() !== ''; + // 「已启用」= 选了目标语言 **且** 该目标真的会触发翻译。目标等于唯一工作语言时后端 + // 走普通润色,状态灯不能还亮着说已启用(否则用户按 Shift 什么都没发生,无从排查)。 + const redundantTarget = isTranslationTargetRedundant( + prefs.translationTargetLanguage, + prefs.workingLanguages, + ); + const enabled = isTranslationEnabled(prefs.translationTargetLanguage) && !redundantTarget; const targetOptions = useMemo(() => ([ { value: '', label: t('translation.target.disabled') }, @@ -230,6 +237,23 @@ export function Translation() { ariaLabel={t('translation.target.title')} style={{ width: '100%', maxWidth: 360, fontSize: 13 }} /> + {redundantTarget && ( +
+ {t('translation.target.sameAsWorking')} +
+ )} {/* 3. 使用方法 */}