From 8bc1583c989b9063263404144ed36e86e10ce39b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Thu, 4 Jun 2026 09:12:33 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(polish):=20=E5=AF=B9=E8=AF=9D=E6=84=9F?= =?UTF-8?q?=E7=9F=A5=E4=B8=8A=E4=B8=8B=E6=96=87=E6=8C=89=E9=A3=8E=E6=A0=BC?= =?UTF-8?q?=E5=8C=85=E9=9A=94=E7=A6=BB=20+=20=E6=8E=92=E9=99=A4=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对话感知 polish 回看最近历史作为 LLM 上下文时,之前复用所有成功会话,导致两类串味: 1. 切换风格包后,旧风格包的历史仍被带进新提示词,把当前风格带偏; 2. 翻译会话的译文/指令污染下一段普通润色(如中文输入被带成英文)。 给 DictationSession 增加 style_pack_id / translation_active 两字段(serde default 向后兼容旧历史),抽出 eligible_polish_context_turns 过滤:仅复用同一 active 风格包、且非翻译的成功会话;旧历史无 style_pack_id 时保守排除。前后端类型同步,补两条单测。 --- openless-all/app/src-tauri/src/coordinator.rs | 2 + .../src-tauri/src/coordinator/dictation.rs | 91 ++++++++++++++++--- openless-all/app/src-tauri/src/types.rs | 7 ++ openless-all/app/src/lib/ipc.ts | 2 + openless-all/app/src/lib/types.ts | 2 + 5 files changed, 93 insertions(+), 11 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index b31cd1cf..135a3d3f 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -3566,6 +3566,8 @@ async fn end_qa_session(inner: &Arc) -> Result<(), String> { raw_transcript: question.clone(), final_text: answer.clone(), mode: PolishMode::Raw, + style_pack_id: None, + translation_active: false, app_bundle_id: None, app_name: front_app.clone(), insert_status: InsertStatus::CopiedFallback, diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 5251ad76..5723f0ca 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1473,6 +1473,8 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { raw_transcript: raw.text.clone(), final_text: String::new(), mode: inner.prefs.get().default_mode, + style_pack_id: None, + translation_active: false, app_bundle_id: None, app_name: None, insert_status: InsertStatus::Failed, @@ -1565,8 +1567,8 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { working_languages ); // 对话感知 polish:拉最近 N 分钟的会话作为 LLM 上下文。仅在非翻译路径且非 Raw mode - // 才有意义(Raw 不走 LLM、翻译走单轮独立 prompt)。窗口=0 时 prior_turns 是空 Vec, - // polish 路径自动退化成单轮单消息——跟历史行为一致。 + // 才有意义(Raw 不走 LLM、翻译走单轮独立 prompt)。窗口=0 时 prior_turns 是空 Vec。 + // 只复用同一 active style pack 的历史;翻译会话永远不进入后续普通 polish 上下文。 let polish_context_window_minutes = prefs.polish_context_window_minutes; let prior_turns: Vec<(String, String)> = if !translation_active && (mode != PolishMode::Raw || raw_uses_llm) @@ -1576,13 +1578,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { .history .recent_within_minutes(polish_context_window_minutes) { - Ok(sessions) => sessions - .into_iter() - // 只取实际成功润色过的会话作为上下文:失败的会话 final_text 是 raw 兜底, - // 喂回 LLM 会让模型以为"上一轮我什么都没做"——没意义且占 token。 - .filter(|s| s.error_code.is_none() && !s.final_text.trim().is_empty()) - .map(|s| (s.raw_transcript, s.final_text)) - .collect(), + Ok(sessions) => eligible_polish_context_turns(sessions, &pack.id), Err(e) => { log::warn!("[coord] fetch polish context failed: {e}; fall back to single-turn"); Vec::new() @@ -1790,6 +1786,8 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { raw_transcript: raw.text.clone(), final_text: polished.clone(), mode, + style_pack_id: Some(pack.id.clone()), + translation_active, app_bundle_id: None, app_name: None, insert_status: status, @@ -1904,14 +1902,33 @@ fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) -> appended } +fn eligible_polish_context_turns( + sessions: Vec, + active_style_pack_id: &str, +) -> Vec<(String, String)> { + sessions + .into_iter() + // 只取实际成功润色过的会话作为上下文:失败的会话 final_text 是 raw 兜底, + // 喂回 LLM 会让模型以为"上一轮我什么都没做"——没意义且占 token。 + .filter(|s| s.error_code.is_none() && !s.final_text.trim().is_empty()) + // 风格包切换 = 上下文边界。旧历史没有 style_pack_id,无法证明同源,保守排除。 + .filter(|s| s.style_pack_id.as_deref() == Some(active_style_pack_id)) + // 翻译输出不参与后续普通润色上下文,避免英文翻译指令 / 译文污染下一次中文输入。 + .filter(|s| !s.translation_active) + .map(|s| (s.raw_transcript, s.final_text)) + .collect() +} + #[cfg(test)] mod tests { use super::{ append_typed_prefix, batch_asr_chunk_limit_ms, default_done_message, - drain_streaming_insert_deltas_with, finalize_polished_text, + drain_streaming_insert_deltas_with, eligible_polish_context_turns, finalize_polished_text, flush_streaming_insert_buffer_with, streaming_insert_eligible, }; - use crate::types::{ChineseScriptPreference, CorrectionRule, InsertStatus, PolishMode}; + use crate::types::{ + ChineseScriptPreference, CorrectionRule, DictationSession, InsertStatus, PolishMode, + }; fn correction_rule(pattern: &str, replacement: &str) -> CorrectionRule { CorrectionRule { @@ -1923,6 +1940,58 @@ mod tests { } } + fn history_session( + id: &str, + raw: &str, + final_text: &str, + style_pack_id: Option<&str>, + translation_active: bool, + ) -> DictationSession { + DictationSession { + id: id.into(), + created_at: "2026-06-03T00:00:00Z".into(), + raw_transcript: raw.into(), + final_text: final_text.into(), + mode: PolishMode::Structured, + app_bundle_id: None, + app_name: None, + insert_status: InsertStatus::Inserted, + error_code: None, + duration_ms: Some(1000), + dictionary_entry_count: None, + has_audio_recording: None, + style_pack_id: style_pack_id.map(str::to_string), + translation_active, + } + } + + #[test] + fn polish_context_resets_when_active_style_pack_changes() { + let sessions = vec![ + history_session("new", "raw new", "final new", Some("pack.new"), false), + history_session("old", "raw old", "final old", Some("pack.old"), false), + ]; + + let turns = eligible_polish_context_turns(sessions, "pack.new"); + + assert_eq!( + turns, + vec![("raw new".to_string(), "final new".to_string())] + ); + } + + #[test] + fn polish_context_excludes_translation_sessions() { + let sessions = vec![ + history_session("translation", "你好", "Hello", Some("pack.new"), true), + history_session("dictation", "继续", "继续。", Some("pack.new"), false), + ]; + + let turns = eligible_polish_context_turns(sessions, "pack.new"); + + assert_eq!(turns, vec![("继续".to_string(), "继续。".to_string())]); + } + #[test] fn streamed_output_skips_postprocessing_mutations() { let rules = vec![correction_rule("Open AI", "OpenAI")]; diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index f64f054e..4b5277e3 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -92,6 +92,13 @@ pub struct DictationSession { pub raw_transcript: String, pub final_text: String, pub mode: PolishMode, + /// 本次 dictation 使用的风格包。旧历史没有此字段时为 None;对话感知 polish + /// 只复用同一风格包的历史,避免切换风格包后旧上下文污染新提示词。 + #[serde(default)] + pub style_pack_id: Option, + /// 本次是否走翻译路径。翻译结果不应再作为下一次普通润色的上下文。 + #[serde(default)] + pub translation_active: bool, pub app_bundle_id: Option, pub app_name: Option, pub insert_status: InsertStatus, diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index a146238c..a9d7abfc 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -464,6 +464,8 @@ const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({ rawTranscript: h.preview, finalText: h.preview, mode: "structured", + stylePackId: "builtin.structured", + translationActive: false, appBundleId: null, appName: "VS Code", insertStatus: "inserted", diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 4b8d08f1..7ca9d310 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -12,6 +12,8 @@ export interface DictationSession { rawTranscript: string; finalText: string; mode: PolishMode; + stylePackId: string | null; + translationActive: boolean; appBundleId: string | null; appName: string | null; insertStatus: InsertStatus; From bff4bd727f704f155f11a25dd209a302535751fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Thu, 4 Jun 2026 11:36:35 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(polish):=20=E7=BF=BB=E8=AF=91=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=8F=82=E4=B8=8E=E5=AF=B9=E8=AF=9D=E6=84=9F=E7=9F=A5?= =?UTF-8?q?=E6=B6=A6=E8=89=B2=EF=BC=88=E6=B6=A6=E8=89=B2+=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E5=8D=95=E6=AC=A1=E8=B0=83=E7=94=A8=20+=20=E6=8C=89?= =?UTF-8?q?=E8=BD=AE=E5=89=94=E9=99=A4=E8=AF=91=E6=96=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接续上一条「按风格包隔离」,把「翻译会话直接排除出上下文」改成「保留但按需剔除」:翻译会话也参与对话连续性,又不让外语译文污染普通润色。 - 翻译改为单次 LLM 调用同时润色源文 + 翻译:polish_and_translate_or_passthrough 用合成系统提示词(翻译规则 + 追加输出润色后源文 + 两段哨兵)走现成 polish() 通道,复用对话上下文;解析失败 / 译文为空则退回专用 translate_text 拿干净译文(不丢字)。 - DictationSession 加 polish_source(serde default 兼容旧历史),存润色后的源语言文本。 - eligible_polish_context_turns 加 current_translation_active 分流:翻译历史在普通轮喂 polish_source(无则跳过=剔除译文),翻译轮喂 final_text;普通历史始终喂 final_text。 - 前后端类型同步(polishSource);补解析器 + 上下文分流单测,cargo test --lib 371 全过。 --- openless-all/app/src-tauri/src/coordinator.rs | 221 +++++++++++++++--- .../src-tauri/src/coordinator/dictation.rs | 106 +++++++-- openless-all/app/src-tauri/src/types.rs | 14 +- openless-all/app/src/lib/ipc.ts | 1 + openless-all/app/src/lib/types.ts | 1 + 5 files changed, 288 insertions(+), 55 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 135a3d3f..8ecfb968 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1760,7 +1760,10 @@ fn sync_custom_dictation_to_plugin(inner: &Arc) { return; } match crate::linux_fcitx::set_custom_dictation_trigger(&key_string) { - Ok(()) => log::info!("[fcitx] Synced custom dictation trigger '{}' to plugin", key_string), + Ok(()) => log::info!( + "[fcitx] Synced custom dictation trigger '{}' to plugin", + key_string + ), Err(e) => log::warn!("[fcitx] Failed to sync custom dictation trigger: {e}"), } } @@ -2876,36 +2879,8 @@ async fn polish_text( .await?) } -/// 翻译路径——和 polish 一样失败时返回原文 + 失败原因,避免"不丢字"约定被违反(CLAUDE.md)。 -async fn translate_or_passthrough( - raw: &RawTranscript, - target_language: &str, - working_languages: &[String], - chinese_script_preference: ChineseScriptPreference, - output_language_preference: OutputLanguagePreference, - llm_thinking_enabled: bool, - front_app: Option<&str>, -) -> (String, Option) { - match translate_text( - &raw.text, - target_language, - working_languages, - chinese_script_preference, - output_language_preference, - llm_thinking_enabled, - front_app, - ) - .await - { - Ok(s) => (s, None), - Err(e) => { - let reason = e.to_string(); - log::error!("[coord] translate failed, falling back to raw: {reason}"); - (raw.text.clone(), Some(reason)) - } - } -} - +/// 专用翻译(仅翻译、不润色、单轮)。现作为"润色+翻译"合成调用解析失败时的兜底—— +/// 模型没按两段格式输出时,退回这里拿一段干净译文,而不是把畸形输出当译文插入。 async fn translate_text( raw: &str, target_language: &str, @@ -2947,6 +2922,124 @@ async fn translate_text( .await?) } +/// "润色+翻译"单次调用的两段哨兵。模型按 `SRC\n源文\nTGT\n译文` 输出,解析器据此切分。 +/// 这两个串必须与 build_polish_translate_system_prompt 写给模型的完全一致。 +const POLISH_TRANSLATE_SRC_MARKER: &str = "[[OPENLESS_POLISHED_SOURCE]]"; +const POLISH_TRANSLATE_TGT_MARKER: &str = "[[OPENLESS_TRANSLATION]]"; + +/// 合成"先润色源文、再翻译"的系统提示词:在原翻译 prompt 之上追加"额外输出润色后源文" +/// 与严格两段格式(覆盖原 prompt 末尾的"只输出译文")。译文仍是要插入用户光标的主产物, +/// 故完整保留原翻译规则;润色后的源文只作对话上下文用,轻量清理即可。 +fn build_polish_translate_system_prompt(target_language: &str) -> String { + let base = crate::polish::prompts::translate_system_prompt(target_language); + format!( + "{base}\n\n\ + # 额外输出:润色后的源文(仅用于对话上下文,不展示给用户)\n\ + 在译文之前,先把上面的原始转写**按它本来的语言**润色一遍:去掉口癖(嗯 / 那个 / um)、\ + 补必要标点、纠正明显的识别错误,但**不翻译、不改写风格、不增删意思**。\n\n\ + # 输出格式(覆盖上面\u{201C}只输出译文\u{201D}的说明,严格遵守)\n\ + 严格按下面两段输出,两个标记必须原样出现、各占一行,标记之外不要有任何多余文字:\n\ + {src}\n\ + (这里放润色后的源文,保持原语言)\n\ + {tgt}\n\ + (这里放翻译成\u{300C}{lang}\u{300D}的译文)", + base = base, + src = POLISH_TRANSLATE_SRC_MARKER, + tgt = POLISH_TRANSLATE_TGT_MARKER, + lang = target_language, + ) +} + +/// 解析"润色+翻译"单次调用输出 → Some((润色后源文, 译文))。 +/// 找到译文标记且译文非空 → Some((源文, 译文)):源文标记缺失 / 源文段为空时源文为 None, +/// 译文取标记之后的干净正文。**没有译文标记、或译文段为空(模型截断 / 只吐了标记)→ None**, +/// 表示没拿到可信译文,交由调用方退回专用翻译——避免把空串当"成功译文"插进光标而丢字。 +fn split_polish_translate_output(raw: &str) -> Option<(Option, String)> { + let tgt_idx = raw.find(POLISH_TRANSLATE_TGT_MARKER)?; + let translation = raw[tgt_idx + POLISH_TRANSLATE_TGT_MARKER.len()..] + .trim() + .to_string(); + if translation.is_empty() { + return None; + } + let before_tgt = &raw[..tgt_idx]; + let source = before_tgt + .find(POLISH_TRANSLATE_SRC_MARKER) + .map(|i| { + before_tgt[i + POLISH_TRANSLATE_SRC_MARKER.len()..] + .trim() + .to_string() + }) + .filter(|s| !s.is_empty()); + Some((source, translation)) +} + +/// 翻译路径——单次 LLM 调用同时润色源文 + 翻译。和 polish 一样失败时返回原文 + 失败原因, +/// 避免"不丢字"约定被违反(CLAUDE.md)。返回 (要插入的译文, 润色后源文供上下文用, 失败原因)。 +#[allow(clippy::too_many_arguments)] +async fn polish_and_translate_or_passthrough( + raw: &RawTranscript, + target_language: &str, + mode: PolishMode, + hotwords: &[String], + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + llm_thinking_enabled: bool, + front_app: Option<&str>, + prior_turns: &[(String, String)], +) -> (String, Option, Option) { + let system_prompt = build_polish_translate_system_prompt(target_language); + match polish_text( + &raw.text, + mode, + hotwords, + &system_prompt, + working_languages, + chinese_script_preference, + output_language_preference, + llm_thinking_enabled, + front_app, + prior_turns, + ) + .await + { + Ok(out) => match split_polish_translate_output(&out) { + Some((source, translation)) => (translation, source, None), + None => { + // 模型没按两段格式输出:退回专用翻译拿一段干净译文,避免把畸形输出插进光标。 + // 此时无可信源文,这条翻译历史不参与后续普通润色上下文。 + log::warn!( + "[coord] polish+translate output missing markers; falling back to plain translate" + ); + match translate_text( + &raw.text, + target_language, + working_languages, + chinese_script_preference, + output_language_preference, + llm_thinking_enabled, + front_app, + ) + .await + { + Ok(translation) => (translation, None, None), + Err(e) => { + let reason = e.to_string(); + log::error!("[coord] fallback translate failed, using raw: {reason}"); + (raw.text.clone(), None, Some(reason)) + } + } + } + }, + Err(e) => { + let reason = e.to_string(); + log::error!("[coord] polish+translate failed, falling back to raw: {reason}"); + (raw.text.clone(), None, Some(reason)) + } + } +} + fn read_whisper_credentials() -> (String, String, String) { let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) .ok() @@ -3568,6 +3661,7 @@ async fn end_qa_session(inner: &Arc) -> Result<(), String> { mode: PolishMode::Raw, style_pack_id: None, translation_active: false, + polish_source: None, app_bundle_id: None, app_name: front_app.clone(), insert_status: InsertStatus::CopiedFallback, @@ -3784,6 +3878,54 @@ mod tests { Uuid::from_u128(n) } + #[test] + fn split_polish_translate_parses_both_sections() { + let out = format!( + "{POLISH_TRANSLATE_SRC_MARKER}\n你好,世界。\n{POLISH_TRANSLATE_TGT_MARKER}\nHello, world." + ); + let (source, translation) = split_polish_translate_output(&out).expect("both markers"); + assert_eq!(source.as_deref(), Some("你好,世界。")); + assert_eq!(translation, "Hello, world."); + } + + #[test] + fn split_polish_translate_no_translation_marker_returns_none_for_fallback() { + // 完全没有译文标记 → None,调用方据此退回专用翻译拿干净译文。 + assert_eq!(split_polish_translate_output(" Hello, world. "), None); + } + + #[test] + fn split_polish_translate_empty_translation_returns_none_for_fallback() { + // 有译文标记但内容为空(截断 / 只吐标记)→ None,避免空串当成功译文插入光标。 + let out = + format!("{POLISH_TRANSLATE_SRC_MARKER}\n你好。\n{POLISH_TRANSLATE_TGT_MARKER}\n "); + assert_eq!(split_polish_translate_output(&out), None); + } + + #[test] + fn split_polish_translate_only_translation_marker_keeps_clean_translation() { + let out = format!("noise{POLISH_TRANSLATE_TGT_MARKER}\nHola"); + let (source, translation) = split_polish_translate_output(&out).expect("tgt marker"); + assert_eq!(source, None); + assert_eq!(translation, "Hola"); + } + + #[test] + fn split_polish_translate_empty_source_section_is_none() { + let out = format!("{POLISH_TRANSLATE_SRC_MARKER}\n \n{POLISH_TRANSLATE_TGT_MARKER}\nHi"); + let (source, translation) = split_polish_translate_output(&out).expect("tgt marker"); + assert_eq!(source, None); + assert_eq!(translation, "Hi"); + } + + #[test] + fn build_polish_translate_prompt_contains_markers_and_target() { + let p = build_polish_translate_system_prompt("日本語"); + assert!(p.contains(POLISH_TRANSLATE_SRC_MARKER)); + assert!(p.contains(POLISH_TRANSLATE_TGT_MARKER)); + assert!(p.contains("日本語")); + } + #[tokio::test] async fn hotkey_injection_gate_logs_pressed_and_cancels() { let _ = env_logger::builder() @@ -5014,7 +5156,9 @@ fn emit_capsule( std::thread::spawn(move || { let current = LAST_AUX.lock().unwrap().clone(); if current.as_deref() != Some(&text) { - log::info!("[capsule] set_aux_down skipped: state changed to {current:?}"); + log::info!( + "[capsule] set_aux_down skipped: state changed to {current:?}" + ); return; } if let Err(e) = crate::linux_fcitx::set_aux_down(&text) { @@ -5022,7 +5166,10 @@ fn emit_capsule( } }); // 终态(Done/Cancelled/Error)3 秒后自动清除,避免一直跟随焦点。 - if matches!(state, CapsuleState::Done | CapsuleState::Cancelled | CapsuleState::Error) { + if matches!( + state, + CapsuleState::Done | CapsuleState::Cancelled | CapsuleState::Error + ) { let text = t.to_string(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_secs(3)); @@ -5045,12 +5192,16 @@ fn emit_capsule( std::thread::spawn(move || { let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst); if latest_gen > gen + 1 { - log::info!("[capsule] clear_aux_down skipped: gen {gen}, latest {latest_gen}"); + log::info!( + "[capsule] clear_aux_down skipped: gen {gen}, latest {latest_gen}" + ); return; } let current = LAST_AUX.lock().unwrap().clone(); if current.is_some() { - log::info!("[capsule] clear_aux_down skipped: state changed to {current:?}"); + log::info!( + "[capsule] clear_aux_down skipped: state changed to {current:?}" + ); return; } if let Err(e) = crate::linux_fcitx::clear_aux_down() { diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 5723f0ca..35f0d9b2 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1475,6 +1475,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { mode: inner.prefs.get().default_mode, style_pack_id: None, translation_active: false, + polish_source: None, app_bundle_id: None, app_name: None, insert_status: InsertStatus::Failed, @@ -1566,19 +1567,21 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { hotword_strs.len(), working_languages ); - // 对话感知 polish:拉最近 N 分钟的会话作为 LLM 上下文。仅在非翻译路径且非 Raw mode - // 才有意义(Raw 不走 LLM、翻译走单轮独立 prompt)。窗口=0 时 prior_turns 是空 Vec。 - // 只复用同一 active style pack 的历史;翻译会话永远不进入后续普通 polish 上下文。 + // 对话感知 polish:拉最近 N 分钟的会话作为 LLM 上下文。翻译现在也走"润色+翻译"单次 + // LLM 调用,所以翻译路径同样需要上下文;只有 Raw 且不走 LLM 才没意义。窗口=0 时为空 Vec。 + // 只复用同一 active style pack 的历史;翻译历史按当前是否翻译决定喂译文还是润色后源文 + // (见 eligible_polish_context_turns)。 let polish_context_window_minutes = prefs.polish_context_window_minutes; - let prior_turns: Vec<(String, String)> = if !translation_active - && (mode != PolishMode::Raw || raw_uses_llm) + let prior_turns: Vec<(String, String)> = if (translation_active + || mode != PolishMode::Raw + || raw_uses_llm) && polish_context_window_minutes > 0 { match inner .history .recent_within_minutes(polish_context_window_minutes) { - Ok(sessions) => eligible_polish_context_turns(sessions, &pack.id), + Ok(sessions) => eligible_polish_context_turns(sessions, &pack.id, translation_active), Err(e) => { log::warn!("[coord] fetch polish context failed: {e}; fall back to single-turn"); Vec::new() @@ -1602,6 +1605,9 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { // Linux: emit_capsule(Polishing) 已通过 fcitx5 auxDown 显示 "✨ 润色中...", // 无需在此重复调用。 + // 翻译会话润色后的源语言文本(译文前的中间产物),仅翻译路径解析成功时有值, + // 写进 history 供后续普通润色轮复用(剔除译文、避免外语污染)。 + let mut polish_source: Option = None; let (polished, polish_error, already_streamed) = if translation_active { log::info!( "[coord] translation mode → target=\u{300C}{}\u{300D} working={:?} front_app={:?}", @@ -1609,16 +1615,20 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { working_languages, front_app ); - let (p, e) = translate_or_passthrough( + let (p, src, e) = polish_and_translate_or_passthrough( &raw, &translation_target, + mode, + &hotword_strs, &working_languages, chinese_script_preference, output_language_preference, llm_thinking_enabled, front_app.as_deref(), + &prior_turns, ) .await; + polish_source = src; (p, e, false) } else if streaming_eligible { run_streaming_polish( @@ -1788,6 +1798,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { mode, style_pack_id: Some(pack.id.clone()), translation_active, + polish_source, app_bundle_id: None, app_name: None, insert_status: status, @@ -1905,17 +1916,31 @@ fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) -> fn eligible_polish_context_turns( sessions: Vec, active_style_pack_id: &str, + current_translation_active: bool, ) -> Vec<(String, String)> { sessions .into_iter() // 只取实际成功润色过的会话作为上下文:失败的会话 final_text 是 raw 兜底, // 喂回 LLM 会让模型以为"上一轮我什么都没做"——没意义且占 token。 + // 这条同时保证下面 filter_map 里翻译历史的 final_text 一定是真译文(而非 passthrough + // 原文)——失败 / 兜底的翻译会话 error_code 非空,已在此被滤掉。 .filter(|s| s.error_code.is_none() && !s.final_text.trim().is_empty()) // 风格包切换 = 上下文边界。旧历史没有 style_pack_id,无法证明同源,保守排除。 .filter(|s| s.style_pack_id.as_deref() == Some(active_style_pack_id)) - // 翻译输出不参与后续普通润色上下文,避免英文翻译指令 / 译文污染下一次中文输入。 - .filter(|s| !s.translation_active) - .map(|s| (s.raw_transcript, s.final_text)) + // 翻译历史按"下一轮是否也翻译"决定喂哪一段,既保留对话连续性又不让译文串味: + // - 当前是翻译轮 → 喂译文(final_text),保持目标语言一致; + // - 当前是普通轮 → 喂润色后的源文(polish_source),把译文剔除掉;源文缺失(解析 + // 失败 / 旧历史)则整条跳过——宁可少一条上下文,也不让外语译文混进普通润色。 + // - 普通历史无论当前轮是什么,都喂 final_text(本就是源语言润色结果)。 + .filter_map(|s| { + if s.translation_active && !current_translation_active { + s.polish_source + .filter(|src| !src.trim().is_empty()) + .map(|src| (s.raw_transcript, src)) + } else { + Some((s.raw_transcript, s.final_text)) + } + }) .collect() } @@ -1940,12 +1965,14 @@ mod tests { } } + #[allow(clippy::too_many_arguments)] fn history_session( id: &str, raw: &str, final_text: &str, style_pack_id: Option<&str>, translation_active: bool, + polish_source: Option<&str>, ) -> DictationSession { DictationSession { id: id.into(), @@ -1962,17 +1989,18 @@ mod tests { has_audio_recording: None, style_pack_id: style_pack_id.map(str::to_string), translation_active, + polish_source: polish_source.map(str::to_string), } } #[test] fn polish_context_resets_when_active_style_pack_changes() { let sessions = vec![ - history_session("new", "raw new", "final new", Some("pack.new"), false), - history_session("old", "raw old", "final old", Some("pack.old"), false), + history_session("new", "raw new", "final new", Some("pack.new"), false, None), + history_session("old", "raw old", "final old", Some("pack.old"), false, None), ]; - let turns = eligible_polish_context_turns(sessions, "pack.new"); + let turns = eligible_polish_context_turns(sessions, "pack.new", false); assert_eq!( turns, @@ -1981,17 +2009,61 @@ mod tests { } #[test] - fn polish_context_excludes_translation_sessions() { + fn normal_turn_uses_polished_source_of_translation_history_not_the_translation() { + // 当前是普通润色轮:翻译历史喂"润色后的源文",把译文剔除,避免外语污染。 let sessions = vec![ - history_session("translation", "你好", "Hello", Some("pack.new"), true), - history_session("dictation", "继续", "继续。", Some("pack.new"), false), + history_session( + "translation", + "你好", + "Hello", + Some("pack.new"), + true, + Some("你好。"), + ), + history_session("dictation", "继续", "继续。", Some("pack.new"), false, None), ]; - let turns = eligible_polish_context_turns(sessions, "pack.new"); + let turns = eligible_polish_context_turns(sessions, "pack.new", false); + + assert_eq!( + turns, + vec![ + ("你好".to_string(), "你好。".to_string()), + ("继续".to_string(), "继续。".to_string()), + ] + ); + } + + #[test] + fn normal_turn_skips_translation_history_without_polished_source() { + // 译文历史没有 polish_source(解析失败 / 旧历史)→ 普通轮整条跳过,宁缺毋滥。 + let sessions = vec![ + history_session("translation", "你好", "Hello", Some("pack.new"), true, None), + history_session("dictation", "继续", "继续。", Some("pack.new"), false, None), + ]; + + let turns = eligible_polish_context_turns(sessions, "pack.new", false); assert_eq!(turns, vec![("继续".to_string(), "继续。".to_string())]); } + #[test] + fn translation_turn_keeps_translation_text_of_translation_history() { + // 当前还是翻译轮:翻译历史喂译文(final_text),保持目标语言一致。 + let sessions = vec![history_session( + "translation", + "你好", + "Hello", + Some("pack.new"), + true, + Some("你好。"), + )]; + + let turns = eligible_polish_context_turns(sessions, "pack.new", true); + + assert_eq!(turns, vec![("你好".to_string(), "Hello".to_string())]); + } + #[test] fn streamed_output_skips_postprocessing_mutations() { let rules = vec![correction_rule("Open AI", "OpenAI")]; diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 4b5277e3..7fa7560e 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -96,9 +96,15 @@ pub struct DictationSession { /// 只复用同一风格包的历史,避免切换风格包后旧上下文污染新提示词。 #[serde(default)] pub style_pack_id: Option, - /// 本次是否走翻译路径。翻译结果不应再作为下一次普通润色的上下文。 + /// 本次是否走翻译路径。决定对话感知上下文怎么复用这条历史:下一轮也是翻译时喂 + /// `final_text`(译文)保持一致;下一轮是普通润色时改喂 `polish_source`(润色后的源文) + /// 以剔除译文、避免外语污染。 #[serde(default)] pub translation_active: bool, + /// 翻译会话润色后的**源语言**文本(译文前的润色中间产物)。普通会话、解析失败或旧 + /// 历史为 None。仅用于对话感知上下文:普通润色轮复用翻译历史时喂这一段而非译文。 + #[serde(default)] + pub polish_source: Option, pub app_bundle_id: Option, pub app_name: Option, pub insert_status: InsertStatus, @@ -1569,14 +1575,16 @@ pub fn default_style_system_prompt_for_mode(mode: PolishMode) -> String { // 到这里只剩 Raw 一种模式(Light / Structured / Formal 都在上面 early-return 了)。 // 仍用 match 把 _ 兜底为 unreachable!(),让编译期挡住未来加新 mode 时忘了在上面分流。 let task_and_example = match mode { - PolishMode::Raw => "# 任务(原文)\n\ + PolishMode::Raw => { + "# 任务(原文)\n\ 仅做最小化整理:补全标点、必要分句。\n\ 保留原话顺序、用词、语气;\u{4E0D}改写、\u{4E0D}扩写、\u{4E0D}重排。\n\ 可去除明显口癖(\u{55EF}、\u{554A}、那个、就是、you know),但\u{4E0D}改变信息密度。\n\ \n\ # 示例\n\ 原:\u{55EF}那个我刚刚跟客户聊完然后他说下周三可以给反馈\n\ - 出:我刚刚跟客户聊完,他说下周三可以给反馈。", + 出:我刚刚跟客户聊完,他说下周三可以给反馈。" + } PolishMode::Light | PolishMode::Structured | PolishMode::Formal => { unreachable!("light/structured/formal handled by early return above") diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index a9d7abfc..842f103f 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -466,6 +466,7 @@ const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({ mode: "structured", stylePackId: "builtin.structured", translationActive: false, + polishSource: null, appBundleId: null, appName: "VS Code", insertStatus: "inserted", diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 7ca9d310..6b1bbd86 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -14,6 +14,7 @@ export interface DictationSession { mode: PolishMode; stylePackId: string | null; translationActive: boolean; + polishSource: string | null; appBundleId: string | null; appName: string | null; insertStatus: InsertStatus; From 4c31448cb970b01fef285bd9729b7057d37d7a39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Thu, 4 Jun 2026 12:59:08 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(polish):=20=E8=A1=A5=20Case=204=20?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=E2=80=94=E2=80=94=E6=99=AE=E9=80=9A=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=9C=A8=E7=BF=BB=E8=AF=91=E8=BD=AE=E5=96=82=20final?= =?UTF-8?q?=5Ftext=20(@claude=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src-tauri/src/coordinator/dictation.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 35f0d9b2..e06d0172 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -2064,6 +2064,23 @@ mod tests { assert_eq!(turns, vec![("你好".to_string(), "Hello".to_string())]); } + #[test] + fn translation_turn_uses_normal_history_final_text() { + // 当前是翻译轮,普通历史照常喂 final_text(本就是源语言润色结果,不需要剔除)。 + let sessions = vec![history_session( + "dictation", + "继续", + "继续。", + Some("pack.new"), + false, + None, + )]; + + let turns = eligible_polish_context_turns(sessions, "pack.new", true); + + assert_eq!(turns, vec![("继续".to_string(), "继续。".to_string())]); + } + #[test] fn streamed_output_skips_postprocessing_mutations() { let rules = vec![correction_rule("Open AI", "OpenAI")];