From b1b64a0594840f8a582f8fa1a59c7147756a3701 Mon Sep 17 00:00:00 2001 From: lightnovel0 Date: Sat, 9 May 2026 12:33:04 +0900 Subject: [PATCH 1/3] feat(polish): cross-mode universal directives field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a single multi-line text field — `polish_universal_directives` on UserPreferences — whose contents are injected into every polish (and translate) system prompt, regardless of which polish mode the user has selected. Empty / whitespace-only values are no-ops, so the change is behavior-preserving by default. ## Why Style modes (Light / Structured / Formal) describe *voice* — how verbose, how rewriting-friendly, how formal. They do not (and should not) encode user-specific *typography* conventions, which the same user wants applied across every mode. Today a Japanese user who insists on full-width punctuation has no way to express that without forking each of the four mode prompts. Same for English users who want the Oxford comma, Chinese users who want full-width paragraph indents, etc. The dictionary covers vocabulary biasing, but not output formatting rules. There is no other prompt knob that survives a mode switch. This field fills that gap. ## What - **`types.rs`** — adds `polish_universal_directives: String` (default empty) on `UserPreferences`; mirrored on `UserPreferencesWire` with `#[serde(default)]` so older preferences.json deserializes cleanly. No migration needed. - **`polish.rs`** — `compose_system_prompt` takes a third arg `universal_directives: &str`. When non-blank, it is appended after the mode prompt and before the hotword block, with a Chinese-language section header consistent with the existing hotword block ("通用规则 (不论 polish mode 如何,始终遵守,与上述模式指示并存)"). The order matters: mode prompt establishes voice → universal rules layer typography on top → hotwords are last (most specific, easiest to attend to). - **`polish.rs`** — `polish()` and `translate_to()` both accept the new arg. Translation gets it too because the user wants their typography conventions in translated output as well, not just polished input. - **`coordinator.rs`** — `polish_or_passthrough`, `polish_text`, `translate_or_passthrough`, `translate_text`, and the `repolish` command all thread the directive through. Read once from `prefs.polish_universal_directives` at session start; cloned String passed by ref through the chain. - **`commands.rs`** — `validate_llm_provider` (the "test the LLM endpoint" call) passes an empty string; the validator does not need user typography rules to verify connectivity. - **Frontend (`Style.tsx`)** — multi-line textarea added at the top of the Style page, above the existing 4-mode grid. Persists on `blur` to avoid 1 IPC per keystroke. Shares the same save-error / rollback path as the master toggle. - **Types & i18n** — `UserPreferences` TS type, `defaultUserPrefs` fixtures (ipc.ts, stylePrefs.test.ts), and 5 locales (ja / en / ko / zh-CN / zh-TW) with label, description, and a placeholder example appropriate to that language's typography concerns. ## Tests Four new tests in `polish::tests`: - empty / blank directives produce no `通用规则` block (legacy parity) - non-empty directives append the section, content reproduced verbatim - order contract: directives appear before hotwords in the composed prompt (`directives_pos < hotwords_pos`) - leading / trailing whitespace is trimmed without dropping interior newlines The pre-existing `compose_system_prompt_prefers_correct_spelling_for_hotwords` test is updated to pass `""` for the new arg, exercising the no-op path. ## Out of scope - Raw mode bypasses the LLM entirely, so directives have no effect there (documented in the field doc-comment and in the i18n description). - QA chat (`answer_chat_streaming`) and other non-polish/non-translate LLM paths are intentionally untouched — those are conversational, not output-formatting, surfaces. --- openless-all/app/src-tauri/src/commands.rs | 1 + openless-all/app/src-tauri/src/coordinator.rs | 17 ++++ openless-all/app/src-tauri/src/polish.rs | 99 +++++++++++++++++-- openless-all/app/src-tauri/src/types.rs | 14 +++ openless-all/app/src/i18n/en.ts | 3 + openless-all/app/src/i18n/ja.ts | 3 + openless-all/app/src/i18n/ko.ts | 3 + openless-all/app/src/i18n/zh-CN.ts | 3 + openless-all/app/src/i18n/zh-TW.ts | 3 + openless-all/app/src/lib/ipc.ts | 1 + openless-all/app/src/lib/stylePrefs.test.ts | 1 + openless-all/app/src/lib/types.ts | 4 + openless-all/app/src/pages/Style.tsx | 60 +++++++++++ 13 files changed, 206 insertions(+), 6 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index c82ca838..7a097d0d 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -585,6 +585,7 @@ async fn validate_llm_provider() -> Result<(), String> { "验证连接", PolishMode::Raw, &[], + "", &[], ChineseScriptPreference::Auto, OutputLanguagePreference::Auto, diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index fd85a4cf..a438ca87 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -842,6 +842,7 @@ impl Coordinator { pub async fn repolish(&self, raw_text: String, mode: PolishMode) -> Result { let hotwords = enabled_phrases(&self.inner); let prefs = self.inner.prefs.get(); + let universal_directives = prefs.polish_universal_directives.clone(); let working_languages = prefs.working_languages; let chinese_script_preference = prefs.chinese_script_preference; let output_language_preference = prefs.output_language_preference; @@ -854,6 +855,7 @@ impl Coordinator { &raw_text, mode, &hotwords, + &universal_directives, &working_languages, chinese_script_preference, output_language_preference, @@ -2703,6 +2705,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { let prefs = inner.prefs.get(); let mode = prefs.default_mode; let hotword_strs = enabled_phrases(inner); + let universal_directives = prefs.polish_universal_directives.clone(); let working_languages = prefs.working_languages.clone(); let chinese_script_preference = prefs.chinese_script_preference; let output_language_preference = prefs.output_language_preference; @@ -2747,6 +2750,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { translate_or_passthrough( &raw, &translation_target, + &universal_directives, &working_languages, chinese_script_preference, output_language_preference, @@ -2758,6 +2762,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { &raw, mode, &hotword_strs, + &universal_directives, &working_languages, chinese_script_preference, output_language_preference, @@ -3355,10 +3360,12 @@ fn ensure_qa_volcengine_credentials() -> Result<(), String> { /// 润色文本;失败时返回原文 + 失败原因,调用方据此弹错误胶囊 + 写历史 error_code。 /// 之前固定返回 String,调用方拿不到失败信号 → 用户感知"为什么风格设置没生效"。issue #57。 +#[allow(clippy::too_many_arguments)] async fn polish_or_passthrough( raw: &RawTranscript, mode: PolishMode, hotwords: &[String], + universal_directives: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, @@ -3372,6 +3379,7 @@ async fn polish_or_passthrough( &raw.text, mode, hotwords, + universal_directives, working_languages, chinese_script_preference, output_language_preference, @@ -3389,10 +3397,12 @@ async fn polish_or_passthrough( } } +#[allow(clippy::too_many_arguments)] async fn polish_text( raw: &str, mode: PolishMode, hotwords: &[String], + universal_directives: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, @@ -3416,6 +3426,7 @@ async fn polish_text( raw, mode, hotwords, + universal_directives, working_languages, chinese_script_preference, output_language_preference, @@ -3426,9 +3437,11 @@ async fn polish_text( } /// 翻译路径——和 polish 一样失败时返回原文 + 失败原因,避免"不丢字"约定被违反(CLAUDE.md)。 +#[allow(clippy::too_many_arguments)] async fn translate_or_passthrough( raw: &RawTranscript, target_language: &str, + universal_directives: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, @@ -3437,6 +3450,7 @@ async fn translate_or_passthrough( match translate_text( &raw.text, target_language, + universal_directives, working_languages, chinese_script_preference, output_language_preference, @@ -3453,9 +3467,11 @@ async fn translate_or_passthrough( } } +#[allow(clippy::too_many_arguments)] async fn translate_text( raw: &str, target_language: &str, + universal_directives: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, @@ -3477,6 +3493,7 @@ async fn translate_text( .translate_to( raw, target_language, + universal_directives, working_languages, chinese_script_preference, output_language_preference, diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 296358a7..d5b2dc98 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -84,18 +84,20 @@ impl OpenAICompatibleLLMProvider { &self.config } + #[allow(clippy::too_many_arguments)] pub async fn polish( &self, raw_text: &str, mode: PolishMode, hotwords: &[String], + universal_directives: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, front_app: Option<&str>, prior_turns: &[(String, String)], ) -> Result { - let mut system_prompt = compose_system_prompt(mode, hotwords); + let mut system_prompt = compose_system_prompt(mode, hotwords, universal_directives); if let Some(premise) = context_premise( working_languages, chinese_script_preference, @@ -155,10 +157,12 @@ impl OpenAICompatibleLLMProvider { /// 把转写翻译成 `target_language`(前端从内置语言列表里选出来的原生名)。 /// `working_languages` 与 `front_app` 作为前提注入头部。详见 issue #4 与 #116。 + #[allow(clippy::too_many_arguments)] pub async fn translate_to( &self, raw_text: &str, target_language: &str, + universal_directives: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, _output_language_preference: OutputLanguagePreference, @@ -167,6 +171,15 @@ impl OpenAICompatibleLLMProvider { let mut system_prompt = prompts::translate_system_prompt(target_language); // 翻译模式必须以 target_language 为唯一输出语言约束,避免和 UI 驱动的 // output_language_preference 发生冲突(例如 UI=ja, target=en)。 + // Universal directives(タイポグラフィ規約等)も翻訳出力に適用する: + // 翻訳された日本語にも「句読点は全角」等のルールを効かせるため。 + let trimmed_directives = universal_directives.trim(); + if !trimmed_directives.is_empty() { + system_prompt = format!( + "{}\n\n通用规则(不论 polish/translate mode 如何,始终遵守):\n{}", + system_prompt, trimmed_directives + ); + } if let Some(premise) = context_premise( working_languages, chinese_script_preference, @@ -560,15 +573,31 @@ fn context_premise( Some(lines.join("\n")) } -fn compose_system_prompt(mode: PolishMode, hotwords: &[String]) -> String { +fn compose_system_prompt( + mode: PolishMode, + hotwords: &[String], + universal_directives: &str, +) -> String { let base = prompts::system_prompt(mode); + // Universal directives are appended after the mode-specific instructions + // and before the hotword block. Order matters: mode prompt establishes the + // overall voice, then user-defined cross-mode rules (e.g. typography + // conventions) layer on top, and finally the hotword glossary. + let mut composed = base; + let trimmed_directives = universal_directives.trim(); + if !trimmed_directives.is_empty() { + composed = format!( + "{}\n\n通用规则(不论 polish mode 如何,始终遵守,与上述模式指示并存):\n{}", + composed, trimmed_directives + ); + } let cleaned: Vec = hotwords .iter() .map(|h| h.trim().to_string()) .filter(|h| !h.is_empty()) .collect(); if cleaned.is_empty() { - return base; + return composed; } let bullets = cleaned .iter() @@ -577,7 +606,7 @@ fn compose_system_prompt(mode: PolishMode, hotwords: &[String]) -> String { .join("\n"); format!( "{}\n\n热词(用户希望以下写法在输出中保持准确;当转写中出现这些词的同音 / 近形误识别时,优先按上述写法输出,不做无关词的机械替换):\n{}", - base, bullets + composed, bullets ) } @@ -1258,8 +1287,11 @@ mod tests { #[test] fn compose_system_prompt_prefers_correct_spelling_for_hotwords() { - let prompt = - compose_system_prompt(PolishMode::Light, &["GitHub".into(), "OpenLess".into()]); + let prompt = compose_system_prompt( + PolishMode::Light, + &["GitHub".into(), "OpenLess".into()], + "", + ); assert!(prompt.contains("用户希望以下写法在输出中保持准确")); assert!(prompt.contains("同音 / 近形误识别时,优先按上述写法输出")); @@ -1267,6 +1299,61 @@ mod tests { assert!(prompt.contains("- OpenLess")); } + #[test] + fn compose_system_prompt_no_universal_directives_keeps_legacy_output() { + // 空文字 = 既存挙動と完全一致:通用规则ブロックは出ない + let with_empty = compose_system_prompt(PolishMode::Light, &[], ""); + let with_blank = compose_system_prompt(PolishMode::Light, &[], " \n\t "); + assert!(!with_empty.contains("通用规则")); + assert!(!with_blank.contains("通用规则")); + assert_eq!(with_empty, with_blank); + } + + #[test] + fn compose_system_prompt_appends_universal_directives_when_present() { + let directives = "句読点は全角(、。)を使う\n!や?の後に全角スペースを1つ入れる"; + let prompt = compose_system_prompt(PolishMode::Light, &[], directives); + assert!( + prompt.contains("通用规则"), + "universal directive header should appear" + ); + assert!(prompt.contains("句読点は全角")); + assert!(prompt.contains("全角スペースを1つ")); + } + + #[test] + fn compose_system_prompt_orders_directives_before_hotwords() { + // 注入順序の契約:mode prompt → 通用规则 → 热词。LLM が後半の指示を + // 優先的に重視する性質上、辞書(最も具体)を末尾に配置する。 + let directives = "句読点は全角を使う"; + let prompt = compose_system_prompt( + PolishMode::Light, + &["梁山泊".into()], + directives, + ); + let directives_pos = prompt.find("通用规则").expect("directive header present"); + let hotwords_pos = prompt.find("热词").expect("hotword header present"); + assert!( + directives_pos < hotwords_pos, + "通用规则 must come before 热词 (directives at {directives_pos}, hotwords at {hotwords_pos})" + ); + } + + #[test] + fn compose_system_prompt_trims_universal_directives() { + // 前後空白は除去して整形する(行内の改行は保持) + let prompt = compose_system_prompt( + PolishMode::Light, + &[], + " \n句読点は全角\nビックリマーク後に全角スペース\n ", + ); + // 整形後は trim 済みの本文だけが現れる + assert!(prompt.contains("句読点は全角")); + assert!(prompt.contains("ビックリマーク後に全角スペース")); + // 終端側の余計な空白が残っていない(次の段落に影響しない) + assert!(!prompt.ends_with(" ")); + } + #[test] fn common_rules_include_auto_correction_and_natural_organization() { // 所有 mode 都要带上"自动纠错"(规则 5)和"按整体意图组织成自然书面表达" diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index b2578117..139e82a8 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -226,6 +226,15 @@ pub struct UserPreferences { /// 0 = 关闭(每次润色独立单轮,跟历史行为一致)。默认 5 分钟。 #[serde(default = "default_polish_context_window_minutes")] pub polish_context_window_minutes: u32, + /// モード横断で常時適用される追加指示(タイポグラフィ規約等)。 + /// polish system prompt の base prompt 直後・hotwords 直前に注入される。 + /// 例:日本語ユーザーが「句読点は全角を使う」「!や?の後に全角スペース」と + /// 入れておくと、Light/Formal/Structured どのモードでも反映される。 + /// 翻訳パスにも適用:translate_to() が出力する翻訳文も同じ規約に従う。 + /// 空文字 = 何も注入しない(既存挙動と完全一致、回帰なし)。 + /// Raw モードは LLM を経由しないため対象外。 + #[serde(default)] + pub polish_universal_directives: String, /// 启动时静默运行(不弹主窗口)。开机自启用户用得多——本来想看托盘 /// 而不是被主窗口打扰。开关一开后所有启动路径都不弹窗(包括手动点击), /// 用户改用托盘菜单访问主窗口。默认 false 跟历史行为一致。 @@ -321,6 +330,8 @@ struct UserPreferencesWire { #[serde(default = "default_polish_context_window_minutes")] polish_context_window_minutes: u32, #[serde(default)] + polish_universal_directives: String, + #[serde(default)] start_minimized: bool, } @@ -360,6 +371,7 @@ impl Default for UserPreferencesWire { update_channel: prefs.update_channel, history_retention_days: prefs.history_retention_days, polish_context_window_minutes: prefs.polish_context_window_minutes, + polish_universal_directives: prefs.polish_universal_directives, start_minimized: prefs.start_minimized, } } @@ -416,6 +428,7 @@ impl<'de> Deserialize<'de> for UserPreferences { update_channel: wire.update_channel, history_retention_days: wire.history_retention_days, polish_context_window_minutes: wire.polish_context_window_minutes, + polish_universal_directives: wire.polish_universal_directives, start_minimized: wire.start_minimized, }) } @@ -525,6 +538,7 @@ impl Default for UserPreferences { update_channel: UpdateChannel::default(), history_retention_days: default_history_retention_days(), polish_context_window_minutes: default_polish_context_window_minutes(), + polish_universal_directives: String::new(), start_minimized: false, } } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 17feb523..24066a62 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -198,6 +198,9 @@ export const en: typeof zhCN = { currentDefault: 'Current default', ariaSetDefault: 'Set as default', saveFailed: 'Save failed: {{error}}', + universalDirectivesLabel: 'Universal directives (all modes)', + universalDirectivesDesc: 'Extra instructions that always apply during polish, regardless of which mode is selected. Use this for typography or stylebook rules — they layer on top of Light / Structured / Formal. Translation output respects them too. Raw mode skips the LLM, so it is unaffected. Leave empty for legacy behavior.', + universalDirectivesPlaceholder: 'e.g.\n- Use the Oxford comma\n- Wrap proper nouns in their official capitalization', modes: { raw: { name: 'Raw', desc: 'Only adds punctuation and natural breaks — no rewriting or expansion.', sample: "Keeps spoken cadence; fillers like 'um' or 'you know' get dropped, but sentences stay intact." }, light: { name: 'Light polish', desc: 'Drops fillers, adds punctuation, and produces sendable natural prose.', sample: "Makes the transcript flow well without sounding scripted — your tone and habits remain." }, diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index b5b3ec28..7bdc00b7 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -200,6 +200,9 @@ export const ja: typeof zhCN = { currentDefault: '現在のデフォルト', ariaSetDefault: 'デフォルトに設定', saveFailed: '保存に失敗しました: {{error}}', + universalDirectivesLabel: '全モード共通の指示', + universalDirectivesDesc: 'どのスタイルを選んでも整文時に必ず適用される追加指示。タイポグラフィ規約や文体ルールをここに書いておくと、Light/Structured/Formal のすべてに反映されます。翻訳出力にも適用。Raw モードは LLM を経由しないため対象外。空欄なら何も追加されません(既存挙動と完全一致)。', + universalDirectivesPlaceholder: '例:\n- 句読点は全角(、。)を使う\n- !や?の後に全角スペースを1つ入れる', modes: { raw: { name: '原文', desc: '句読点と必要な区切りのみ補い、書き換えや拡張はしません。', sample: '元の話し言葉を保持。「えー」「あの」などの口癖は除去しますが、文の組み替えはしません。' }, light: { name: '軽い整文', desc: '口癖の除去、句読点の補完、自然な送信可能テキストへの整理。', sample: '原稿読み上げのようにならないよう、語気と表現の癖を残しつつ、文章をなめらかにします。' }, diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 9518e2af..41f9a2a2 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -200,6 +200,9 @@ export const ko: typeof zhCN = { currentDefault: '현재 기본', ariaSetDefault: '기본으로 설정', saveFailed: '저장 실패: {{error}}', + universalDirectivesLabel: '모든 모드에 적용되는 공통 지시', + universalDirectivesDesc: '어떤 스타일을 선택하든 정리(polish) 시에 항상 적용되는 추가 지시입니다. 타이포그래피 규약이나 문체 규칙을 여기에 적어두면 Light / Structured / Formal 모두에 반영됩니다. 번역 출력에도 적용됩니다. Raw 모드는 LLM 을 거치지 않아 대상이 아닙니다. 비워두면 기존 동작과 완전히 일치합니다.', + universalDirectivesPlaceholder: '예:\n- 문장 부호는 전각을 사용\n- 느낌표나 물음표 뒤에 전각 공백을 하나 둠', modes: { raw: { name: '원문', desc: '구두점과 필요한 문장 구분만 보충하고 다시 쓰거나 확장하지 않습니다.', sample: '원래 구어체 유지. "음", "그게" 같은 입버릇은 제거하지만 문장을 재구성하지 않습니다.' }, light: { name: '가벼운 정리', desc: '입버릇 제거, 구두점 보충, 자연스럽게 보낼 수 있는 텍스트로 정리합니다.', sample: '원고를 읽는 듯한 느낌이 들지 않도록 어조와 표현 습관은 남기되, 문장이 매끄럽게 흐르도록 합니다.' }, diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 265449a2..76d44c27 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -196,6 +196,9 @@ export const zhCN = { currentDefault: '当前默认', ariaSetDefault: '设为默认', saveFailed: '保存失败:{{error}}', + universalDirectivesLabel: '所有模式通用的指令', + universalDirectivesDesc: '不论选择哪种 polish 模式都始终生效的额外指令。把排版规约、文风约束写在这里,Light / Structured / Formal 都会带上。翻译输出同样遵循。Raw 模式不经过 LLM,因此不受影响。留空 = 与旧版完全一致。', + universalDirectivesPlaceholder: '示例:\n- 中文段首使用全角缩进\n- 引号统一使用「」/『』', modes: { raw: { name: '原文', desc: '只补标点和必要分句,不改写不扩写。', sample: '保留原始口语;嗯、那个等口癖会被去除,但不会重组语句。' }, light: { name: '轻度润色', desc: '去口癖、补标点,整理为可发送的自然文字。', sample: '让转写听起来不像念稿——保留语气和表达习惯,但行文流畅。' }, diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 21dc2d80..405baf50 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -198,6 +198,9 @@ export const zhTW: typeof zhCN = { currentDefault: '當前默認', ariaSetDefault: '設爲默認', saveFailed: '保存失敗:{{error}}', + universalDirectivesLabel: '所有模式通用的指令', + universalDirectivesDesc: '不論選擇哪種 polish 模式都始終生效的額外指令。把排版規約、文風約束寫在這裡,Light / Structured / Formal 都會帶上。翻譯輸出同樣遵循。Raw 模式不經過 LLM,因此不受影響。留空 = 與舊版完全一致。', + universalDirectivesPlaceholder: '示例:\n- 中文段首使用全形縮進\n- 引號統一使用「」/『』', modes: { raw: { name: '原文', desc: '只補標點和必要分句,不改寫不擴寫。', sample: '保留原始口語;嗯、那個等口癖會被去除,但不會重組語句。' }, light: { name: '輕度潤色', desc: '去口癖、補標點,整理爲可發送的自然文字。', sample: '讓轉寫聽起來不像念稿——保留語氣和表達習慣,但行文流暢。' }, diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 96adf2e7..75a989f3 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -75,6 +75,7 @@ const mockSettings: UserPreferences = { foundryLocalAsrKeepLoadedSecs: 300, historyRetentionDays: 7, polishContextWindowMinutes: 5, + polishUniversalDirectives: '', startMinimized: false, }; diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 262e31a3..82d88e7f 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -42,6 +42,7 @@ const previousPrefs: UserPreferences = { foundryLocalAsrKeepLoadedSecs: 300, historyRetentionDays: 7, polishContextWindowMinutes: 5, + polishUniversalDirectives: '', startMinimized: false, }; diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index b946ff0e..0e3a24cd 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -171,6 +171,10 @@ export interface UserPreferences { historyRetentionDays: number; /** 对话感知 polish 上下文窗口(分钟)。0 = 关闭。默认 5。详见 PR-A。 */ polishContextWindowMinutes: number; + /** モード横断で常時適用される追加指示(タイポグラフィ規約等)。空文字 = 何も注入しない。 + * Style ページの textarea から編集;polish/translate の system prompt に注入される。 + * Raw モードは LLM を経由しないため対象外。 */ + polishUniversalDirectives: string; /** 启动时静默运行(不弹主窗口)。Windows 开机自启场景常用——只想要后台 + 托盘, * 不想被主窗口打扰。开后所有启动路径都不弹窗,从菜单栏 / 托盘进入主窗口。默认 false。 */ startMinimized: boolean; diff --git a/openless-all/app/src/pages/Style.tsx b/openless-all/app/src/pages/Style.tsx index 6e868aa5..821a16f6 100644 --- a/openless-all/app/src/pages/Style.tsx +++ b/openless-all/app/src/pages/Style.tsx @@ -102,6 +102,28 @@ export function Style() { if (saved) setSaveError(null); }; + // Universal directives: persist to backend on blur (avoid 1 IPC per keystroke). + // Local state mirrors `prefs.polishUniversalDirectives` so typing feels instant; + // we commit on blur via setSettings() and rely on prefs:changed to re-sync. + const [universalDraft, setUniversalDraft] = useState(''); + useEffect(() => { + if (prefs) setUniversalDraft(prefs.polishUniversalDirectives ?? ''); + }, [prefs?.polishUniversalDirectives]); + const onCommitUniversalDirectives = async () => { + if (!prefs) return; + const trimmed = universalDraft; + if ((prefs.polishUniversalDirectives ?? '') === trimmed) return; + const next = { ...prefs, polishUniversalDirectives: trimmed }; + const saved = await persistStylePreferenceChange( + next, + () => setSettings(next), + setPrefs, + error => showSaveError('master', error), + rollbackWholeStylePreferences(prefs, next), + ); + if (saved) setSaveError(null); + }; + if (!prefs) { return ( } /> +
+
+ {t('style.universalDirectivesLabel')} +
+
+ {t('style.universalDirectivesDesc')} +
+