Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -599,11 +599,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>>,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(())
}

Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 → 光条点亮。
Expand Down
13 changes: 7 additions & 6 deletions openless-all/app/src-tauri/src/coordinator/dictation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -3601,8 +3599,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,
Expand Down
40 changes: 32 additions & 8 deletions openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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>) {
Expand Down Expand Up @@ -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 => {
Expand Down
95 changes: 95 additions & 0 deletions openless-all/app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -2976,6 +3003,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::*;
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,7 @@ export const ja: typeof zhCN = {
title: '翻訳ターゲット言語',
desc: '録音中に Shift で翻訳を起動。「無効」で Shift 無効化。',
disabled: '無効(Shift で翻訳を発動しない)',
sameAsWorking: 'ターゲット言語が唯一の作業言語と同じため、翻訳は発動しません(Shift を押しても通常の整文になります)。別のターゲットを選ぶか、上で作業言語を追加してください。',
},
save: {
workingFailed: '作業言語の保存に失敗しました。もう一度お試しください。',
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,7 @@ export const ko: typeof zhCN = {
title: '번역 대상 언어',
desc: '녹음 중 Shift 로 번역 실행. "비활성화" 시 Shift 무효.',
disabled: '비활성화 (Shift 로 번역 발동 안 함)',
sameAsWorking: '대상 언어가 유일한 작업 언어와 같아 번역이 실행되지 않습니다. Shift 를 눌러도 일반 정리로 처리됩니다. 다른 대상 언어를 고르거나 위에서 작업 언어를 추가하세요.',
},
save: {
workingFailed: '작업 언어 저장에 실패했습니다. 다시 시도하세요.',
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,7 @@ export const zhCN = {
title: '翻译目标语言',
desc: '录音时按 Shift 触发翻译。选「不启用」则 Shift 无效。',
disabled: '不启用(Shift 按下不触发翻译)',
sameAsWorking: '目标语言与你唯一的工作语言相同,翻译不会生效:按 Shift 仍按普通润色处理。换一个目标语言,或在上方多勾选一个工作语言。',
},
save: {
workingFailed: '工作语言保存失败,请重试。',
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,7 @@ export const zhTW: typeof zhCN = {
title: '翻譯目標語言',
desc: '錄音時按 Shift 觸發翻譯。選「不啟用」則 Shift 無效。',
disabled: '不啓用(Shift 按下不觸發翻譯)',
sameAsWorking: '目標語言與你唯一的工作語言相同,翻譯不會生效:按 Shift 仍按普通潤色處理。換一個目標語言,或在上方多勾選一個工作語言。',
},
save: {
workingFailed: '工作語言保存失敗,請重試。',
Expand Down
42 changes: 42 additions & 0 deletions openless-all/app/src/lib/translationTarget.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
24 changes: 24 additions & 0 deletions openless-all/app/src/lib/translationTarget.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading