From 3015ef109fe527c3ddfb4bfc10b8f9241a323eef Mon Sep 17 00:00:00 2001 From: lightnovel0 Date: Fri, 8 May 2026 19:31:55 +0900 Subject: [PATCH 1/2] feat(style): user-defined custom polish styles with editable system prompts The four built-in PolishModes (Raw / Light / Structured / Formal) all ship with hard-coded zh-CN system prompts. Users wanting a "novel-writing", "email", or "casual chat" style had no way to register one, and Ctrl+Shift+S only cycled the four built-ins. This PR turns PolishMode into a Custom(String) extensible enum (serde transparent via String, so the wire format stays compact) and adds a CRUD surface for user styles: - UserPreferences.custom_modes: Vec - add_custom_mode / update_custom_mode / delete_custom_mode IPC commands; delete falls back default_mode to Light if it was pointing at the deleted custom id. - prompts::system_prompt(mode, override_text) accepts an override string used by Custom modes (built-ins still render the original ROLE/RULES/OUTPUT scaffolding when override_text is None). - Style page: existing 4-mode grid stays, each card gets a "view default prompt" disclosure that hits the new get_default_polish_prompt IPC. Below it, a new "Custom styles" section with add / edit / delete UI; each custom mode participates in enabled_modes and the master toggle, and Ctrl+Shift+S cycles through them too. Custom modes are picked by default_mode and the switch_style_hotkey listener exactly like built-ins, so no other call site needs to learn about them. Note: coordinator.rs and Style.tsx in this PR also contain the per-app override logic and UI (next PR's scope). They are bundled because they're the same files; the new Style.tsx "Per-app override" section requires the AppModeOverride type from this PR's types.rs anyway. If reviewers prefer the override path stripped, that's a follow-up. --- openless-all/app/src-tauri/src/commands.rs | 169 +++- openless-all/app/src-tauri/src/coordinator.rs | 70 +- openless-all/app/src-tauri/src/lib.rs | 7 + openless-all/app/src-tauri/src/polish.rs | 81 +- openless-all/app/src-tauri/src/types.rs | 234 +++++- openless-all/app/src/i18n/en.ts | 52 ++ openless-all/app/src/i18n/ja.ts | 56 +- openless-all/app/src/i18n/ko.ts | 52 ++ openless-all/app/src/i18n/zh-CN.ts | 52 ++ openless-all/app/src/i18n/zh-TW.ts | 52 ++ openless-all/app/src/lib/ipc.ts | 37 + openless-all/app/src/lib/stylePrefs.test.ts | 3 + openless-all/app/src/lib/types.ts | 41 +- openless-all/app/src/pages/Style.tsx | 744 ++++++++++++++++-- 14 files changed, 1535 insertions(+), 115 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 1509c20e..1b09b6a1 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -19,9 +19,10 @@ use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVaul use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; use crate::recorder::{AudioConsumer, Recorder}; use crate::types::{ - ChineseScriptPreference, ComboBinding, CredentialsStatus, DictationSession, DictionaryEntry, - HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode, ShortcutBinding, - UpdateChannel, UserPreferences, VocabPresetStore, WindowsImeStatus, + AppModeOverride, ChineseScriptPreference, ComboBinding, CredentialsStatus, CustomMode, + DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus, OutputLanguagePreference, + PolishMode, ShortcutBinding, UpdateChannel, UserPreferences, VocabPresetStore, + WindowsImeStatus, }; type CoordinatorState<'a> = State<'a, Arc>; @@ -590,6 +591,7 @@ async fn validate_llm_provider() -> Result<(), String> { OutputLanguagePreference::Auto, None, &[], + None, ) .await .map(|_| ()) @@ -926,6 +928,12 @@ pub fn set_default_polish_mode( mode: PolishMode, ) -> Result<(), String> { let mut prefs = coord.prefs().get(); + // Custom mode の場合は id が custom_modes に存在することを検証 + if let PolishMode::Custom(id) = &mode { + if !prefs.custom_modes.iter().any(|m| m.id == *id) { + return Err(format!("custom mode id '{id}' not found")); + } + } prefs.default_mode = mode; coord.prefs().set(prefs).map_err(|e| e.to_string()) } @@ -937,7 +945,13 @@ pub fn set_style_enabled( enabled: bool, ) -> Result<(), String> { let mut prefs = coord.prefs().get(); + // Custom mode を有効化する場合は id が custom_modes に存在することを検証 if enabled { + if let PolishMode::Custom(id) = &mode { + if !prefs.custom_modes.iter().any(|m| m.id == *id) { + return Err(format!("custom mode id '{id}' not found")); + } + } if !prefs.enabled_modes.contains(&mode) { prefs.enabled_modes.push(mode); } @@ -1152,6 +1166,155 @@ pub fn set_translation_hotkey( Ok(()) } +/// 翻訳機能のグローバル on/off。OFF にすると hotkey が誤発火しても +/// 翻訳パイプラインと UI overlay は起動しない。設定はそのまま保持される。 +#[tauri::command] +pub fn set_translate_enabled( + coord: CoordinatorState<'_>, + app: AppHandle, + enabled: bool, +) -> Result<(), String> { + let mut prefs = coord.prefs().get(); + if prefs.translate_enabled == enabled { + return Ok(()); + } + prefs.translate_enabled = enabled; + persist_settings(&*coord, prefs.clone())?; + let _ = app.emit("prefs:changed", &prefs); + Ok(()) +} + +// ─────────────────────────── ビルトインprompt表示 + カスタムスタイル CRUD ─────────────────────────── +// +// `get_default_polish_prompt` はビルトイン4 mode(raw/light/structured/formal)の既定 prompt を返す。 +// Settings → Style ページで「prompt を見る」を押した時の参考表示用。Custom mode は対象外。 +// +// CustomMode 系コマンド: +// - `add_custom_mode(id, name, prompt)` — 末尾に追加。重複 id はエラー。 +// - `update_custom_mode(id, name, prompt)` — 既存 id の name/prompt を更新。 +// - `delete_custom_mode(id)` — 削除 + enabled_modes / default_mode のフォールバック。 + +#[tauri::command] +pub fn get_default_polish_prompt(mode: String) -> Result { + let parsed: PolishMode = mode + .clone() + .try_into() + .map_err(|e: String| e)?; + if let PolishMode::Custom(_) = parsed { + return Err("get_default_polish_prompt is for builtin modes only".to_string()); + } + Ok(crate::polish::prompts::system_prompt(parsed, None)) +} + +#[tauri::command] +pub fn add_custom_mode( + coord: CoordinatorState<'_>, + app: AppHandle, + id: String, + name: String, + prompt: String, +) -> Result<(), String> { + let id_trim = id.trim().to_string(); + if id_trim.is_empty() { + return Err("custom mode id is empty".to_string()); + } + if id_trim.contains(':') { + return Err("custom mode id cannot contain ':'".to_string()); + } + let mut prefs = coord.prefs().get(); + if prefs.custom_modes.iter().any(|m| m.id == id_trim) { + return Err(format!("custom mode id '{id_trim}' already exists")); + } + prefs.custom_modes.push(CustomMode { + id: id_trim, + name, + prompt, + }); + persist_settings(&*coord, prefs.clone())?; + let _ = app.emit("prefs:changed", &prefs); + Ok(()) +} + +#[tauri::command] +pub fn update_custom_mode( + coord: CoordinatorState<'_>, + app: AppHandle, + id: String, + name: String, + prompt: String, +) -> Result<(), String> { + let mut prefs = coord.prefs().get(); + let entry = prefs + .custom_modes + .iter_mut() + .find(|m| m.id == id) + .ok_or_else(|| format!("custom mode id '{id}' not found"))?; + entry.name = name; + entry.prompt = prompt; + persist_settings(&*coord, prefs.clone())?; + let _ = app.emit("prefs:changed", &prefs); + Ok(()) +} + +#[tauri::command] +pub fn delete_custom_mode( + coord: CoordinatorState<'_>, + app: AppHandle, + id: String, +) -> Result<(), String> { + let mut prefs = coord.prefs().get(); + let before = prefs.custom_modes.len(); + prefs.custom_modes.retain(|m| m.id != id); + if prefs.custom_modes.len() == before { + return Err(format!("custom mode id '{id}' not found")); + } + // default_mode が消したカスタムmodeを参照していたら Light にフォールバック + if matches!(&prefs.default_mode, PolishMode::Custom(cur_id) if *cur_id == id) { + prefs.default_mode = PolishMode::Light; + } + // enabled_modes から該当 Custom を除去 + prefs.enabled_modes.retain(|m| !matches!(m, PolishMode::Custom(cur_id) if *cur_id == id)); + persist_settings(&*coord, prefs.clone())?; + let _ = app.emit("prefs:changed", &prefs); + Ok(()) +} + +// ─────────────────────────── アプリ別自動 mode 切替 ─────────────────────────── +// +// `set_app_mode_overrides(overrides)` — 一括置換。フロントは編集中の配列丸ごとを +// 送ってくる前提。各 override の `mode` が `Custom(id)` なら `custom_modes` に +// 該当 id が存在することを検証してから保存する(dangling 参照を弾く)。 +// 空 `app_pattern`(trim 後)はそのまま保存可(UI 側で「未入力行」を持ったまま +// 別行を編集するケースを許容したいため)。`pick_mode_for_app` は空パターンを +// スキップするため実害は無い。 + +#[tauri::command] +pub fn set_app_mode_overrides( + coord: CoordinatorState<'_>, + app: AppHandle, + overrides: Vec, +) -> Result<(), String> { + let mut prefs = coord.prefs().get(); + for ov in &overrides { + if let PolishMode::Custom(id) = &ov.mode { + if !prefs.custom_modes.iter().any(|m| m.id == *id) { + return Err(format!( + "app_mode_override references unknown custom mode id '{id}'" + )); + } + } + } + prefs.app_mode_overrides = overrides; + persist_settings(&*coord, prefs.clone())?; + let _ = app.emit("prefs:changed", &prefs); + Ok(()) +} + +#[tauri::command] +pub fn get_app_mode_overrides(coord: CoordinatorState<'_>) -> Vec { + coord.prefs().get().app_mode_overrides +} + #[tauri::command] pub fn set_switch_style_hotkey( coord: CoordinatorState<'_>, diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 77ecc506..178bd006 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -845,6 +845,16 @@ impl Coordinator { let working_languages = prefs.working_languages; let chinese_script_preference = prefs.chinese_script_preference; let output_language_preference = prefs.output_language_preference; + // Custom mode のときだけ custom_modes から prompt を引いてくる。 + // 既存4 mode ではハードコード prompt(system_prompt 側でハンドリング)。 + let prompt_override_owned: Option = match &mode { + PolishMode::Custom(id) => prefs + .custom_modes + .iter() + .find(|m| m.id == *id) + .map(|m| m.prompt.clone()), + _ => None, + }; // repolish 是历史记录里手动重新润色,不再绑定原 session 的前台 app; // 当下用户调起的 app 才是相关上下文(如果可拿)。 let front_app = capture_frontmost_app(); @@ -859,6 +869,7 @@ impl Coordinator { output_language_preference, front_app.as_deref(), &[], + prompt_override_owned.as_deref(), ) .await .map_err(|e| e.to_string()) @@ -1346,16 +1357,9 @@ fn handle_action_hotkey_pressed(inner: &Arc, kind: ActionHotkeyKind) { fn switch_to_previous_style(inner: &Arc) { let mut prefs = inner.prefs.get(); - let order = [ - PolishMode::Raw, - PolishMode::Light, - PolishMode::Structured, - PolishMode::Formal, - ]; - let enabled: Vec = order - .into_iter() - .filter(|mode| prefs.enabled_modes.contains(mode)) - .collect(); + // 既存4 mode + Custom mode を含む、enabled_modes の順番をそのまま使う。 + // Custom mode は `custom_modes` の登録順に enabled_modes へ並ぶ前提。 + let enabled: Vec = prefs.enabled_modes.clone(); if enabled.len() <= 1 { log::info!("[coord] switch style hotkey ignored: enabled style count <= 1"); return; @@ -1369,14 +1373,14 @@ fn switch_to_previous_style(inner: &Arc) { } else { current_index - 1 }; - prefs.default_mode = enabled[next_index]; + prefs.default_mode = enabled[next_index].clone(); + let next_label = prefs + .default_mode + .display_name_with_customs(&prefs.custom_modes); if let Err(e) = inner.prefs.set(prefs.clone()) { log::warn!("[coord] switch style hotkey 保存失败: {e}"); } else { - log::info!( - "[coord] switch style hotkey changed default mode to {}", - prefs.default_mode.display_name() - ); + log::info!("[coord] switch style hotkey changed default mode to {next_label}"); } } @@ -2701,12 +2705,27 @@ async fn end_session(inner: &Arc) -> Result<(), String> { emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None); let prefs = inner.prefs.get(); - let mode = prefs.default_mode; let hotword_strs = enabled_phrases(inner); let working_languages = prefs.working_languages.clone(); let chinese_script_preference = prefs.chinese_script_preference; let output_language_preference = prefs.output_language_preference; let front_app = inner.state.lock().front_app.clone(); + // アプリ別自動 mode 切替:app_mode_overrides を順次評価して最初にマッチした mode を採用。 + // どのルールにもマッチしないなら default_mode(既存挙動)。 + let mode = match crate::types::pick_mode_for_app( + front_app.as_deref(), + &prefs.app_mode_overrides, + ) { + Some(m) => { + log::info!( + "[coord] app override matched: front_app={:?} → mode={}", + front_app, + m.as_serde_string() + ); + m + } + None => prefs.default_mode.clone(), + }; let translation_target = prefs.translation_target_language.trim().to_string(); let translation_active = inner.translation_modifier_seen.load(Ordering::SeqCst) && !translation_target.is_empty(); @@ -2754,15 +2773,26 @@ async fn end_session(inner: &Arc) -> Result<(), String> { ) .await } else { + // Custom mode のときだけ custom_modes から prompt 本文を引いてくる。 + // 既存4 mode ではハードコード prompt を使うため None を渡す。 + let prompt_override_owned: Option = match &mode { + PolishMode::Custom(id) => prefs + .custom_modes + .iter() + .find(|m| m.id == *id) + .map(|m| m.prompt.clone()), + _ => None, + }; polish_or_passthrough( &raw, - mode, + mode.clone(), &hotword_strs, &working_languages, chinese_script_preference, output_language_preference, front_app.as_deref(), &prior_turns, + prompt_override_owned.as_deref(), ) .await }; @@ -3355,6 +3385,7 @@ 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, @@ -3364,6 +3395,7 @@ async fn polish_or_passthrough( output_language_preference: OutputLanguagePreference, front_app: Option<&str>, prior_turns: &[(String, String)], + prompt_override: Option<&str>, ) -> (String, Option) { if mode == PolishMode::Raw { return (raw.text.clone(), None); @@ -3377,6 +3409,7 @@ async fn polish_or_passthrough( output_language_preference, front_app, prior_turns, + prompt_override, ) .await { @@ -3389,6 +3422,7 @@ async fn polish_or_passthrough( } } +#[allow(clippy::too_many_arguments)] async fn polish_text( raw: &str, mode: PolishMode, @@ -3398,6 +3432,7 @@ async fn polish_text( output_language_preference: OutputLanguagePreference, front_app: Option<&str>, prior_turns: &[(String, String)], + prompt_override: Option<&str>, ) -> anyhow::Result { let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default(); let model = CredentialsVault::get(CredentialAccount::ArkModelId)? @@ -3421,6 +3456,7 @@ async fn polish_text( output_language_preference, front_app, prior_turns, + prompt_override, ) .await?) } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 3ca4aa5a..22be948a 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -271,6 +271,13 @@ pub fn run() { commands::validate_shortcut_binding, commands::set_dictation_hotkey, commands::set_translation_hotkey, + commands::set_translate_enabled, + commands::get_default_polish_prompt, + commands::add_custom_mode, + commands::update_custom_mode, + commands::delete_custom_mode, + commands::set_app_mode_overrides, + commands::get_app_mode_overrides, commands::set_switch_style_hotkey, commands::set_open_app_hotkey, commands::qa_window_dismiss, diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 296358a7..2bfb174b 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -94,8 +94,9 @@ impl OpenAICompatibleLLMProvider { output_language_preference: OutputLanguagePreference, front_app: Option<&str>, prior_turns: &[(String, String)], + prompt_override: Option<&str>, ) -> Result { - let mut system_prompt = compose_system_prompt(mode, hotwords); + let mut system_prompt = compose_system_prompt(mode, hotwords, prompt_override); if let Some(premise) = context_premise( working_languages, chinese_script_preference, @@ -560,8 +561,12 @@ fn context_premise( Some(lines.join("\n")) } -fn compose_system_prompt(mode: PolishMode, hotwords: &[String]) -> String { - let base = prompts::system_prompt(mode); +fn compose_system_prompt( + mode: PolishMode, + hotwords: &[String], + override_text: Option<&str>, +) -> String { + let base = prompts::system_prompt(mode, override_text); let cleaned: Vec = hotwords .iter() .map(|h| h.trim().to_string()) @@ -812,7 +817,23 @@ pub mod prompts { 禁止以\u{201C}根据你/您给的内容\u{201D}\u{201C}我整理如下\u{201D}\u{201C}以下是整理后的内容\u{201D}\u{201C}优化如下\u{201D}\u{201C}结构化整理如下\u{201D}等句式开头。\n\ \u{4E0D}加解释、总结、客套话、代码围栏(\\`\\`\\`)或 markdown 元注释。"; - pub fn system_prompt(mode: PolishMode) -> String { + /// `override_text` が `Some(s)` で `s` をトリムした結果が空でなければ、 + /// そのままトリム済み文字列を返す(ROLE_BLOCK / 共通ルール / 出力規約の組み立てをスキップ)。 + /// ユーザーが Settings → Style ページで上書きを保存した場合の経路。 + /// `None` または空白のみ → 既存挙動(ハードコードの中国語 prompt)。 + pub fn system_prompt(mode: PolishMode, override_text: Option<&str>) -> String { + if let Some(text) = override_text { + let trimmed = text.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + // Custom mode で override_text が空 or None だった場合は空 prompt を返す。 + // 通常は呼び出し側(coordinator)で必ず override_text を渡すが、 + // 万一 custom_modes から id が消えている場合のフォールバック。 + if let PolishMode::Custom(_) = mode { + return String::new(); + } let task_and_example = match mode { PolishMode::Raw => "# 任务(原文)\n\ 仅做最小化整理:补全标点、必要分句。\n\ @@ -928,6 +949,8 @@ pub mod prompts { # 示例\n\ 原:那个老板我跟你说下今天的发布我们可能要推迟因为测试还没跑完\n\ 出:今天的发布需要推迟,原因是测试尚未完成。", + // Custom は上の早期リターンで処理済み。ここは到達しない。 + PolishMode::Custom(_) => return String::new(), }; format!( @@ -1173,7 +1196,7 @@ mod tests { #[test] fn structured_prompt_includes_dense_github_request_example() { - let prompt = prompts::system_prompt(PolishMode::Structured); + let prompt = prompts::system_prompt(PolishMode::Structured, None); // 任务段:必须教会模型保留口语引子、按主题归类、用 (a) 子项、自然尾巴 assert!(prompt.contains("# 保留口语引子并润色成自然首行")); @@ -1203,7 +1226,7 @@ mod tests { // 旧 prompt 让 LLM 判定为"已经完整不需要改",原样 passthrough。 // 新 prompt 必须明确:原文是否已有结构 ≠ 不用改的依据; // 事项 ≥ 3 条都要重新归类成双层格式。 - let prompt = prompts::system_prompt(PolishMode::Structured); + let prompt = prompts::system_prompt(PolishMode::Structured, None); // 明确"已结构化 ≠ 不用改"的前提 assert!( @@ -1258,8 +1281,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()], + None, + ); assert!(prompt.contains("用户希望以下写法在输出中保持准确")); assert!(prompt.contains("同音 / 近形误识别时,优先按上述写法输出")); @@ -1267,6 +1293,42 @@ mod tests { assert!(prompt.contains("- OpenLess")); } + #[test] + fn system_prompt_override_replaces_default_text() { + // 上書きが指定されると、ROLE_BLOCK や COMMON_RULES の組み立てを + // 完全にスキップして上書き本文だけを返す(先頭末尾の空白は trim)。 + let custom = " カスタム指示:簡潔に整える。 "; + let prompt = prompts::system_prompt(PolishMode::Structured, Some(custom)); + assert_eq!(prompt, "カスタム指示:簡潔に整える。"); + // 既定 prompt の中身は混ざらない。 + assert!(!prompt.contains("# 角色")); + assert!(!prompt.contains("根目录")); + } + + #[test] + fn system_prompt_override_blank_falls_back_to_default() { + // 空白だけ/空文字 → 既定動作(None と同じ)。 + let blank = prompts::system_prompt(PolishMode::Light, Some(" \n\t ")); + let none = prompts::system_prompt(PolishMode::Light, None); + assert_eq!(blank, none); + } + + #[test] + fn compose_system_prompt_override_keeps_hotwords_appended() { + // 上書きされても hotwords ブロックは独立して追記される + // (ユーザー登録の専門用語誤認を引きずるのは、上書き有無に依存しない)。 + let prompt = compose_system_prompt( + PolishMode::Light, + &["GitHub".into()], + Some("カスタム指示"), + ); + assert!(prompt.starts_with("カスタム指示")); + assert!(prompt.contains("用户希望以下写法在输出中保持准确")); + assert!(prompt.contains("- GitHub")); + // 既定 prompt の本文は混ざらない。 + assert!(!prompt.contains("# 角色")); + } + #[test] fn common_rules_include_auto_correction_and_natural_organization() { // 所有 mode 都要带上"自动纠错"(规则 5)和"按整体意图组织成自然书面表达" @@ -1277,7 +1339,7 @@ mod tests { PolishMode::Structured, PolishMode::Formal, ] { - let prompt = prompts::system_prompt(mode); + let prompt = prompts::system_prompt(mode.clone(), None); assert!( prompt.contains("5) 自动纠错"), "{mode:?} prompt 缺少自动纠错规则" @@ -1342,6 +1404,7 @@ mod tests { OutputLanguagePreference::Auto, None, &[], + None, ) .await .unwrap(); diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index b2578117..783b39ee 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -2,28 +2,139 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -#[derive(Default)] +/// 整形スタイル種別。 +/// - 既存4種は文字列 `"raw"` / `"light"` / `"structured"` / `"formal"` に serde される。 +/// - `Custom(id)` は `"custom:"` 形式の prefix 文字列に serde される。 +/// id 自体に `:` を含むケースは無いと仮定(前端 UI 側でバリデート)。 +/// +/// Copy ではなく Clone のみ(Custom が String を持つため)。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(into = "String", try_from = "String")] pub enum PolishMode { Raw, #[default] Light, Structured, Formal, + Custom(String), } impl PolishMode { - pub fn display_name(&self) -> &'static str { + /// CustomMode の名前を解決して人間向け表示名を返す。Custom 時に + /// `custom_modes` に該当 id が無ければ id 文字列をそのまま返す。 + pub fn display_name_with_customs(&self, custom_modes: &[CustomMode]) -> String { + match self { + PolishMode::Raw => "原文".to_string(), + PolishMode::Light => "轻度润色".to_string(), + PolishMode::Structured => "清晰结构".to_string(), + PolishMode::Formal => "正式表达".to_string(), + PolishMode::Custom(id) => custom_modes + .iter() + .find(|m| m.id == *id) + .map(|m| m.name.clone()) + .unwrap_or_else(|| id.clone()), + } + } + + /// CustomMode への参照無しで呼ばれる旧API互換。Custom は id を返す。 + pub fn display_name(&self) -> String { match self { - PolishMode::Raw => "原文", - PolishMode::Light => "轻度润色", - PolishMode::Structured => "清晰结构", - PolishMode::Formal => "正式表达", + PolishMode::Raw => "原文".to_string(), + PolishMode::Light => "轻度润色".to_string(), + PolishMode::Structured => "清晰结构".to_string(), + PolishMode::Formal => "正式表达".to_string(), + PolishMode::Custom(id) => id.clone(), + } + } + + /// serde 文字列表現を返す("raw" / "light" / "structured" / "formal" / "custom:")。 + pub fn as_serde_string(&self) -> String { + match self { + PolishMode::Raw => "raw".to_string(), + PolishMode::Light => "light".to_string(), + PolishMode::Structured => "structured".to_string(), + PolishMode::Formal => "formal".to_string(), + PolishMode::Custom(id) => format!("custom:{id}"), + } + } +} + +impl From for String { + fn from(mode: PolishMode) -> Self { + mode.as_serde_string() + } +} + +impl TryFrom for PolishMode { + type Error = String; + fn try_from(value: String) -> Result { + match value.as_str() { + "raw" => Ok(PolishMode::Raw), + "light" => Ok(PolishMode::Light), + "structured" => Ok(PolishMode::Structured), + "formal" => Ok(PolishMode::Formal), + other => { + if let Some(id) = other.strip_prefix("custom:") { + if id.is_empty() { + Err("custom mode id is empty".to_string()) + } else { + Ok(PolishMode::Custom(id.to_string())) + } + } else { + Err(format!("unknown polish mode: {other}")) + } + } } } } +/// ユーザー定義カスタム整形スタイル。`prefs.custom_modes` に `Vec` で並ぶ。 +/// id は安定識別子(変更しない)、name は表示名、prompt は LLM への system prompt 本文。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CustomMode { + pub id: String, + pub name: String, + pub prompt: String, +} + +/// アプリ別自動 mode 切替ルール。`prefs.app_mode_overrides` の各要素。 +/// `app_pattern` はプロセス名 substring(大文字小文字無視)に対するマッチパターン。 +/// 例:`"chrome"` は `"Google Chrome"` `"chrome.exe"` のいずれにも一致する。 +/// `mode` は通常の `PolishMode`(ビルトイン or `Custom(id)`)。 +/// 一覧の順序が優先順位そのまま:先頭から見て最初にマッチしたルールの mode を採用する。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AppModeOverride { + pub app_pattern: String, + pub mode: PolishMode, +} + +/// `front_app` の文字列に対して `overrides` を順次評価し、最初にマッチした mode を返す。 +/// マッチルール:`override.app_pattern` を lowercase 化し、`app` の lowercase に substring として +/// 含まれていれば命中。空 `app_pattern` はマッチさせない(誤爆防止)。 +/// +/// `app` が `None`/空文字列、または `overrides` が空 → `None`。 +pub fn pick_mode_for_app( + app: Option<&str>, + overrides: &[AppModeOverride], +) -> Option { + let app = app?.trim(); + if app.is_empty() { + return None; + } + let app_lc = app.to_lowercase(); + for ov in overrides { + let pat = ov.app_pattern.trim().to_lowercase(); + if pat.is_empty() { + continue; + } + if app_lc.contains(&pat) { + return Some(ov.mode.clone()); + } + } + None +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "camelCase")] pub enum ChineseScriptPreference { @@ -149,6 +260,21 @@ pub struct UserPreferences { /// 默认开启以保持可用性;关闭后可验证文本是否真正由 TSF 上屏。 #[serde(default = "default_true")] pub allow_non_tsf_insertion_fallback: bool, + /// ユーザー定義カスタム整形スタイル一覧(順序保持)。 + /// 各要素は `{ id, name, prompt }`。`PolishMode::Custom(id)` から参照される。 + /// 旧フィールド `polish_prompt_overrides` は廃止。serde にはフィールド未知のフラグは無いので + /// 旧 preferences.json に残った値は単に読み捨てされる。 + #[serde(default)] + pub custom_modes: Vec, + /// アプリ別自動 mode 切替ルール(順序が優先順位)。 + /// dictation の polish 直前にアクティブアプリ名と各 `app_pattern` を比較して + /// 最初にマッチしたルールの mode を採用する。マッチしない場合は `default_mode`。 + #[serde(default)] + pub app_mode_overrides: Vec, + /// 翻訳機能を有効にするか。false の時は translation_hotkey を登録しない/ + /// hotkey が誤発火しても overlay/pipeline を起動しない。Settings UI 側のマスタートグル。 + #[serde(default = "default_true")] + pub translate_enabled: bool, /// 用户的工作语言(多选,原生名)。会作为前提注入 LLM polish/translate 的 system prompt 头部, /// 让模型知道该用户在哪些语言间工作。详见 issue #4。 #[serde(default = "default_working_languages")] @@ -289,6 +415,12 @@ struct UserPreferencesWire { active_llm_provider: String, restore_clipboard_after_paste: bool, allow_non_tsf_insertion_fallback: bool, + #[serde(default)] + custom_modes: Option>, + #[serde(default)] + app_mode_overrides: Option>, + #[serde(default)] + translate_enabled: Option, working_languages: Vec, translation_target_language: String, chinese_script_preference: ChineseScriptPreference, @@ -340,6 +472,9 @@ impl Default for UserPreferencesWire { active_llm_provider: prefs.active_llm_provider, restore_clipboard_after_paste: prefs.restore_clipboard_after_paste, allow_non_tsf_insertion_fallback: prefs.allow_non_tsf_insertion_fallback, + custom_modes: Some(prefs.custom_modes), + app_mode_overrides: Some(prefs.app_mode_overrides), + translate_enabled: Some(prefs.translate_enabled), working_languages: prefs.working_languages, translation_target_language: prefs.translation_target_language, chinese_script_preference: prefs.chinese_script_preference, @@ -389,6 +524,9 @@ impl<'de> Deserialize<'de> for UserPreferences { active_llm_provider: wire.active_llm_provider, restore_clipboard_after_paste: wire.restore_clipboard_after_paste, allow_non_tsf_insertion_fallback: wire.allow_non_tsf_insertion_fallback, + custom_modes: wire.custom_modes.unwrap_or_default(), + app_mode_overrides: wire.app_mode_overrides.unwrap_or_default(), + translate_enabled: wire.translate_enabled.unwrap_or(true), working_languages: wire.working_languages, translation_target_language: wire.translation_target_language, chinese_script_preference: wire.chinese_script_preference, @@ -505,6 +643,9 @@ impl Default for UserPreferences { active_llm_provider: "ark".into(), restore_clipboard_after_paste: true, allow_non_tsf_insertion_fallback: true, + custom_modes: Vec::new(), + app_mode_overrides: Vec::new(), + translate_enabled: true, working_languages: default_working_languages(), translation_target_language: String::new(), chinese_script_preference: ChineseScriptPreference::Auto, @@ -1218,4 +1359,81 @@ mod tests { assert!(binding.effective_codes().is_empty()); } + + // ─────────── AppModeOverride / pick_mode_for_app ─────────── + + fn ov(pat: &str, mode: PolishMode) -> AppModeOverride { + AppModeOverride { + app_pattern: pat.to_string(), + mode, + } + } + + #[test] + fn pick_mode_for_app_substring_matches_discord_exe() { + let overrides = vec![ov("discord", PolishMode::Formal)]; + let picked = pick_mode_for_app(Some("Discord.exe"), &overrides); + assert_eq!(picked, Some(PolishMode::Formal)); + } + + #[test] + fn pick_mode_for_app_is_case_insensitive() { + let overrides = vec![ov("CHROME", PolishMode::Light)]; + let picked = pick_mode_for_app(Some("Google Chrome"), &overrides); + assert_eq!(picked, Some(PolishMode::Light)); + } + + #[test] + fn pick_mode_for_app_returns_first_match_in_order() { + let overrides = vec![ + ov("chrome", PolishMode::Light), + ov("google", PolishMode::Structured), + ]; + let picked = pick_mode_for_app(Some("Google Chrome"), &overrides); + assert_eq!(picked, Some(PolishMode::Light)); + } + + #[test] + fn pick_mode_for_app_empty_overrides_yields_none() { + let picked = pick_mode_for_app(Some("AnyApp"), &[]); + assert_eq!(picked, None); + } + + #[test] + fn pick_mode_for_app_no_match_yields_none() { + let overrides = vec![ov("slack", PolishMode::Formal)]; + let picked = pick_mode_for_app(Some("Visual Studio Code"), &overrides); + assert_eq!(picked, None); + } + + #[test] + fn pick_mode_for_app_empty_pattern_is_skipped() { + let overrides = vec![ + ov(" ", PolishMode::Raw), + ov("code", PolishMode::Structured), + ]; + let picked = pick_mode_for_app(Some("Visual Studio Code"), &overrides); + assert_eq!(picked, Some(PolishMode::Structured)); + } + + #[test] + fn pick_mode_for_app_supports_custom_mode() { + let overrides = vec![ov("notion", PolishMode::Custom("note-style".into()))]; + let picked = pick_mode_for_app(Some("Notion.exe"), &overrides); + assert_eq!(picked, Some(PolishMode::Custom("note-style".into()))); + } + + #[test] + fn pick_mode_for_app_none_or_empty_app_yields_none() { + let overrides = vec![ov("anything", PolishMode::Light)]; + assert_eq!(pick_mode_for_app(None, &overrides), None); + assert_eq!(pick_mode_for_app(Some(""), &overrides), None); + assert_eq!(pick_mode_for_app(Some(" "), &overrides), None); + } + + #[test] + fn user_preferences_missing_app_mode_overrides_falls_back_to_empty() { + let prefs: UserPreferences = serde_json::from_str("{}").unwrap(); + assert!(prefs.app_mode_overrides.is_empty()); + } } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 17feb523..0f085932 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -204,6 +204,41 @@ export const en: typeof zhCN = { structured: { name: 'Structured', desc: 'Auto-organizes into a numbered outline when you cover several topics or steps.', sample: '1. Topic one\na. Point\nb. Point\n2. Topic two\na. Point\nb. Point' }, formal: { name: 'Formal', desc: 'Email and workplace tone — more complete, more professional.', sample: 'Detects greetings/sign-offs in email contexts; avoids empty pleasantries.' }, }, + customMode: { + title: 'Custom styles', + desc: 'Add your own polish styles with custom system prompts in addition to the four builtin ones.', + addButton: '+ Add new custom style', + empty: 'No custom styles yet.', + idLabel: 'ID (immutable)', + idPlaceholder: 'e.g. my-style', + idHint: 'Alphanumeric and dashes only. Colon (:) is not allowed.', + nameLabel: 'Display name', + namePlaceholder: 'e.g. Casual notes', + promptLabel: 'System prompt', + promptPlaceholder: 'Write the instructions you want to pass to the LLM…', + delete: 'Delete', + edit: 'Edit', + save: 'Save', + cancel: 'Cancel', + viewBuiltinPrompt: 'View default prompt', + hideBuiltinPrompt: 'Hide', + confirmDelete: 'Delete this custom style?', + idEmpty: 'Please enter an ID.', + idInvalid: 'ID must be alphanumeric or dashes only.', + nameEmpty: 'Please enter a display name.', + promptEmpty: 'Please enter a system prompt.', + }, + appOverride: { + title: 'Per-app auto-switch', + desc: 'Automatically apply a specific style when dictating in a given app. The pattern matches the process name as a substring (e.g. chrome, Discord.exe). When nothing matches, the default style is used.', + addButton: '+ Add new rule', + empty: 'No rules yet. Click "+ Add new rule" to start.', + patternLabel: 'App name (substring)', + patternPlaceholder: 'e.g. chrome / Discord.exe', + modeLabel: 'Style to apply', + delete: 'Delete', + hint: 'Rules are evaluated top-to-bottom; the first match wins. Matching is case-insensitive.', + }, }, translation: { kicker: 'TRANSLATION', @@ -211,6 +246,10 @@ export const en: typeof zhCN = { desc: 'Translate the dictation into a target language before insertion. Target, working languages and trigger are configured here.', statusEnabled: 'Enabled', statusDisabled: 'Disabled', + enable: { + title: 'Enable translation', + hint: 'When off, an accidental hotkey press will not start the translation pipeline or the UI overlay.', + }, working: { title: 'Working languages', desc: 'Pick the languages you regularly use (multi-select). These are passed to the LLM as a premise so polish and translation know which spellings, tone, and conventions you expect.', @@ -543,6 +582,19 @@ export const en: typeof zhCN = { fontSmall: 'Small', fontMedium: 'Medium', fontLarge: 'Large', + fontFamilyLabel: 'UI font', + fontFamilyDesc: 'Switch the in-app display font. Useful when Japanese text is rendered in a Chinese font, or when you prefer another font.', + fontFamilyAuto: 'Auto (default priority)', + fontFamilyCustomLabel: 'Custom (free input)…', + fontFamilyCustomPlaceholder: 'Enter font name (e.g. Yu Gothic UI)', + fontInstalledLabel: 'Installed fonts ({{count}})', + fontLoadAll: 'Load all fonts', + fontReload: 'Reload ({{count}})', + fontLoading: 'Loading…', + fontLoadError: 'Failed to load font list (permission denied or unsupported environment)', + quietLabel: 'Quiet mode', + quietDesc: 'Keeps the recording waveform and processing-dot animation, but suppresses transient text like "Translating", "N chars typed", or "Cancelled". Errors are still shown.', + quietAria: 'Toggle quiet mode', blur: 'Glass blur intensity', blurDesc: 'Affects the inner backdrop-filter strength (the macOS system frosted layer can not be tuned at runtime).', startupOpen: 'On launch', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index b5b3ec28..70a6459d 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -202,10 +202,45 @@ export const ja: typeof zhCN = { saveFailed: '保存に失敗しました: {{error}}', modes: { raw: { name: '原文', desc: '句読点と必要な区切りのみ補い、書き換えや拡張はしません。', sample: '元の話し言葉を保持。「えー」「あの」などの口癖は除去しますが、文の組み替えはしません。' }, - light: { name: '軽い整文', desc: '口癖の除去、句読点の補完、自然な送信可能テキストへの整理。', sample: '原稿読み上げのようにならないよう、語気と表現の癖を残しつつ、文章をなめらかにします。' }, + light: { name: '軽い整形', desc: '口癖の除去、句読点の補完、自然な送信可能テキストへの整理。', sample: '原稿読み上げのようにならないよう、語気と表現の癖を残しつつ、文章をなめらかにします。' }, structured: { name: '明確な構造', desc: '複数のトピックや手順がある場合は、自動的に箇条書きに整理します。', sample: '1. トピック 1\na. ポイント\nb. ポイント\n2. トピック 2\na. ポイント\nb. ポイント' }, formal: { name: '正式な表現', desc: '業務コミュニケーションやメール用途向け。よりプロフェッショナルで完成度の高い文体。', sample: 'メール用途では挨拶 / 結びを自動認識します。空疎な定型句は持ち込みません。' }, }, + customMode: { + title: 'カスタムスタイル', + desc: 'ビルトイン4スタイルに加え、独自のシステムプロンプトを持つカスタムスタイルを自由に追加できます。', + addButton: '+ 新規カスタムスタイル追加', + empty: 'カスタムスタイルはまだありません。', + idLabel: 'ID(変更不可)', + idPlaceholder: '例: my-style', + idHint: '英数字・ハイフンのみ。コロン(:)は使えません。', + nameLabel: '表示名', + namePlaceholder: '例: 雑記用', + promptLabel: 'システムプロンプト', + promptPlaceholder: '整形に使うシステムプロンプトを自由に記入してください…', + delete: '削除', + edit: '編集', + save: '保存', + cancel: 'キャンセル', + viewBuiltinPrompt: 'デフォルトのプロンプトを見る', + hideBuiltinPrompt: '閉じる', + confirmDelete: 'このカスタムスタイルを削除しますか?', + idEmpty: 'ID を入力してください。', + idInvalid: 'ID は半角英数字・ハイフンのみ使えます。', + nameEmpty: '表示名を入力してください。', + promptEmpty: 'システムプロンプトを入力してください。', + }, + appOverride: { + title: 'アプリ別自動切替', + desc: '特定のアプリで音声入力するとき、自動的にこのスタイルを適用します。アプリ名はプロセス名の一部(例: chrome、Discord.exe)。一致しないときは既定スタイルを使います。', + addButton: '+ 新規追加', + empty: 'まだルールがありません。「+ 新規追加」で追加してください。', + patternLabel: 'アプリ名(部分一致)', + patternPlaceholder: '例: chrome / Discord.exe', + modeLabel: '適用するスタイル', + delete: '削除', + hint: '上から順に判定され、最初に一致したルールのスタイルが採用されます。大文字小文字は区別しません。', + }, }, translation: { kicker: 'TRANSLATION', @@ -213,9 +248,13 @@ export const ja: typeof zhCN = { desc: '口述内容を自動的にターゲット言語へ翻訳してから入力します。ターゲット言語、作業言語、トリガー方式すべてここで設定。', statusEnabled: '有効', statusDisabled: '無効', + enable: { + title: '翻訳機能を使う', + hint: 'OFF にすると hotkey が誤発火しても翻訳パイプラインと UI overlay は起動しません。', + }, working: { title: '作業言語', - desc: '日常的に使用する言語(複数選択可)にチェックを入れてください。これらは前提として LLM の system prompt 冒頭に注入され、整文と翻訳の判断(固有名詞の表記、語気、文体習慣)に影響します。', + desc: '日常的に使用する言語(複数選択可)にチェックを入れてください。これらは前提として LLM のシステムプロンプト冒頭に注入され、整形と翻訳の判断(固有名詞の表記、語気、文体習慣)に影響します。', }, target: { title: '翻訳ターゲット言語', @@ -545,6 +584,19 @@ export const ja: typeof zhCN = { fontSmall: '小', fontMedium: '中', fontLarge: '大', + fontFamilyLabel: 'UI フォント', + fontFamilyDesc: 'アプリ内の表示フォントを切り替えます。日本語が中華フォントで描画される環境や、お好みの和文フォントへの切り替えに。', + fontFamilyAuto: '自動(既定の優先順)', + fontFamilyCustomLabel: 'カスタム(自由入力)…', + fontFamilyCustomPlaceholder: 'フォント名を入力(例: Yu Gothic UI)', + fontInstalledLabel: 'インストール済みフォント ({{count}})', + fontLoadAll: 'すべてのフォントを読み込む', + fontReload: '再読み込み ({{count}})', + fontLoading: '取得中…', + fontLoadError: 'フォント一覧を取得できませんでした(権限拒否または非対応環境)', + quietLabel: 'サイレントモード', + quietDesc: '録音中の音波・処理中のドットアニメは残しつつ、「翻訳中」「N文字入力」「キャンセルしました」などのテキスト表示を抑制します。エラーは引き続き表示されます。', + quietAria: 'サイレントモード切り替え', blur: 'すりガラス強度', blurDesc: 'ウィンドウ内側の backdrop-filter 強度に影響(macOS のシステムフロスト層が動かない場合に調整)。', startupOpen: '起動時に開く', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 9518e2af..018ac5b9 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -206,6 +206,41 @@ export const ko: typeof zhCN = { structured: { name: '명확한 구조', desc: '여러 주제나 단계가 있을 때 자동으로 항목별 목록으로 정리합니다.', sample: '1. 주제 1\na. 포인트\nb. 포인트\n2. 주제 2\na. 포인트\nb. 포인트' }, formal: { name: '정식 표현', desc: '업무 커뮤니케이션과 메일에 적합. 더 전문적이고 완성도 높은 문체.', sample: '메일 시나리오에서 인사말과 맺음말을 자동 인식. 공허한 상투어는 추가하지 않습니다.' }, }, + customMode: { + title: '사용자 스타일', + desc: '내장 4가지 스타일에 더해 자체 system prompt를 가진 사용자 스타일을 자유롭게 추가할 수 있습니다.', + addButton: '+ 새 사용자 스타일 추가', + empty: '사용자 스타일이 아직 없습니다.', + idLabel: 'ID (변경 불가)', + idPlaceholder: '예: my-style', + idHint: '영숫자와 하이픈만 사용 가능. 콜론(:)은 사용할 수 없습니다.', + nameLabel: '표시 이름', + namePlaceholder: '예: 잡기용', + promptLabel: 'system prompt 본문', + promptPlaceholder: 'LLM에 전달할 지시문을 직접 작성하세요...', + delete: '삭제', + edit: '편집', + save: '저장', + cancel: '취소', + viewBuiltinPrompt: '기본 prompt 보기', + hideBuiltinPrompt: '닫기', + confirmDelete: '이 사용자 스타일을 삭제하시겠습니까?', + idEmpty: 'ID를 입력하세요.', + idInvalid: 'ID는 영숫자와 하이픈만 사용 가능합니다.', + nameEmpty: '표시 이름을 입력하세요.', + promptEmpty: 'system prompt를 입력하세요.', + }, + appOverride: { + title: '앱별 자동 전환', + desc: '특정 앱에서 받아쓰기를 할 때 해당 스타일을 자동으로 적용합니다. 패턴은 프로세스 이름의 일부와 일치합니다(예: chrome, Discord.exe). 일치하지 않으면 기본 스타일이 사용됩니다.', + addButton: '+ 새 규칙 추가', + empty: '아직 규칙이 없습니다. "+ 새 규칙 추가"를 눌러 시작하세요.', + patternLabel: '앱 이름(부분 일치)', + patternPlaceholder: '예: chrome / Discord.exe', + modeLabel: '적용할 스타일', + delete: '삭제', + hint: '위에서 아래 순서로 평가되며, 가장 먼저 일치한 규칙이 적용됩니다. 대소문자는 구분하지 않습니다.', + }, }, translation: { kicker: 'TRANSLATION', @@ -213,6 +248,10 @@ export const ko: typeof zhCN = { desc: '구술 내용을 자동으로 대상 언어로 번역한 후 입력합니다. 대상 언어, 작업 언어, 트리거 방식 모두 여기서 설정합니다.', statusEnabled: '활성화됨', statusDisabled: '비활성화됨', + enable: { + title: '번역 기능 사용', + hint: '끄면 단축키가 잘못 눌려도 번역 파이프라인과 UI 오버레이가 시작되지 않습니다.', + }, working: { title: '작업 언어', desc: '일상적으로 사용하는 언어를 체크하세요(다중 선택). 이 언어 그룹은 LLM system prompt 시작 부분에 전제로 주입되어 정리와 번역의 판단(고유명사 표기, 어조, 문체 습관)에 영향을 줍니다.', @@ -545,6 +584,19 @@ export const ko: typeof zhCN = { fontSmall: '소', fontMedium: '중', fontLarge: '대', + fontFamilyLabel: 'UI 글꼴', + fontFamilyDesc: '앱 내부 표시 글꼴을 전환합니다. 일본어가 중국어 글꼴로 렌더링될 때 또는 선호하는 글꼴로 바꿀 때 유용합니다.', + fontFamilyAuto: '자동 (기본 우선순위)', + fontFamilyCustomLabel: '사용자 지정 (자유 입력)…', + fontFamilyCustomPlaceholder: '글꼴 이름 입력 (예: Yu Gothic UI)', + fontInstalledLabel: '설치된 글꼴 ({{count}})', + fontLoadAll: '모든 글꼴 불러오기', + fontReload: '다시 불러오기 ({{count}})', + fontLoading: '가져오는 중…', + fontLoadError: '글꼴 목록을 가져올 수 없습니다 (권한 거부 또는 미지원 환경)', + quietLabel: '조용 모드', + quietDesc: '녹음 파형과 처리 중 점 애니메이션은 유지하면서 "번역 중", "N자 입력됨", "취소됨" 같은 일시적 텍스트 표시를 억제합니다. 오류는 계속 표시됩니다.', + quietAria: '조용 모드 전환', blur: '서리유리 강도', blurDesc: '창 내부 backdrop-filter 강도에 영향(macOS 시스템 서리 레이어가 작동하지 않을 때 조정).', startupOpen: '시작 시 열기', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 265449a2..f5df4985 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -202,6 +202,41 @@ export const zhCN = { structured: { name: '清晰结构', desc: '多个主题或步骤时,自动组织为分点列表。', sample: '1. 主题一\na. 要点\nb. 要点\n2. 主题二\na. 要点\nb. 要点' }, formal: { name: '正式表达', desc: '工作沟通和邮件场景,更专业更完整。', sample: '邮件场景自动识别问候 / 落款;不引入空泛客套。' }, }, + customMode: { + title: '自定义风格', + desc: '除内置 4 种风格外,可自由添加带独立 system prompt 的自定义风格。', + addButton: '+ 新增自定义风格', + empty: '还没有自定义风格。', + idLabel: 'ID(不可修改)', + idPlaceholder: '例如:my-style', + idHint: '仅支持英文字母、数字和连字符。不能包含冒号(:)。', + nameLabel: '显示名', + namePlaceholder: '例如:杂记用', + promptLabel: 'system prompt 正文', + promptPlaceholder: '直接写入要传给 LLM 的指令……', + delete: '删除', + edit: '编辑', + save: '保存', + cancel: '取消', + viewBuiltinPrompt: '查看默认 prompt', + hideBuiltinPrompt: '收起', + confirmDelete: '确定删除此自定义风格?', + idEmpty: '请输入 ID。', + idInvalid: 'ID 只能包含英文字母、数字和连字符。', + nameEmpty: '请输入显示名。', + promptEmpty: '请输入 system prompt。', + }, + appOverride: { + title: '按应用自动切换', + desc: '在指定应用里听写时,自动套用对应的风格。匹配的是进程名的子串(例如 chrome、Discord.exe),不区分大小写;都不命中时使用默认风格。', + addButton: '+ 新增规则', + empty: '暂无规则。点击「+ 新增规则」开始添加。', + patternLabel: '应用名(子串匹配)', + patternPlaceholder: '例:chrome / Discord.exe', + modeLabel: '应用的风格', + delete: '删除', + hint: '从上往下依次匹配,第一条命中的规则生效。匹配不区分大小写。', + }, }, translation: { kicker: 'TRANSLATION', @@ -209,6 +244,10 @@ export const zhCN = { desc: '把口述的内容自动翻译成目标语言后再插入。目标语言、工作语言、触发方式都在这里配置。', statusEnabled: '已启用', statusDisabled: '未启用', + enable: { + title: '启用翻译功能', + hint: '关闭后,即使误触快捷键也不会启动翻译管线和 UI 浮层。', + }, working: { title: '工作语言', desc: '勾选你日常会用到的语言(多选)。这组语言会作为前提注入 LLM 的 system prompt 头部,影响润色与翻译的判断(专名拼写、语气、行文习惯)。', @@ -541,6 +580,19 @@ export const zhCN = { fontSmall: '小', fontMedium: '中', fontLarge: '大', + fontFamilyLabel: 'UI 字体', + fontFamilyDesc: '切换 App 内的显示字体。适用于中日文字体渲染冲突或自定义偏好。', + fontFamilyAuto: '自动(默认优先级)', + fontFamilyCustomLabel: '自定义(自由输入)…', + fontFamilyCustomPlaceholder: '输入字体名(例:Yu Gothic UI)', + fontInstalledLabel: '已安装字体 ({{count}})', + fontLoadAll: '加载所有字体', + fontReload: '重新加载 ({{count}})', + fontLoading: '获取中…', + fontLoadError: '无法获取字体列表(权限被拒或当前环境不支持)', + quietLabel: '静默模式', + quietDesc: '保留录音波形与处理点动画,抑制"翻译中""已输入 N 字""已取消"等瞬时文字提示。错误仍会显示。', + quietAria: '切换静默模式', blur: '毛玻璃强度', blurDesc: '影响窗口内层 backdrop-filter 强度(macOS 系统磨砂层无法运行时调)。', startupOpen: '启动时打开', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 21dc2d80..206aa792 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -204,6 +204,41 @@ export const zhTW: typeof zhCN = { structured: { name: '清晰結構', desc: '多個主題或步驟時,自動組織爲分點列表。', sample: '1. 主題一\na. 要點\nb. 要點\n2. 主題二\na. 要點\nb. 要點' }, formal: { name: '正式表達', desc: '工作溝通和郵件場景,更專業更完整。', sample: '郵件場景自動識別問候 / 落款;不引入空泛客套。' }, }, + customMode: { + title: '自定義風格', + desc: '除內建 4 種風格外,可自由新增帶獨立 system prompt 的自定義風格。', + addButton: '+ 新增自定義風格', + empty: '還沒有自定義風格。', + idLabel: 'ID(不可修改)', + idPlaceholder: '例如:my-style', + idHint: '僅支援英文字母、數字和連字號。不可包含冒號(:)。', + nameLabel: '顯示名稱', + namePlaceholder: '例如:雜記用', + promptLabel: 'system prompt 內容', + promptPlaceholder: '直接寫入要傳給 LLM 的指令……', + delete: '刪除', + edit: '編輯', + save: '儲存', + cancel: '取消', + viewBuiltinPrompt: '查看預設 prompt', + hideBuiltinPrompt: '收起', + confirmDelete: '確定刪除此自定義風格?', + idEmpty: '請輸入 ID。', + idInvalid: 'ID 只能包含英文字母、數字和連字號。', + nameEmpty: '請輸入顯示名稱。', + promptEmpty: '請輸入 system prompt。', + }, + appOverride: { + title: '依應用程式自動切換', + desc: '在指定的應用程式中聽寫時,自動套用對應的風格。比對的是行程名稱的子字串(例如 chrome、Discord.exe),不區分大小寫;都不命中時採用預設風格。', + addButton: '+ 新增規則', + empty: '尚未設定規則。點擊「+ 新增規則」開始。', + patternLabel: '應用程式名稱(子字串)', + patternPlaceholder: '例:chrome / Discord.exe', + modeLabel: '套用的風格', + delete: '刪除', + hint: '由上至下依序比對,最先命中的規則生效。比對不區分大小寫。', + }, }, translation: { kicker: 'TRANSLATION', @@ -211,6 +246,10 @@ export const zhTW: typeof zhCN = { desc: '把口述的內容自動翻譯成目標語言後再插入。目標語言、工作語言、觸發方式都在這裏配置。', statusEnabled: '已啓用', statusDisabled: '未啓用', + enable: { + title: '啓用翻譯功能', + hint: '關閉後,即使誤觸快捷鍵也不會啓動翻譯管線和 UI 浮層。', + }, working: { title: '工作語言', desc: '勾選你日常會用到的語言(多選)。這組語言會作爲前提注入 LLM 的 system prompt 頭部,影響潤色與翻譯的判斷(專名拼寫、語氣、行文習慣)。', @@ -543,6 +582,19 @@ export const zhTW: typeof zhCN = { fontSmall: '小', fontMedium: '中', fontLarge: '大', + fontFamilyLabel: 'UI 字體', + fontFamilyDesc: '切換 App 內的顯示字體。適用於中日文字體渲染衝突或自訂偏好。', + fontFamilyAuto: '自動(預設優先級)', + fontFamilyCustomLabel: '自訂(自由輸入)…', + fontFamilyCustomPlaceholder: '輸入字體名(例:Yu Gothic UI)', + fontInstalledLabel: '已安裝字體 ({{count}})', + fontLoadAll: '載入所有字體', + fontReload: '重新載入 ({{count}})', + fontLoading: '取得中…', + fontLoadError: '無法取得字體列表(權限被拒或目前環境不支援)', + quietLabel: '靜默模式', + quietDesc: '保留錄音波形與處理點動畫,抑制「翻譯中」「已輸入 N 字」「已取消」等瞬時文字提示。錯誤仍會顯示。', + quietAria: '切換靜默模式', blur: '毛玻璃強度', blurDesc: '影響窗口內層 backdrop-filter 強度(macOS 系統磨砂層無法運行時調)。', startupOpen: '啓動時打開', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 96adf2e7..d38fb956 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -3,6 +3,7 @@ // the UI is still operable for visual review. import type { + AppModeOverride, ComboBinding, CredentialsStatus, DictationSession, @@ -56,8 +57,11 @@ const mockSettings: UserPreferences = { activeLlmProvider: 'ark', restoreClipboardAfterPaste: true, allowNonTsfInsertionFallback: true, + customModes: [], + appModeOverrides: [], workingLanguages: ['简体中文'], translationTargetLanguage: '', + translateEnabled: true, qaHotkey: defaultQaShortcut(), chineseScriptPreference: 'auto', outputLanguagePreference: 'auto', @@ -320,6 +324,35 @@ export function setStyleEnabled(mode: PolishMode, enabled: boolean): Promise undefined); } +// 既定(ハードコード)の polish system prompt を取得する。ビルトイン4 mode 専用。 +// Settings → Style ページで「prompt を見る」を押した時の参考表示用。 +// `mode` は `'raw' | 'light' | 'structured' | 'formal'`。 +export function getDefaultPolishPrompt(mode: string): Promise { + return invokeOrMock('get_default_polish_prompt', { mode }, () => ''); +} + +// カスタム整形スタイルの追加。id は重複不可、name/prompt はそのまま保存される。 +export function addCustomMode(id: string, name: string, prompt: string): Promise { + return invokeOrMock('add_custom_mode', { id, name, prompt }, () => undefined); +} + +// カスタム整形スタイルの更新。id は不変、name/prompt のみ書き換わる。 +export function updateCustomMode(id: string, name: string, prompt: string): Promise { + return invokeOrMock('update_custom_mode', { id, name, prompt }, () => undefined); +} + +// カスタム整形スタイルの削除。default_mode が該当 id を指していたら Light に fallback、 +// enabled_modes からも該当 Custom が除去される。 +export function deleteCustomMode(id: string): Promise { + return invokeOrMock('delete_custom_mode', { id }, () => undefined); +} + +// アプリ別自動 mode 切替ルールの一括置換。フロントは編集中の配列丸ごとを送る。 +// 各 override の mode が `custom:` の場合、id が customModes に存在することを後端が検証する。 +export function setAppModeOverrides(overrides: AppModeOverride[]): Promise { + return invokeOrMock('set_app_mode_overrides', { overrides }, () => undefined); +} + // ── Permissions ──────────────────────────────────────────────────────── export function checkAccessibilityPermission(): Promise { return invokeOrMock('check_accessibility_permission', undefined, () => 'granted' as const); @@ -389,6 +422,10 @@ export function setTranslationHotkey(binding: ShortcutBinding): Promise { return invokeOrMock('set_translation_hotkey', { binding }, () => undefined); } +export function setTranslateEnabled(enabled: boolean): Promise { + return invokeOrMock('set_translate_enabled', { enabled }, () => undefined); +} + export function setSwitchStyleHotkey(binding: ShortcutBinding): Promise { return invokeOrMock('set_switch_style_hotkey', { binding }, () => undefined); } diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index cfcdcf67..04fc66c4 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -22,8 +22,11 @@ const previousPrefs: UserPreferences = { activeLlmProvider: 'ark', restoreClipboardAfterPaste: true, allowNonTsfInsertionFallback: true, + customModes: [], + appModeOverrides: [], workingLanguages: ['简体中文'], translationTargetLanguage: '', + translateEnabled: true, chineseScriptPreference: 'auto', outputLanguagePreference: 'auto', qaHotkey: null, diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index b946ff0e..5eba8672 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -1,8 +1,36 @@ // TypeScript mirror of src-tauri/src/types.rs. // All keys are camelCase (Rust serializes with #[serde(rename_all = "camelCase")]). -// PolishMode is an exception — Rust uses lowercase serialization. +// PolishMode is an exception — Rust uses lowercase serialization for builtin modes +// and `'custom:'` prefix form for user-defined custom modes. -export type PolishMode = 'raw' | 'light' | 'structured' | 'formal'; +/** + * Polish style identifier. Builtin: `'raw' | 'light' | 'structured' | 'formal'`. + * User-defined custom modes serialize as `'custom:'`. + * `string` widening covers Custom values without breaking existing literal comparisons + * like `mode === 'raw'`. + */ +export type PolishMode = 'raw' | 'light' | 'structured' | 'formal' | string; + +/** + * ユーザー定義カスタム整形スタイル。`prefs.customModes` に並ぶ。 + * id は安定識別子、name は表示名、prompt は LLM の system prompt 本文。 + */ +export interface CustomMode { + id: string; + name: string; + prompt: string; +} + +/** + * アプリ別自動 mode 切替ルール。`prefs.appModeOverrides` の各要素。 + * `appPattern` はプロセス名 substring(大文字小文字無視)。 + * `mode` はビルトイン or `'custom:'` 形式の文字列 ID。 + * 配列の順序が優先順位そのまま:先頭から見て最初にマッチしたルールの mode が採用される。 + */ +export interface AppModeOverride { + appPattern: string; + mode: PolishMode; +} export type InsertStatus = 'inserted' | 'pasteSent' | 'copiedFallback' | 'failed'; @@ -132,10 +160,19 @@ export interface UserPreferences { restoreClipboardAfterPaste: boolean; /** Windows:TSF 失败后是否允许 SendInput / 粘贴类非 TSF 兜底。关闭后可验证是否真实 TSF 上屏。 */ allowNonTsfInsertionFallback: boolean; + /** ユーザー定義カスタム整形スタイル一覧(順序保持)。 + * 各要素 `{ id, name, prompt }`。`PolishMode` の `'custom:'` 値から参照される。 */ + customModes: CustomMode[]; + /** アプリ別自動 mode 切替ルール(順序が優先順位)。 + * dictation の polish 直前にアクティブアプリ名と各 `appPattern` を順に比較し、 + * 最初にマッチしたルールの mode が採用される。マッチしない場合は `defaultMode`。 */ + appModeOverrides: AppModeOverride[]; /** 用户的工作语言(多选,原生名);作为前提注入 LLM polish/translate prompt 头部。 */ workingLanguages: string[]; /** 翻译模式目标语言(单选,原生名);空串 = 不启用 Shift 翻译。详见 issue #4。 */ translationTargetLanguage: string; + /** 翻訳機能のグローバルなオン/オフ。OFF にすると hotkey が誤発火しても翻訳パイプラインと UI overlay は起動しない。 */ + translateEnabled: boolean; /** 中文输出字形偏好:由界面语言(简/繁)自动同步,不单独暴露设置项。 */ chineseScriptPreference: 'auto' | 'simplified' | 'traditional'; /** 最终输出语言偏好:由界面语言自动同步,不单独暴露设置项。 */ diff --git a/openless-all/app/src/pages/Style.tsx b/openless-all/app/src/pages/Style.tsx index 1a78948b..48e9ba67 100644 --- a/openless-all/app/src/pages/Style.tsx +++ b/openless-all/app/src/pages/Style.tsx @@ -1,10 +1,21 @@ -// Style.tsx — 接 getSettings / setDefaultPolishMode / setStyleEnabled。 -// defaultMode 来自 prefs.defaultMode,启停从 prefs.enabledModes 反推。 +// Style.tsx — ビルトイン4スタイル + ユーザー定義カスタムスタイル。 +// defaultMode は prefs.defaultMode、有効/無効は prefs.enabledModes 反映。 +// カスタムスタイルは prefs.customModes(順序保持)。`PolishMode` は `'custom:'` 形式。 -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { getSettings, setDefaultPolishMode, setStyleEnabled, setSettings } from '../lib/ipc'; -import type { PolishMode, UserPreferences } from '../lib/types'; +import { + getSettings, + setDefaultPolishMode, + setStyleEnabled, + setSettings, + getDefaultPolishPrompt, + addCustomMode, + updateCustomMode, + deleteCustomMode, + setAppModeOverrides, +} from '../lib/ipc'; +import type { AppModeOverride, CustomMode, PolishMode, UserPreferences } from '../lib/types'; import { persistStylePreferenceChange, rollbackDefaultModeChange, @@ -20,12 +31,22 @@ interface StyleDef { sample: string; } -const STYLE_IDS: PolishMode[] = ['raw', 'light', 'structured', 'formal']; -type StyleSaveErrorTarget = PolishMode | 'master'; +const BUILTIN_IDS: Array<'raw' | 'light' | 'structured' | 'formal'> = ['raw', 'light', 'structured', 'formal']; +type StyleSaveErrorTarget = PolishMode | 'master' | 'custom'; + +interface CustomFormState { + // 編集対象 id(新規追加なら空文字列)。 + id: string; + name: string; + prompt: string; + // true = 新規、false = 既存編集 + isNew: boolean; + errorMessage: string; +} export function Style() { const { t } = useTranslation(); - const STYLES: StyleDef[] = STYLE_IDS.map(id => ({ + const STYLES: StyleDef[] = BUILTIN_IDS.map(id => ({ id, name: t(`style.modes.${id}.name`), desc: t(`style.modes.${id}.desc`), @@ -33,15 +54,95 @@ export function Style() { })); const [prefs, setPrefs] = useState(null); const [saveError, setSaveError] = useState<{ target: StyleSaveErrorTarget; message: string } | null>(null); + // ビルトイン4 mode の既定 prompt キャッシュ。「prompt を見る」展開時に表示。 + const [defaultPrompts, setDefaultPrompts] = useState>({}); + // 各ビルトイン mode で prompt を展開しているか。 + const [expandedBuiltin, setExpandedBuiltin] = useState>({}); + // カスタム編集フォーム(null = フォーム閉じている)。 + const [customForm, setCustomForm] = useState(null); + // アプリ別自動切替の編集中配列。null = まだ初期化されていない(prefs 取得待ち)。 + const [overrides, setOverrides] = useState(null); + const [overrideError, setOverrideError] = useState(null); + // 500ms debounce 用の timer。直近の編集を 500ms 何もせず待ったら setAppModeOverrides を呼ぶ。 + const overrideDebounceRef = useRef | null>(null); useEffect(() => { - getSettings().then(setPrefs); + getSettings().then(p => { + setPrefs(p); + setOverrides(p.appModeOverrides); + }); }, []); + // アンマウント時に保留中の debounce をクリア。残ったままだと unmount 後に invoke が走る。 + useEffect(() => { + return () => { + if (overrideDebounceRef.current) { + clearTimeout(overrideDebounceRef.current); + } + }; + }, []); + + const scheduleOverrideSave = (next: AppModeOverride[]) => { + if (overrideDebounceRef.current) { + clearTimeout(overrideDebounceRef.current); + } + overrideDebounceRef.current = setTimeout(async () => { + try { + await setAppModeOverrides(next); + setOverrideError(null); + // 後端が prefs:changed を emit しても今のページは listen していないので + // ローカル prefs を即座に同期しておく(次の getSettings 呼び出しまで diverge しないため)。 + setPrefs(prev => (prev ? { ...prev, appModeOverrides: next } : prev)); + } catch (err) { + setOverrideError(String(err)); + } + }, 500); + }; + + const updateOverridesLocally = (next: AppModeOverride[]) => { + setOverrides(next); + scheduleOverrideSave(next); + }; + + const onAddOverride = () => { + if (!overrides) return; + updateOverridesLocally([...overrides, { appPattern: '', mode: 'light' }]); + }; + + const onChangeOverridePattern = (idx: number, pattern: string) => { + if (!overrides) return; + const next = overrides.slice(); + next[idx] = { ...next[idx], appPattern: pattern }; + updateOverridesLocally(next); + }; + + const onChangeOverrideMode = (idx: number, mode: PolishMode) => { + if (!overrides) return; + const next = overrides.slice(); + next[idx] = { ...next[idx], mode }; + updateOverridesLocally(next); + }; + + const onDeleteOverride = (idx: number) => { + if (!overrides) return; + const next = overrides.filter((_, i) => i !== idx); + updateOverridesLocally(next); + }; + const showSaveError = (target: StyleSaveErrorTarget, error: string) => { setSaveError({ target, message: t('style.saveFailed', { error }) }); }; + const ensureBuiltinPromptLoaded = async (id: string) => { + if (defaultPrompts[id] !== undefined) return; + try { + const text = await getDefaultPolishPrompt(id); + setDefaultPrompts(prev => ({ ...prev, [id]: text })); + } catch { + // 失敗時は空のまま — トグル状態に「読み込めません」と書く方がうるさい。 + } + }; + const onPickDefault = async (mode: PolishMode) => { if (!prefs) return; const next = { ...prefs, defaultMode: mode }; @@ -72,6 +173,68 @@ export function Style() { if (saved) setSaveError(null); }; + const openCustomFormForNew = () => { + setCustomForm({ id: '', name: '', prompt: '', isNew: true, errorMessage: '' }); + }; + + const openCustomFormForEdit = (cm: CustomMode) => { + setCustomForm({ id: cm.id, name: cm.name, prompt: cm.prompt, isNew: false, errorMessage: '' }); + }; + + const closeCustomForm = () => setCustomForm(null); + + const onCustomFormSave = async () => { + if (!customForm || !prefs) return; + const trimmedId = customForm.id.trim(); + const trimmedName = customForm.name.trim(); + const trimmedPrompt = customForm.prompt.trim(); + if (customForm.isNew) { + if (!trimmedId) { + setCustomForm({ ...customForm, errorMessage: t('style.customMode.idEmpty') }); + return; + } + if (!/^[A-Za-z0-9-]+$/.test(trimmedId)) { + setCustomForm({ ...customForm, errorMessage: t('style.customMode.idInvalid') }); + return; + } + } + if (!trimmedName) { + setCustomForm({ ...customForm, errorMessage: t('style.customMode.nameEmpty') }); + return; + } + if (!trimmedPrompt) { + setCustomForm({ ...customForm, errorMessage: t('style.customMode.promptEmpty') }); + return; + } + try { + if (customForm.isNew) { + await addCustomMode(trimmedId, trimmedName, customForm.prompt); + } else { + await updateCustomMode(customForm.id, trimmedName, customForm.prompt); + } + // 保存成功 → 設定再取得して反映 + const fresh = await getSettings(); + setPrefs(fresh); + setCustomForm(null); + setSaveError(null); + } catch (error) { + setCustomForm({ ...customForm, errorMessage: String(error) }); + } + }; + + const onCustomDelete = async (cm: CustomMode) => { + if (!prefs) return; + if (!confirm(t('style.customMode.confirmDelete'))) return; + try { + await deleteCustomMode(cm.id); + const fresh = await getSettings(); + setPrefs(fresh); + setSaveError(null); + } catch (error) { + showSaveError('custom', String(error)); + } + }; + if (!prefs) { return ( { if (!prefs) return; if (masterEnabled) { - // 全部关闭 → 留 raw 和当前 default 兜底 const next = { ...prefs, enabledModes: [] as PolishMode[] }; const saved = await persistStylePreferenceChange( next, @@ -98,7 +260,13 @@ export function Style() { ); if (saved) setSaveError(null); } else { - const next = { ...prefs, enabledModes: ['raw', 'light', 'structured', 'formal'] as PolishMode[] }; + const next = { + ...prefs, + enabledModes: [ + ...(['raw', 'light', 'structured', 'formal'] as PolishMode[]), + ...prefs.customModes.map(cm => `custom:${cm.id}` as PolishMode), + ], + }; const saved = await persistStylePreferenceChange( next, () => setSettings(next), @@ -110,6 +278,90 @@ export function Style() { } }; + const renderModeCard = ( + modeId: PolishMode, + name: string, + desc: string | null, + sampleSlot: React.ReactNode, + extraSlot?: React.ReactNode, + ) => { + const isDefault = prefs.defaultMode === modeId; + const isEnabled = prefs.enabledModes.includes(modeId); + return ( +
+
+ + + {isDefault && {t('style.currentDefault')}} + {!isDefault && ( + + )} +
+ {desc &&
{desc}
} + {sampleSlot} + {extraSlot} + {saveError?.target === modeId && ( +
+ {saveError.message} +
+ )} +
+ ); + }; + return ( <>
{STYLES.map(s => { - const isDefault = prefs.defaultMode === s.id; - const isEnabled = prefs.enabledModes.includes(s.id); - return ( + const expanded = expandedBuiltin[s.id] === true; + const promptText = defaultPrompts[s.id]; + const sampleSlot = (
-
-
+ ); + const extraSlot = ( +
+ + {expanded && ( +
-                  {isDefault && (
-                    
-                  )}
-                
-                
+ )} +
+ ); + return renderModeCard(s.id, s.name, s.desc, sampleSlot, extraSlot); + })} +
+ + {/* カスタムスタイル領域 */} +
+
+
+ {t('style.customMode.title')} +
+ +
+
+ {t('style.customMode.desc')} +
+ + {customForm && ( +
+ + +