feat(translation): inherit active style pack (#984)

* feat(translation): inherit active style pack

* fix(translation): resolve style prompt conflicts
This commit is contained in:
Chris233 authored and GitHub committed 2026-08-23 02:08:01 +08:00
1 parent ec33bd5dd2
commit 1abc7f595b
10 files changed
+194 -43

No files matched your search

+29 -5
View File
@@ -4158,11 +4158,35 @@ mod tests {
}
#[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("日本語"));
fn translation_prompt_inherits_active_style_and_preserves_structure() {
let style_prompt = "# STYLE_PACK_976\n按主题整理为编号列表。\n\n{{HOTWORDS}}";
let combined = build_polish_translate_system_prompt(style_prompt, "English");
let (system_prompt, _) = crate::polish::compose_polish_prompts(
"原始转写",
PolishMode::Structured,
&["OpenLess".to_string()],
&combined,
&["简体中文".to_string(), "English".to_string()],
ChineseScriptPreference::Auto,
crate::types::OutputLanguagePreference::Auto,
Some("GitHub"),
Some("已有上下文<|OPENLESS_CURSOR|>"),
true,
);
assert!(system_prompt.contains("STYLE_PACK_976"));
assert!(system_prompt.contains("OpenLess"));
assert!(!system_prompt.contains("{{HOTWORDS}}"));
assert!(system_prompt.contains("English"));
assert!(system_prompt.contains(POLISH_TRANSLATE_SRC_MARKER));
assert!(system_prompt.contains(POLISH_TRANSLATE_TGT_MARKER));
assert!(system_prompt.contains("# ASR 纠错"));
assert!(system_prompt.contains("Token"));
assert!(!system_prompt.contains("只输出最终英文译文"));
assert!(!system_prompt.contains("不得输出中文"));
assert!(system_prompt.contains("列表、编号、段落和 Markdown 结构"));
assert!(system_prompt.contains("<cursor_context>"));
assert!(system_prompt.contains("# 多轮上下文使用规则"));
}
#[tokio::test]
@@ -4546,6 +4546,7 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
&translation_target,
mode,
&hotword_strs,
&style_system_prompt,
&working_languages,
chinese_script_preference,
output_language_preference,
@@ -331,23 +331,34 @@ pub(super) async fn translate_text(
pub(super) const POLISH_TRANSLATE_SRC_MARKER: &str = "[[OPENLESS_POLISHED_SOURCE]]";
pub(super) const POLISH_TRANSLATE_TGT_MARKER: &str = "[[OPENLESS_TRANSLATION]]";
/// 合成"先润色源文、再翻译"的系统提示词:在原翻译 prompt 之上追加"额外输出润色后源文"
/// 与严格两段格式(覆盖原 prompt 末尾的"只输出译文")。译文仍是要插入用户光标的主产物,
/// 故完整保留原翻译规则;润色后的源文只作对话上下文用,轻量清理即可。
pub(super) fn build_polish_translate_system_prompt(target_language: &str) -> String {
let base = crate::polish::prompts::translate_system_prompt(target_language);
/// 合成"按当前风格润色源文、再翻译"的系统提示词。当前风格包决定源文的结构与语气,
/// 翻译规则负责把该结果忠实转换为目标语言;末尾的严格两段格式覆盖两套 prompt 各自的
/// "只输出正文"约束。译文用于插入,风格化源文只写入历史供后续上下文复用。
pub(super) fn build_polish_translate_system_prompt(
style_system_prompt: &str,
target_language: &str,
) -> String {
let translation_rules = crate::polish::prompts::translate_system_prompt_rules(target_language);
format!(
"{base}\n\n\
# 额外输出:润色后的源文(仅用于对话上下文,不展示给用户)\n\
在译文之前,先把上面的原始转写**按它本来的语言**润色一遍:去掉口癖(嗯 / 那个 / um)、\
补必要标点、纠正明显的识别错误,但**不翻译、不改写风格、不增删意思**。\n\n\
# 输出格式(覆盖上面\u{201C}只输出译文\u{201D}的说明,严格遵守)\n\
"# 任务(按当前风格润色并翻译)\n\
先完整执行下方的当前风格包规则,把原始 ASR 转写整理为同语言的风格化源文;\
再把该风格化源文翻译成\u{300C}{lang}\u{300D}。翻译对象是风格化源文,不是原始转写。\n\n\
# 当前风格包规则\n\
{style}\n\n\
# 翻译规则\n\
{translation_rules}\n\n\
# 两阶段约束\n\
- 风格包决定内容的组织方式、语气和信息密度;翻译不得把它还原成普通连续段落。\n\
- 译文必须保留风格化源文的列表、编号、段落和 Markdown 结构,并忠实保留原意。\n\
- 风格化源文保持原语言;最终译文只使用\u{300C}{lang}\u{300D}表达需要翻译的正文。\n\n\
# 输出格式(优先级最高,覆盖上面所有\u{201C}只输出正文\u{201D}的说明)\n\
严格按下面两段输出,两个标记必须原样出现、各占一行,标记之外不要有任何多余文字:\n\
{src}\n\
(这里放润色后的源文,保持原语言)\n\
(这里放按当前风格包完整润色后的源文,保持原语言)\n\
{tgt}\n\
(这里放翻译成\u{300C}{lang}\u{300D}的译文)",
base = base,
(这里放保留相同风格与结构的\u{300C}{lang}\u{300D}译文)",
style = style_system_prompt.trim(),
translation_rules = translation_rules,
src = POLISH_TRANSLATE_SRC_MARKER,
tgt = POLISH_TRANSLATE_TGT_MARKER,
lang = target_language,
@@ -386,6 +397,7 @@ pub(super) async fn polish_and_translate_or_passthrough(
target_language: &str,
mode: PolishMode,
hotwords: &[String],
style_system_prompt: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
@@ -397,7 +409,7 @@ pub(super) async fn polish_and_translate_or_passthrough(
llm_elapsed_ms: &mut Option<u64>,
multimodal: bool,
) -> (String, Option<String>, Option<String>) {
let system_prompt = build_polish_translate_system_prompt(target_language);
let system_prompt = build_polish_translate_system_prompt(style_system_prompt, target_language);
match polish_text(
&raw.text,
mode,
+48 -24
View File
@@ -2155,28 +2155,53 @@ pub mod prompts {
/// 此 prompt 之上还有 working_languages_premise 拼出的"# 上下文"前提。
///
/// target_language == "English"(含 "美式英文" / "英文" / "english" 等别名)时整段切到
/// EN_TRANSLATE_SYSTEM_PROMPT —— 不再走通用 base,避免通用规则与 EN 专属的「ASR 纠错优先
/// EN_TRANSLATE_SYSTEM_RULES —— 不再走通用 base,避免通用规则与 EN 专属的「ASR 纠错优先
/// + 中→英技术词规范化」相互稀释。来源:社区「重写为英文」prompt,精简整合后整体注入。
pub fn translate_system_prompt(target_language: &str) -> String {
// issue #609 F-02:翻译路径与 polish 路径对齐——在系统提示末尾追加对抗式注入防御措辞。
// 本函数是所有翻译路径(OpenAI 兼容 / Gemini 的 compose_translate_prompts、Codex
// translate_to、润色+翻译合一的 build_polish_translate_system_prompt)写给模型的唯一
// base,把防御嵌在这里令每个调用方自动覆盖,杜绝调用点遗漏。LLM 不是安全边界,纵深防御。
// translate_to)写给模型的唯一 base,把防御嵌在这里令每个调用方自动覆盖,杜绝调用点遗漏。
// LLM 不是安全边界,纵深防御。
let base = translate_system_prompt_base(target_language);
format!("{}\n\n{}", base, polish_injection_defense())
}
/// 可嵌入其它工作流的翻译规则,不包含单段翻译的输出格式约束。
///
/// 润色+翻译流程需要同时输出原语言风格化源文和目标语言译文;复用
/// translate_system_prompt 会把“只输出译文 / 不得输出中文”等单段输出规则一并带入,
/// 与两段格式冲突。因此这里只复用 ASR 纠错、术语和忠实翻译规则。
pub fn translate_system_prompt_rules(target_language: &str) -> String {
translate_system_prompt_rules_base(target_language)
}
fn translate_system_prompt_base(target_language: &str) -> String {
let rules = translate_system_prompt_rules_base(target_language);
if is_english_target(target_language) {
return EN_TRANSLATE_SYSTEM_PROMPT.to_string();
return format!(
"{rules}\n\n{output}",
output = EN_TRANSLATE_OUTPUT_INSTRUCTIONS
);
}
format!(
"# 任务(翻译输出)\n\
把下面收到的一段语音转写翻译成 \u{300C}{lang}\u{300D}。\n\
这是用户对着语音输入工具说的话——他正在某个 app 的输入框前,\
转译结果会直接被插入到光标位置。\n\
\n\
# 翻译规则\n\
转译结果会直接被插入到光标位置。\n\n\
{rules}\n\n\
{output}",
lang = target_language,
rules = rules,
output = COMMON_TRANSLATE_OUTPUT_INSTRUCTIONS,
)
}
fn translate_system_prompt_rules_base(target_language: &str) -> String {
if is_english_target(target_language) {
return EN_TRANSLATE_SYSTEM_RULES.to_string();
}
format!(
"# 翻译规则\n\
## 必须保留原文(不要翻译)\n\
- 人名、地名、品牌名(OpenAI、Tauri、字节跳动、张三 等)。\n\
- 代码标识符、技术术语(useState、async/await、HTTP、Rust crate 名 等)。\n\
@@ -2196,15 +2221,15 @@ pub mod prompts {
## 边界 case\n\
- 转写非常短(一两个字)也照译,\u{4E0D}因为短就硬补内容。\n\
- 转写是命令式(\"加个空格 / 删除最后一行\")时,照原意翻译,\u{4E0D}改成陈述句。\n\
- 转写全是 fillers(\"嗯嗯啊那个\")时,输出空字符串。\n\
\n\
# 输出\n\
只输出翻译后的正文,\u{4E0D}带 \u{300C}翻译:\u{300D}\u{300C}译文:\u{300D}\u{300C}Translation:\u{300D}之类前缀,\
\u{4E0D}加引号、\u{4E0D}加 markdown 围栏。",
lang = target_language
- 转写全是 fillers(\"嗯嗯啊那个\")时,输出空字符串。",
lang = target_language,
)
}
const COMMON_TRANSLATE_OUTPUT_INSTRUCTIONS: &str = "# 输出\n\
只输出翻译后的正文,\u{4E0D}带 \u{300C}翻译:\u{300D}\u{300C}译文:\u{300D}\u{300C}Translation:\u{300D}之类前缀,\
\u{4E0D}加引号、\u{4E0D}加 markdown 围栏。";
/// target_language 是否指向英语 —— 容忍用户在偏好里写 "English" / "english" / "美式英文" /
/// "英文" / "British English" 等几种写法。匹配松一点没坏处:误命中只会让模型走 EN 专属
/// prompt,对纯中文 / 日文等目标本来就不会被选中。
@@ -2227,7 +2252,7 @@ pub mod prompts {
/// - 比通用翻译 prompt 更窄、更强:ASR 纠错优先于逐字翻译;英文要求自然 idiomatic,
/// 不接受 Chinglish 直译。
/// - 来源:社区「重写为英文」prompt(imported.573e86a1bcf44dbb...),整合精简后注入。
const EN_TRANSLATE_SYSTEM_PROMPT: &str = "# 任务(中文转写 → 英文翻译)\n\
const EN_TRANSLATE_SYSTEM_RULES: &str = "# 任务(中文转写 → 英文翻译)\n\
你是一名中译英助手,专门处理语音识别(ASR)后的中文技术文本。\n\
用户的转写不是可靠原文:可能有错别字、同音字、近音字、断句缺失、术语误识别、\
英文术语被中文音译。**你的任务不是逐字翻译,而是先理解用户真实意图,纠正显然的识别错误,\
@@ -2240,7 +2265,6 @@ pub mod prompts {
3. 把中文音译还原为标准英文技术术语。\n\
4. 整理混乱、口语化或重复的表达。\n\
5. 在不改变用户真实意图的前提下,翻译成自然、专业的英文。\n\
6. **只输出最终英文译文**。\n\
\n\
# ASR 纠错(按置信度分级)\n\
- 高置信度(错误明显、正确写法唯一)→ 直接替换,不保留原词、不加说明。\n\
@@ -2287,13 +2311,13 @@ pub mod prompts {
\n\
# 禁止\n\
1. \u{4E0D}得逐字翻译明显错误的 ASR 文本。\n\
2. \u{4E0D}得输出中文(不要给出中文润色稿、对比表、原文回显)。\n\
3. \u{4E0D}得输出解释、修改说明、change log、思路过程。\n\
4. \u{4E0D}得为了流畅而删减重要信息,也\u{4E0D}得添加用户未表达过的新事实、链接、路径、字段、步骤。\n\
5. \u{4E0D}得改变用户真实意图。\n\
\n\
# 输出\n\
只输出最终英文译文。\u{4E0D}带 \u{300C}翻译:\u{300D}\u{300C}译文:\u{300D}\u{300C}Translation:\u{300D}\
2. \u{4E0D}得输出解释、修改说明、change log、思路过程。\n\
3. \u{4E0D}得为了流畅而删减重要信息,也\u{4E0D}得添加用户未表达过的新事实、链接、路径、字段、步骤。\n\
4. \u{4E0D}得改变用户真实意图。";
const EN_TRANSLATE_OUTPUT_INSTRUCTIONS: &str = "# 输出\n\
只输出最终英文译文。\u{4E0D}得输出中文(不要给出中文润色稿、对比表、原文回显)。\
\u{4E0D}带 \u{300C}翻译:\u{300D}\u{300C}译文:\u{300D}\u{300C}Translation:\u{300D}\
\u{4E4B}\u{7C7B}前缀,\u{4E0D}加引号、\u{4E0D}加 markdown 围栏、\u{4E0D}加代码 fence。";
}
@@ -3843,7 +3867,7 @@ mod tests {
#[test]
fn injection_defense_present_in_translate_system_prompt() {
// issue #609 F-02:翻译路径(EN 专用 / 通用 base)必须与 polish 路径一样带对抗式注入防御。
// 覆盖英文目标(走 EN_TRANSLATE_SYSTEM_PROMPT)与非英文目标(走通用 base)两条分支。
// 覆盖英文目标(走 EN_TRANSLATE_SYSTEM_RULES)与非英文目标(走通用 base)两条分支。
for target in ["English", "繁体中文", "日本語"] {
let p = prompts::translate_system_prompt(target);
assert!(
@@ -3932,7 +3956,7 @@ mod tests {
#[test]
fn translate_prompt_swaps_to_en_dedicated_when_target_is_english() {
// 英文目标:整段切到 EN_TRANSLATE_SYSTEM_PROMPT,不再带通用 base 的 \"# 任务(翻译输出)\" 标题。
// 英文目标:整段切到 EN_TRANSLATE_SYSTEM_RULES,不再带通用 base 的 \"# 任务(翻译输出)\" 标题。
let en = prompts::translate_system_prompt("English");
assert!(
en.contains("# 任务(中文转写 → 英文翻译)"),
+5
View File
@@ -629,6 +629,11 @@ export const en: typeof zhCN = {
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.',
},
style: {
title: 'Translation style',
desc: 'Automatically inherits the active style pack from the Style page.',
unavailable: 'Unavailable',
},
save: {
workingFailed: 'Failed to save working languages. Please try again.',
targetFailed: 'Failed to save translation target. Please try again.',
+5
View File
@@ -631,6 +631,11 @@ export const ja: typeof zhCN = {
disabled: '無効(Shift で翻訳を発動しない)',
sameAsWorking: 'ターゲット言語が唯一の作業言語と同じため、翻訳は発動しません(Shift を押しても通常の整文になります)。別のターゲットを選ぶか、上で作業言語を追加してください。',
},
style: {
title: '翻訳スタイル',
desc: '「スタイル」ページで現在有効なスタイルパックを自動的に引き継ぎます。',
unavailable: '取得できません',
},
save: {
workingFailed: '作業言語の保存に失敗しました。もう一度お試しください。',
targetFailed: '翻訳ターゲット言語の保存に失敗しました。もう一度お試しください。',
+5
View File
@@ -631,6 +631,11 @@ export const ko: typeof zhCN = {
disabled: '비활성화 (Shift 로 번역 발동 안 함)',
sameAsWorking: '대상 언어가 유일한 작업 언어와 같아 번역이 실행되지 않습니다. Shift 를 눌러도 일반 정리로 처리됩니다. 다른 대상 언어를 고르거나 위에서 작업 언어를 추가하세요.',
},
style: {
title: '번역 스타일',
desc: '「스타일」 페이지에서 현재 활성화된 스타일 팩을 자동으로 사용합니다.',
unavailable: '사용할 수 없음',
},
save: {
workingFailed: '작업 언어 저장에 실패했습니다. 다시 시도하세요.',
targetFailed: '번역 대상 언어 저장에 실패했습니다. 다시 시도하세요.',
+5
View File
@@ -627,6 +627,11 @@ export const zhCN = {
disabled: '不启用(Shift 按下不触发翻译)',
sameAsWorking: '目标语言与你唯一的工作语言相同,翻译不会生效:按 Shift 仍按普通润色处理。换一个目标语言,或在上方多勾选一个工作语言。',
},
style: {
title: '翻译风格',
desc: '自动继承「风格」页当前激活的风格包。',
unavailable: '暂不可用',
},
save: {
workingFailed: '工作语言保存失败,请重试。',
targetFailed: '翻译目标语言保存失败,请重试。',
+5
View File
@@ -629,6 +629,11 @@ export const zhTW: typeof zhCN = {
disabled: '不啓用(Shift 按下不觸發翻譯)',
sameAsWorking: '目標語言與你唯一的工作語言相同,翻譯不會生效:按 Shift 仍按普通潤色處理。換一個目標語言,或在上方多勾選一個工作語言。',
},
style: {
title: '翻譯風格',
desc: '自動沿用「風格」頁目前啓用的風格包。',
unavailable: '暫時無法取得',
},
save: {
workingFailed: '工作語言保存失敗,請重試。',
targetFailed: '翻譯目標語言保存失敗,請重試。',
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
import { Card, PageHeader } from './_atoms';
import { SavedToast } from '../components/SavedToast';
import { SelectLite } from '../components/ui/SelectLite';
import { listStylePacks } from '../lib/ipc';
import { SUPPORTED_LANGUAGES } from '../lib/types';
import { isTranslationEnabled, isTranslationTargetRedundant } from '../lib/translationTarget';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
@@ -22,12 +23,36 @@ export function Translation() {
const { prefs, loading, error, refresh, updatePrefs: savePrefs } = useHotkeySettings();
const [saveState, setSaveState] = useState<SaveState>('idle');
const [saveMessage, setSaveMessage] = useState('');
const [activeStylePackName, setActiveStylePackName] = useState<string | null>(null);
const [stylePackLoadFailed, setStylePackLoadFailed] = useState(false);
const statusTimer = useRef<number | null>(null);
useEffect(() => () => {
if (statusTimer.current !== null) window.clearTimeout(statusTimer.current);
}, []);
useEffect(() => {
const activeStylePackId = prefs?.activeStylePackId;
let cancelled = false;
setActiveStylePackName(null);
setStylePackLoadFailed(false);
void listStylePacks()
.then(packs => {
if (cancelled) return;
const activePack =
packs.find(pack => pack.active && pack.enabled) ??
packs.find(pack => pack.id === activeStylePackId && pack.enabled);
setActiveStylePackName(activePack?.name ?? null);
setStylePackLoadFailed(!activePack);
})
.catch(loadError => {
console.warn('[translation] failed to load active style pack', loadError);
if (!cancelled) setStylePackLoadFailed(true);
});
return () => { cancelled = true; };
}, [prefs?.activeStylePackId]);
const showSaveStatus = (state: SaveState, message: string, temporary = false) => {
if (statusTimer.current !== null) {
window.clearTimeout(statusTimer.current);
@@ -237,6 +262,46 @@ export function Translation() {
ariaLabel={t('translation.target.title')}
style={{ width: '100%', maxWidth: 360, fontSize: 13 }}
/>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
marginTop: 12,
paddingTop: 12,
borderTop: '0.5px solid var(--ol-line)',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-2)' }}>
{t('translation.style.title')}
</div>
<div style={{ marginTop: 2, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.5 }}>
{t('translation.style.desc')}
</div>
</div>
<span
role="status"
style={{
flex: '0 0 auto',
maxWidth: 180,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
padding: '3px 9px',
borderRadius: 999,
background: 'rgba(37,99,235,0.08)',
color: 'var(--ol-blue)',
fontSize: 11,
fontWeight: 600,
}}
>
{stylePackLoadFailed
? t('translation.style.unavailable')
: activeStylePackName ?? t('common.loading')}
</span>
</div>
{redundantTarget && (
<div
role="status"