From be1d1e6c99aa151d6ba2508d8b6759794b25d3af Mon Sep 17 00:00:00 2001 From: jisongniu <529058747@qq.com> Date: Tue, 4 Aug 2026 12:13:30 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(translation):=20=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E4=B8=8D=E4=BC=9A=E7=94=9F=E6=95=88=E6=97=B6=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=BF=9B=E5=85=A5=E7=BF=BB=E8=AF=91=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个症状同源:`translation_modifier_seen` 的语义只是「按过 Shift」, 而「是否真的翻译」的判定散落在读取侧,胶囊那一侧漏了目标语言检查。 - 没在翻译页选目标语言,录音中按 Shift → 光标处显示「正在翻译」, 但 end_session 走的是普通润色,什么也没翻。 - 目标语言等于用户唯一的工作语言(例:工作语言只勾了简体中文、目标也选 简体中文)→ 源语言必定就是目标语言,仍会照常发起一次 LLM 翻译往返, 纯浪费时延和 token。 新增 `types::translation_effective` 作为唯一判定入口,并把判定收到写入侧 (`mark_translation_modifier_seen`):该函数每次按键才跑一次,读一次 prefs 无所谓;而读取侧之一是音频回调线程上的 emit_capsule,按帧执行, 不能碰偏好锁(capsule_focus.rs 既有注释已就此立过规矩)。收紧后 flag 的 语义变成「本次会话真的要翻译」,胶囊提示与 polish 分派读同一个真值。 工作语言有多个时不拦:中/英双语用户把目标设成英文是正常用法(说中文出 英文),源语言无法预先判定。简体/繁体是语言列表里两个独立条目,简→繁 仍照常翻译。安卓浮层的 start_dictation_with_translation 一并走同一个 gate。 「按了但不翻」的情况会记一条 INFO 说明原因,否则用户只看到胶囊没提示, 无从判断是没生效还是没按到。 Co-Authored-By: Claude Opus 5 --- openless-all/app/src-tauri/src/coordinator.rs | 16 ++-- .../src-tauri/src/coordinator/dictation.rs | 7 +- .../src-tauri/src/coordinator/hotkey_loops.rs | 34 ++++++- openless-all/app/src-tauri/src/types.rs | 94 +++++++++++++++++++ 4 files changed, 137 insertions(+), 14 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 22f3c55b3..3f9094725 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -599,10 +599,12 @@ 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。 + /// 「本次会话真的要翻译」。每次 begin_session 重置为 false;hotkey 监听器在 + /// Listening / Starting 阶段看到 Shift down 边沿时,经 `mark_translation_modifier_seen` + /// 判定翻译确实会生效(设了目标语言、且不等于唯一工作语言)后才 set true。 + /// + /// 判定收在写入侧:读取侧之一是音频回调线程上的 emit_capsule,不能碰偏好锁。 + /// 胶囊提示与 end_session 的 polish 分派因此读到同一个真值。详见 issue #4。 translation_modifier_seen: AtomicBool, /// 划词语音问答(issue #118):与 dictation hotkey 平行的全局快捷键 /// 监听器(global-hotkey crate)。`None` 表示功能关闭或还没成功安装。 @@ -1724,9 +1726,9 @@ 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); + // 与桌面 Shift 走同一个 gate:目标语言没设 / 与唯一工作语言相同时不置位, + // 避免安卓浮层也出现「提示在翻译、实际没翻」。 + mark_translation_modifier_seen(&self.inner); log::info!("[coord] android overlay translation dictation started"); Ok(()) } diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 0e607e056..4f24456a9 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -3601,8 +3601,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_modifier_seen.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..9a92bd81e 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -1286,14 +1286,38 @@ pub(super) fn modifier_shortcut_triggers( (qa_trigger, selection_polish_trigger, translation_trigger) } +/// 在这里、而不是在读取侧判定「翻译是否真的会发生」:本函数每次按下翻译修饰键才跑一次 +/// (bridge 线程),读一次 prefs 无所谓;而 `translation_modifier_seen` 的读取侧之一是 +/// emit_capsule —— 它在音频回调线程按帧执行,不能碰偏好锁(见 capsule_focus.rs 注释)。 +/// +/// 收紧后这个 flag 的语义从「按过 Shift」变成「本次会话真的要翻译」,胶囊提示与 polish +/// 分派读同一个值,不会再出现「胶囊说正在翻译、后端其实没翻」的漂移(用户未设目标语言 +/// 时按 Shift 就会撞上)。 pub(super) fn mark_translation_modifier_seen(inner: &Arc) { let phase = inner.state.lock().phase; - if matches!(phase, SessionPhase::Starting | SessionPhase::Listening) { - inner - .translation_modifier_seen - .store(true, Ordering::SeqCst); - log::info!("[coord] translation modifier seen during {phase:?}"); + if !matches!(phase, SessionPhase::Starting | SessionPhase::Listening) { + return; + } + let prefs = inner.prefs.get(); + if !crate::types::translation_effective( + true, + &prefs.translation_target_language, + &prefs.working_languages, + ) { + // 明确记录「按了但不翻」的原因,否则用户只能看到胶囊不提示、无从判断是没生效 + // 还是没按到。 + log::info!( + "[coord] translation modifier seen during {phase:?} but translation is a no-op \ + (target={:?} working={:?}); staying in plain polish", + prefs.translation_target_language, + prefs.working_languages + ); + return; } + inner + .translation_modifier_seen + .store(true, Ordering::SeqCst); + log::info!("[coord] translation modifier seen during {phase:?}"); } 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..d425e29b1 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -461,6 +461,32 @@ impl Default for StylePack { } } +/// 本次会话是否真的会走翻译管线。**唯一判定入口**——胶囊提示与 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; + } + !matches!(working_languages, [only] if only.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 +3002,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::*; From fd6909b5dac85637ace55d8d999dc1de60296bab Mon Sep 17 00:00:00 2001 From: jisongniu <529058747@qq.com> Date: Tue, 4 Aug 2026 12:13:40 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(translation):=20=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E4=B8=8E=E5=94=AF=E4=B8=80=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E7=9B=B8=E5=90=8C=E6=97=B6=E5=9C=A8=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E9=A1=B5=E7=BB=99=E5=87=BA=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端已经不会在这种组合下发起翻译(上一个 commit),但用户在设置页看不出 任何异常:状态灯仍写「已启用」,按 Shift 却什么也不会发生,只能靠翻日志 才知道为什么。 - 翻译目标语言卡片下方出现一条警示:说明这个组合不会生效,并给出两条 出路(换目标语言,或多勾一个工作语言)。 - 「已启用 / 未启用」状态灯改为同时看目标语言和这个冗余判定,不再谎报。 判定逻辑抽成 `lib/translationTarget.ts`,与后端 `translation_effective` 同一套规则(多工作语言不拦、简→繁不误判),两侧各带单测钉住契约。 Co-Authored-By: Claude Opus 5 --- 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 + .../app/src/lib/translationTarget.test.ts | 42 +++++++++++++++++++ openless-all/app/src/lib/translationTarget.ts | 24 +++++++++++ openless-all/app/src/pages/Translation.tsx | 26 +++++++++++- 8 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 openless-all/app/src/lib/translationTarget.test.ts create mode 100644 openless-all/app/src/lib/translationTarget.ts 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. 使用方法 */} From 641ea3b4ba6896e2636d0393646f1bfe3f6b8748 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Wed, 5 Aug 2026 22:43:11 +0800 Subject: [PATCH 3/4] =?UTF-8?q?refactor(translation):=20translation=5Fmodi?= =?UTF-8?q?fier=5Fseen=20=E6=9B=B4=E5=90=8D=20translation=5Factive=20+=20?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=AE=89=E5=8D=93=20overlay=20=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openless-all/app/src-tauri/src/coordinator.rs | 17 +++++------ .../src/coordinator/capsule_focus.rs | 2 +- .../src-tauri/src/coordinator/dictation.rs | 8 ++---- .../src-tauri/src/coordinator/hotkey_loops.rs | 28 +++++++++---------- openless-all/app/src-tauri/src/types.rs | 7 +++-- 5 files changed, 31 insertions(+), 31 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 3f9094725..f3bb4d72b 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -600,12 +600,13 @@ struct Inner { #[cfg(not(mobile))] selection_polish_preview: Mutex>, /// 「本次会话真的要翻译」。每次 begin_session 重置为 false;hotkey 监听器在 - /// Listening / Starting 阶段看到 Shift down 边沿时,经 `mark_translation_modifier_seen` - /// 判定翻译确实会生效(设了目标语言、且不等于唯一工作语言)后才 set true。 + /// Listening / Starting 阶段看到 Shift down 边沿(或安卓浮层请求)时,经 + /// `mark_translation_active` 判定翻译确实会生效(设了目标语言、且不等于唯一工作语言) + /// 后才 set true。 /// /// 判定收在写入侧:读取侧之一是音频回调线程上的 emit_capsule,不能碰偏好锁。 /// 胶囊提示与 end_session 的 polish 分派因此读到同一个真值。详见 issue #4。 - translation_modifier_seen: AtomicBool, + translation_active: AtomicBool, /// 划词语音问答(issue #118):与 dictation hotkey 平行的全局快捷键 /// 监听器(global-hotkey crate)。`None` 表示功能关闭或还没成功安装。 qa_hotkey: Mutex>, @@ -828,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), @@ -946,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), @@ -1728,8 +1729,8 @@ impl Coordinator { begin_session(&self.inner).await?; // 与桌面 Shift 走同一个 gate:目标语言没设 / 与唯一工作语言相同时不置位, // 避免安卓浮层也出现「提示在翻译、实际没翻」。 - mark_translation_modifier_seen(&self.inner); - log::info!("[coord] android overlay translation dictation started"); + let translation_armed = mark_translation_active(&self.inner); + log::info!("[coord] android overlay dictation started (translation={translation_armed})"); Ok(()) } @@ -1743,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); + mark_translation_active(&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 4f24456a9..1a4626b09 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); + // 翻译生效标志重置;修饰键按下或安卓浮层请求时经 mark_translation_active 置位。 + inner.translation_active.store(false, Ordering::SeqCst); #[cfg(any(debug_assertions, test))] if hotkey_injection_dry_run_enabled() { @@ -3602,7 +3600,7 @@ 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 = crate::types::translation_effective( - inner.translation_modifier_seen.load(Ordering::SeqCst), + inner.translation_active.load(Ordering::SeqCst), &translation_target, &working_languages, ); 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 9a92bd81e..9bc4cf688 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); + mark_translation_active(&inner); } } } @@ -1286,17 +1286,18 @@ pub(super) fn modifier_shortcut_triggers( (qa_trigger, selection_polish_trigger, translation_trigger) } -/// 在这里、而不是在读取侧判定「翻译是否真的会发生」:本函数每次按下翻译修饰键才跑一次 -/// (bridge 线程),读一次 prefs 无所谓;而 `translation_modifier_seen` 的读取侧之一是 -/// emit_capsule —— 它在音频回调线程按帧执行,不能碰偏好锁(见 capsule_focus.rs 注释)。 +/// 在这里、而不是在读取侧判定「翻译是否真的会发生」:本函数在桥接线程(翻译热键事件 / +/// 主热键循环)和安卓 overlay 命令路径上调用,均非音频回调线程,读一次 prefs 无妨; +/// 而 `translation_active` 的读取侧之一是 emit_capsule —— 它在音频回调线程按帧执行, +/// 不能碰偏好锁(见 capsule_focus.rs 注释)。 /// /// 收紧后这个 flag 的语义从「按过 Shift」变成「本次会话真的要翻译」,胶囊提示与 polish /// 分派读同一个值,不会再出现「胶囊说正在翻译、后端其实没翻」的漂移(用户未设目标语言 -/// 时按 Shift 就会撞上)。 -pub(super) fn mark_translation_modifier_seen(inner: &Arc) { +/// 时按 Shift 就会撞上)。返回 true 表示本次会话翻译已置位。 +pub(super) fn mark_translation_active(inner: &Arc) -> bool { let phase = inner.state.lock().phase; if !matches!(phase, SessionPhase::Starting | SessionPhase::Listening) { - return; + return false; } let prefs = inner.prefs.get(); if !crate::types::translation_effective( @@ -1307,17 +1308,16 @@ pub(super) fn mark_translation_modifier_seen(inner: &Arc) { // 明确记录「按了但不翻」的原因,否则用户只能看到胶囊不提示、无从判断是没生效 // 还是没按到。 log::info!( - "[coord] translation modifier seen during {phase:?} but translation is a no-op \ + "[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; + return false; } - inner - .translation_modifier_seen - .store(true, Ordering::SeqCst); - log::info!("[coord] translation modifier seen during {phase:?}"); + 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) { @@ -1356,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 d425e29b1..6cd480ef6 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -461,9 +461,10 @@ impl Default for StylePack { } } -/// 本次会话是否真的会走翻译管线。**唯一判定入口**——胶囊提示与 polish 分派都必须调它, -/// 否则两边会漂移(此前胶囊只看 `modifier_seen`,用户没设目标语言按下 Shift 也会看到 -/// 「正在翻译」,而后端根本没翻)。 +/// 本次会话是否真的会走翻译管线。**唯一判定入口**——写入侧(mark_translation_active) +/// 与 end_session 的 polish 分派都经它判定,否则两边会漂移(此前胶囊只看 +/// `modifier_seen`,用户没设目标语言按下 Shift 也会看到「正在翻译」,而后端根本没翻)。 +/// 胶囊本身只读经它置位的原子标志,不在音频回调线程触碰偏好锁。 /// /// 三个条件: /// 1. 会话期间按下过翻译修饰键; From ceee9cffa11b54def212bd3c122b9195a770f556 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Wed, 5 Aug 2026 23:20:17 +0800 Subject: [PATCH 4/4] =?UTF-8?q?refactor(translation):=20=E6=9B=B4=E5=90=8D?= =?UTF-8?q?=20arm=5Ftranslation=5Fif=5Feffective=20=E5=B9=B6=E7=AE=80?= =?UTF-8?q?=E5=8C=96=E5=86=97=E4=BD=99=E5=88=A4=E5=AE=9A=E8=A1=A8=E8=BE=BE?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查跟进(PR #908):mark_translation_active 的实际行为是「只在翻译会 生效时置位并返回是否置位」,名字却像无条件 setter,改为 arm_translation_if_effective 更诚实;translation_effective 里的 matches!([only] if ...) 改写为显式 len==1 + trim 相等(语义完全等价)。 行为零变化:translation_effective 6 个单测全过(backend-tests), cargo check 全绿。 --- openless-all/app/src-tauri/src/coordinator.rs | 6 +++--- openless-all/app/src-tauri/src/coordinator/dictation.rs | 2 +- openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs | 6 +++--- openless-all/app/src-tauri/src/types.rs | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index f3bb4d72b..3fa076789 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -601,7 +601,7 @@ struct Inner { selection_polish_preview: Mutex>, /// 「本次会话真的要翻译」。每次 begin_session 重置为 false;hotkey 监听器在 /// Listening / Starting 阶段看到 Shift down 边沿(或安卓浮层请求)时,经 - /// `mark_translation_active` 判定翻译确实会生效(设了目标语言、且不等于唯一工作语言) + /// `arm_translation_if_effective` 判定翻译确实会生效(设了目标语言、且不等于唯一工作语言) /// 后才 set true。 /// /// 判定收在写入侧:读取侧之一是音频回调线程上的 emit_capsule,不能碰偏好锁。 @@ -1729,7 +1729,7 @@ impl Coordinator { begin_session(&self.inner).await?; // 与桌面 Shift 走同一个 gate:目标语言没设 / 与唯一工作语言相同时不置位, // 避免安卓浮层也出现「提示在翻译、实际没翻」。 - let translation_armed = mark_translation_active(&self.inner); + let translation_armed = arm_translation_if_effective(&self.inner); log::info!("[coord] android overlay dictation started (translation={translation_armed})"); Ok(()) } @@ -1744,7 +1744,7 @@ impl Coordinator { pub async fn stop_dictation_with_translation(&self, translation: bool) -> Result<(), String> { if translation { - mark_translation_active(&self.inner); + arm_translation_if_effective(&self.inner); } self.stop_dictation().await } diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 1a4626b09..f37aede90 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1613,7 +1613,7 @@ pub(super) async fn begin_session_as( store_prepared_windows_ime_session(&mut slots, current_session_id, prepared); } } - // 翻译生效标志重置;修饰键按下或安卓浮层请求时经 mark_translation_active 置位。 + // 翻译生效标志重置;修饰键按下或安卓浮层请求时经 arm_translation_if_effective 置位。 inner.translation_active.store(false, Ordering::SeqCst); #[cfg(any(debug_assertions, test))] 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 9bc4cf688..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_active(&inner); + arm_translation_if_effective(&inner); } } } @@ -1294,7 +1294,7 @@ pub(super) fn modifier_shortcut_triggers( /// 收紧后这个 flag 的语义从「按过 Shift」变成「本次会话真的要翻译」,胶囊提示与 polish /// 分派读同一个值,不会再出现「胶囊说正在翻译、后端其实没翻」的漂移(用户未设目标语言 /// 时按 Shift 就会撞上)。返回 true 表示本次会话翻译已置位。 -pub(super) fn mark_translation_active(inner: &Arc) -> bool { +pub(super) fn arm_translation_if_effective(inner: &Arc) -> bool { let phase = inner.state.lock().phase; if !matches!(phase, SessionPhase::Starting | SessionPhase::Listening) { return false; @@ -1356,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 6cd480ef6..f27693f57 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -461,7 +461,7 @@ impl Default for StylePack { } } -/// 本次会话是否真的会走翻译管线。**唯一判定入口**——写入侧(mark_translation_active) +/// 本次会话是否真的会走翻译管线。**唯一判定入口**——写入侧(arm_translation_if_effective) /// 与 end_session 的 polish 分派都经它判定,否则两边会漂移(此前胶囊只看 /// `modifier_seen`,用户没设目标语言按下 Shift 也会看到「正在翻译」,而后端根本没翻)。 /// 胶囊本身只读经它置位的原子标志,不在音频回调线程触碰偏好锁。 @@ -485,7 +485,7 @@ pub fn translation_effective( if target.is_empty() { return false; } - !matches!(working_languages, [only] if only.trim() == target) + !(working_languages.len() == 1 && working_languages[0].trim() == target) } pub const BUILTIN_STYLE_PACK_RAW_ID: &str = "builtin.raw";