fix(translation): 翻译不会生效时不再进入翻译态(胶囊误报 + 空转 LLM) (#908)

* fix(translation): 翻译不会生效时不再进入翻译态

两个症状同源:`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 <noreply@anthropic.com>

* feat(translation): 目标语言与唯一工作语言相同时在翻译页给出提示

后端已经不会在这种组合下发起翻译(上一个 commit),但用户在设置页看不出
任何异常:状态灯仍写「已启用」,按 Shift 却什么也不会发生,只能靠翻日志
才知道为什么。

- 翻译目标语言卡片下方出现一条警示:说明这个组合不会生效,并给出两条
  出路(换目标语言,或多勾一个工作语言)。
- 「已启用 / 未启用」状态灯改为同时看目标语言和这个冗余判定,不再谎报。

判定逻辑抽成 `lib/translationTarget.ts`,与后端 `translation_effective`
同一套规则(多工作语言不拦、简→繁不误判),两侧各带单测钉住契约。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(translation): translation_modifier_seen 更名 translation_active + 修正安卓 overlay 日志

* refactor(translation): 更名 arm_translation_if_effective 并简化冗余判定表达式

审查跟进(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 全绿。

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-08-06 00:42:59 +08:00
1 parent 27198b9b2f
commit cd58759c26
13 files changed
+246 -28

No files matched your search

+15 -12
View File
@@ -624,11 +624,14 @@ struct Inner {
/// 预览确认模式暂存的结果和原选区目标;仅在用户确认时才允许插入。
#[cfg(not(mobile))]
selection_polish_preview: Mutex<Option<selection_polish::PendingSelectionPolishPreview>>,
/// 翻译模式触发标志。每次 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<Option<QaHotkeyMonitor>>,
@@ -851,7 +854,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),
@@ -969,7 +972,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),
@@ -1763,10 +1766,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(())
}
@@ -1780,7 +1783,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
}
@@ -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 → 光条点亮。
@@ -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() {
@@ -3603,8 +3601,11 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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,
@@ -994,7 +994,7 @@ pub(super) fn translation_hotkey_bridge_loop(inner: Arc<Inner>, 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<Inner>) {
/// 在这里、而不是在读取侧判定「翻译是否真的会发生」:本函数在桥接线程(翻译热键事件 /
/// 主热键循环)和安卓 overlay 命令路径上调用,均非音频回调线程,读一次 prefs 无妨;
/// 而 `translation_active` 的读取侧之一是 emit_capsule —— 它在音频回调线程按帧执行,
/// 不能碰偏好锁(见 capsule_focus.rs 注释)。
///
/// 收紧后这个 flag 的语义从「按过 Shift」变成「本次会话真的要翻译」,胶囊提示与 polish
/// 分派读同一个值,不会再出现「胶囊说正在翻译、后端其实没翻」的漂移(用户未设目标语言
/// 时按 Shift 就会撞上)。返回 true 表示本次会话翻译已置位。
pub(super) fn arm_translation_if_effective(inner: &Arc<Inner>) -> 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<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
@@ -1332,7 +1356,7 @@ pub(super) fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEve
|| crate::shortcut_binding::legacy_modifier_trigger(&translation_hotkey)
.is_some()
{
mark_translation_modifier_seen(&inner_cloned);
arm_translation_if_effective(&inner_cloned);
}
}
HotkeyEvent::QaShortcutPressed => {
+95
View File
@@ -469,6 +469,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";
@@ -2992,6 +3019,74 @@ pub struct QaChatMessage {
pub selection_text: Option<String>,
}
#[cfg(test)]
mod translation_effective_tests {
use super::translation_effective;
fn langs(list: &[&str]) -> Vec<String> {
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::*;
+1
View File
@@ -585,6 +585,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.',
+1
View File
@@ -587,6 +587,7 @@ export const ja: typeof zhCN = {
title: '翻訳ターゲット言語',
desc: '録音中に Shift で翻訳を起動。「無効」で Shift 無効化。',
disabled: '無効(Shift で翻訳を発動しない)',
sameAsWorking: 'ターゲット言語が唯一の作業言語と同じため、翻訳は発動しません(Shift を押しても通常の整文になります)。別のターゲットを選ぶか、上で作業言語を追加してください。',
},
save: {
workingFailed: '作業言語の保存に失敗しました。もう一度お試しください。',
+1
View File
@@ -587,6 +587,7 @@ export const ko: typeof zhCN = {
title: '번역 대상 언어',
desc: '녹음 중 Shift 로 번역 실행. "비활성화" 시 Shift 무효.',
disabled: '비활성화 (Shift 로 번역 발동 안 함)',
sameAsWorking: '대상 언어가 유일한 작업 언어와 같아 번역이 실행되지 않습니다. Shift 를 눌러도 일반 정리로 처리됩니다. 다른 대상 언어를 고르거나 위에서 작업 언어를 추가하세요.',
},
save: {
workingFailed: '작업 언어 저장에 실패했습니다. 다시 시도하세요.',
+1
View File
@@ -583,6 +583,7 @@ export const zhCN = {
title: '翻译目标语言',
desc: '录音时按 Shift 触发翻译。选「不启用」则 Shift 无效。',
disabled: '不启用(Shift 按下不触发翻译)',
sameAsWorking: '目标语言与你唯一的工作语言相同,翻译不会生效:按 Shift 仍按普通润色处理。换一个目标语言,或在上方多勾选一个工作语言。',
},
save: {
workingFailed: '工作语言保存失败,请重试。',
+1
View File
@@ -585,6 +585,7 @@ export const zhTW: typeof zhCN = {
title: '翻譯目標語言',
desc: '錄音時按 Shift 觸發翻譯。選「不啟用」則 Shift 無效。',
disabled: '不啓用(Shift 按下不觸發翻譯)',
sameAsWorking: '目標語言與你唯一的工作語言相同,翻譯不會生效:按 Shift 仍按普通潤色處理。換一個目標語言,或在上方多勾選一個工作語言。',
},
save: {
workingFailed: '工作語言保存失敗,請重試。',
@@ -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',
);
@@ -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;
}
+25 -1
View File
@@ -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 && (
<div
role="status"
style={{
marginTop: 10,
padding: '8px 12px',
borderRadius: 10,
border: '0.5px solid rgba(217,119,6,0.24)',
background: 'rgba(217,119,6,0.08)',
color: 'var(--ol-warn, #b45309)',
fontSize: 11.5,
lineHeight: 1.55,
}}
>
{t('translation.target.sameAsWorking')}
</div>
)}
</Card>
{/* 3. 使用方法 */}