From 8baf160ea20ed731bf21b38de82e1b2705efc7d2 Mon Sep 17 00:00:00 2001 From: cooper Date: Wed, 13 May 2026 13:47:27 +0800 Subject: [PATCH 01/16] feat: add style pack runtime and persistence --- openless-all/app/src-tauri/src/commands.rs | 252 +++++- openless-all/app/src-tauri/src/coordinator.rs | 89 ++- .../src-tauri/src/coordinator/dictation.rs | 204 ++--- openless-all/app/src-tauri/src/lib.rs | 9 + openless-all/app/src-tauri/src/llm_gemini.rs | 2 + openless-all/app/src-tauri/src/persistence.rs | 727 +++++++++++++++++- openless-all/app/src-tauri/src/polish.rs | 54 +- openless-all/app/src-tauri/src/types.rs | 362 +++++++++ 8 files changed, 1502 insertions(+), 197 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 4c759ea5..79dfb894 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -15,7 +15,8 @@ use crate::asr::local::FoundryLocalRuntime; use crate::coordinator::Coordinator; use crate::permissions::{self, PermissionStatus}; use crate::persistence::{ - CredentialAccount, CredentialsSnapshot, CredentialsVault, PreferencesStore, + sync_style_pack_preferences, CredentialAccount, CredentialsSnapshot, CredentialsVault, + PreferencesStore, }; use crate::polish::{ http_client_builder, CodexOAuthConfig, CodexOAuthCredentials, CodexOAuthLLMProvider, LLMError, @@ -24,9 +25,10 @@ use crate::polish::{ }; use crate::recorder::{AudioConsumer, Recorder}; use crate::types::{ - ChineseScriptPreference, ComboBinding, CorrectionRule, CredentialsStatus, DictationSession, - DictionaryEntry, HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode, - ShortcutBinding, UpdateChannel, UserPreferences, VocabPresetStore, WindowsImeStatus, + builtin_style_pack_id, default_active_style_pack_id, ChineseScriptPreference, ComboBinding, + CorrectionRule, CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, + HotkeyStatus, OutputLanguagePreference, PolishMode, ShortcutBinding, StylePack, + StyleSystemPrompts, UpdateChannel, UserPreferences, VocabPresetStore, WindowsImeStatus, }; type CoordinatorState<'a> = State<'a, Arc>; @@ -58,6 +60,11 @@ pub fn get_settings(coord: CoordinatorState<'_>) -> UserPreferences { coord.prefs().get() } +#[tauri::command] +pub fn get_default_style_system_prompts() -> StyleSystemPrompts { + StyleSystemPrompts::default() +} + trait SettingsWriter { fn write_settings(&self, prefs: UserPreferences) -> Result<(), String>; fn refresh_dictation_hotkey(&self); @@ -178,6 +185,36 @@ pub fn set_settings( Ok(()) } +fn refresh_tray_menu_async(app: &AppHandle) { + let app_for_main = app.clone(); + let _ = app.run_on_main_thread(move || { + if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) { + log::warn!("[tray] refresh after style change failed: {err}"); + } + }); +} + +fn emit_prefs_changed(app: &AppHandle, prefs: &UserPreferences) { + let _ = app.emit("prefs:changed", prefs); + let _ = app.emit_to("main", "prefs:changed", prefs); +} + +fn sync_style_pack_prefs_and_persist( + coord: &Coordinator, + app: &AppHandle, + mut prefs: UserPreferences, +) -> Result { + let packs = coord.style_packs().list().map_err(|e| e.to_string())?; + sync_style_pack_preferences(&mut prefs, &packs); + coord + .prefs() + .set(prefs.clone()) + .map_err(|e| e.to_string())?; + emit_prefs_changed(app, &prefs); + refresh_tray_menu_async(app); + Ok(prefs) +} + // ─────────────────────────── release channel (Beta opt-in) ─────────────────────────── // // 渠道偏好的写入路径跟 set_settings 复用 persist_settings:保持热键兜底归一化 @@ -642,6 +679,7 @@ async fn validate_llm_provider() -> Result<(), String> { "验证连接", PolishMode::Raw, &[], + "", &[], ChineseScriptPreference::Auto, OutputLanguagePreference::Auto, @@ -679,6 +717,7 @@ async fn validate_llm_provider() -> Result<(), String> { "验证连接", PolishMode::Raw, &[], + "", &[], ChineseScriptPreference::Auto, OutputLanguagePreference::Auto, @@ -1147,10 +1186,167 @@ pub async fn repolish( raw_text: String, mode: PolishMode, ) -> Result { + log::info!( + "[style-pack] command repolish requested legacy_mode={:?} raw_chars={}", + mode, + raw_text.chars().count() + ); coord.repolish(raw_text, mode).await } -// ─────────────────────────── style toggles (lightweight) ─────────────────────────── +// ─────────────────────────── style packs ─────────────────────────── + +#[tauri::command] +pub fn list_style_packs(coord: CoordinatorState<'_>) -> Result, String> { + let prefs = coord.prefs().get(); + coord + .style_packs() + .list_with_active(&prefs.active_style_pack_id) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn save_style_pack( + coord: CoordinatorState<'_>, + style_pack: StylePack, +) -> Result { + log::info!( + "[style-pack] command save id={} kind={:?} base_mode={:?}", + style_pack.id, + style_pack.kind, + style_pack.base_mode + ); + coord + .style_packs() + .upsert(style_pack) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_active_style_pack( + coord: CoordinatorState<'_>, + app: AppHandle, + id: String, +) -> Result { + let mut prefs = coord.prefs().get(); + let pack = coord.style_packs().get(&id).map_err(|e| e.to_string())?; + log::info!( + "[style-pack] command activate requested id={} kind={:?} base_mode={:?} enabled={}", + pack.id, + pack.kind, + pack.base_mode, + pack.enabled + ); + if !pack.enabled { + coord + .style_packs() + .set_enabled(&id, true) + .map_err(|e| e.to_string())?; + } + prefs.active_style_pack_id = id.clone(); + sync_style_pack_prefs_and_persist(&*coord, &app, prefs)?; + log::info!("[style-pack] command activate applied id={id}"); + coord + .style_packs() + .get(&id) + .map(|mut pack| { + pack.active = true; + pack + }) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_style_pack_enabled( + coord: CoordinatorState<'_>, + app: AppHandle, + id: String, + enabled: bool, +) -> Result, String> { + log::info!( + "[style-pack] command set_enabled requested id={} enabled={}", + id, + enabled + ); + coord + .style_packs() + .set_enabled(&id, enabled) + .map_err(|e| e.to_string())?; + let mut prefs = coord.prefs().get(); + if !enabled && prefs.active_style_pack_id == id { + prefs.active_style_pack_id = default_active_style_pack_id(); + } + let prefs = sync_style_pack_prefs_and_persist(&*coord, &app, prefs)?; + coord + .style_packs() + .list_with_active(&prefs.active_style_pack_id) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn reset_builtin_style_pack( + coord: CoordinatorState<'_>, + id: String, +) -> Result { + log::info!("[style-pack] command reset_builtin requested id={id}"); + coord + .style_packs() + .reset_builtin(&id) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn delete_style_pack( + coord: CoordinatorState<'_>, + app: AppHandle, + id: String, +) -> Result<(), String> { + let mut prefs = coord.prefs().get(); + log::info!("[style-pack] command delete requested id={id}"); + coord + .style_packs() + .remove_imported(&id) + .map_err(|e| e.to_string())?; + if prefs.active_style_pack_id == id { + prefs.active_style_pack_id = default_active_style_pack_id(); + let _ = sync_style_pack_prefs_and_persist(&*coord, &app, prefs)?; + } else { + refresh_tray_menu_async(&app); + } + Ok(()) +} + +#[tauri::command] +pub fn import_style_pack_from_zip( + coord: CoordinatorState<'_>, + zip_path: String, +) -> Result { + log::info!("[style-pack] command import requested zip_path={zip_path}"); + coord + .style_packs() + .import_from_zip(std::path::Path::new(&zip_path)) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn export_style_pack_to_zip( + coord: CoordinatorState<'_>, + id: String, + target_path: String, +) -> Result { + log::info!( + "[style-pack] command export requested id={} target_path={}", + id, + target_path + ); + coord + .style_packs() + .export_to_zip(&id, std::path::Path::new(&target_path)) + .map_err(|e| e.to_string())?; + Ok(target_path) +} + +// ─────────────────────────── style toggles (compat) ─────────────────────────── #[tauri::command] pub fn set_default_polish_mode( @@ -1159,39 +1355,45 @@ pub fn set_default_polish_mode( mode: PolishMode, ) -> Result<(), String> { let mut prefs = coord.prefs().get(); - prefs.default_mode = mode; + let pack_id = builtin_style_pack_id(mode).to_string(); + log::info!( + "[style-pack] compat set_default_polish_mode mode={:?} pack_id={}", + mode, + pack_id + ); coord - .prefs() - .set(prefs.clone()) + .style_packs() + .set_enabled(&pack_id, true) .map_err(|e| e.to_string())?; - // 跟 set_settings 同样:refresh_tray_microphone_menu 里 tray.set_menu 改 NSStatusItem, - // 必须主线程;这里是同步 Tauri command 跑在 IPC 线程,直调会让 macOS 死锁。 - let app_for_main = app.clone(); - let _ = app.run_on_main_thread(move || { - if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) { - log::warn!("[tray] refresh style menu after polish mode IPC change failed: {err}"); - } - }); - let _ = app.emit("prefs:changed", &prefs); - let _ = app.emit_to("main", "prefs:changed", &prefs); + prefs.active_style_pack_id = pack_id; + let _ = sync_style_pack_prefs_and_persist(&*coord, &app, prefs)?; Ok(()) } #[tauri::command] pub fn set_style_enabled( coord: CoordinatorState<'_>, + app: AppHandle, mode: PolishMode, enabled: bool, ) -> Result<(), String> { + let pack_id = builtin_style_pack_id(mode).to_string(); + log::info!( + "[style-pack] compat set_style_enabled mode={:?} pack_id={} enabled={}", + mode, + pack_id, + enabled + ); + coord + .style_packs() + .set_enabled(&pack_id, enabled) + .map_err(|e| e.to_string())?; let mut prefs = coord.prefs().get(); - if enabled { - if !prefs.enabled_modes.contains(&mode) { - prefs.enabled_modes.push(mode); - } - } else { - prefs.enabled_modes.retain(|m| *m != mode); + if !enabled && prefs.active_style_pack_id == pack_id { + prefs.active_style_pack_id = default_active_style_pack_id(); } - coord.prefs().set(prefs).map_err(|e| e.to_string()) + let _ = sync_style_pack_prefs_and_persist(&*coord, &app, prefs)?; + Ok(()) } // ─────────────────────────── 系统权限 ─────────────────────────── diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index cdff19a0..bdc46de0 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -32,8 +32,8 @@ use crate::coordinator_state::{ use crate::hotkey::{HotkeyEvent, HotkeyMonitor}; use crate::insertion::TextInserter; use crate::persistence::{ - CorrectionRuleStore, CredentialAccount, CredentialsVault, DictionaryStore, HistoryStore, - PreferencesStore, + sync_style_pack_preferences, CorrectionRuleStore, CredentialAccount, CredentialsVault, + DictionaryStore, HistoryStore, PreferencesStore, StylePackStore, }; use crate::llm_gemini::{GeminiConfig, GeminiProvider}; @@ -100,6 +100,7 @@ struct Inner { app: Mutex>, history: HistoryStore, prefs: PreferencesStore, + style_packs: StylePackStore, vocab: DictionaryStore, correction_rules: CorrectionRuleStore, inserter: TextInserter, @@ -186,6 +187,7 @@ impl Coordinator { HistoryStore::new().expect("history store init") }); let prefs = PreferencesStore::new().expect("preferences store init"); + let style_packs = StylePackStore::new(&prefs).expect("style pack store init"); let vocab = DictionaryStore::new().expect("dictionary store init"); let correction_rules = CorrectionRuleStore::new().expect("correction rule store init"); @@ -194,6 +196,7 @@ impl Coordinator { app: Mutex::new(None), history, prefs, + style_packs, vocab, correction_rules, inserter: TextInserter::new(), @@ -231,6 +234,7 @@ impl Coordinator { HistoryStore::new().expect("history store init") }); let prefs = PreferencesStore::new().expect("preferences store init"); + let style_packs = StylePackStore::new(&prefs).expect("style pack store init"); let vocab = DictionaryStore::new().expect("dictionary store init"); let correction_rules = CorrectionRuleStore::new().expect("correction rule store init"); @@ -239,6 +243,7 @@ impl Coordinator { app: Mutex::new(None), history, prefs, + style_packs, vocab, correction_rules, inserter: TextInserter::new(), @@ -656,6 +661,9 @@ impl Coordinator { pub fn prefs(&self) -> &PreferencesStore { &self.inner.prefs } + pub fn style_packs(&self) -> &StylePackStore { + &self.inner.style_packs + } pub fn vocab(&self) -> &DictionaryStore { &self.inner.vocab } @@ -793,10 +801,35 @@ 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 pack = self + .inner + .style_packs + .get_or_default_active(&prefs.active_style_pack_id) + .map_err(|e| e.to_string())?; + let style_system_prompt = pack.prompt.clone(); let working_languages = prefs.working_languages; let chinese_script_preference = prefs.chinese_script_preference; let output_language_preference = prefs.output_language_preference; let llm_thinking_enabled = prefs.llm_thinking_enabled; + let effective_mode = pack.base_mode; + log::info!( + "[style-pack] repolish dispatch active_pack={} kind={:?} effective_mode={:?} legacy_mode={:?} raw_chars={} prompt_chars={} hotwords={} thinking={}", + pack.id, + pack.kind, + effective_mode, + mode, + raw_text.chars().count(), + style_system_prompt.chars().count(), + hotwords.len(), + llm_thinking_enabled + ); + if effective_mode == PolishMode::Raw && !raw_style_pack_uses_llm(&pack) { + log::info!( + "[style-pack] repolish bypass llm active_pack={} reason=default_builtin_raw", + pack.id + ); + return Ok(raw_text); + } // repolish 是历史记录里手动重新润色,不再绑定原 session 的前台 app; // 当下用户调起的 app 才是相关上下文(如果可拿)。 let front_app = capture_frontmost_app(); @@ -804,8 +837,9 @@ impl Coordinator { // 用户改的就是这一条本身,不要把别的会话拿进来。所以始终走单轮路径。 polish_text( &raw_text, - mode, + effective_mode, &hotwords, + &style_system_prompt, &working_languages, chinese_script_preference, output_language_preference, @@ -818,6 +852,16 @@ impl Coordinator { } } +fn raw_style_pack_uses_llm(pack: &crate::types::StylePack) -> bool { + !(pack.kind == crate::types::StylePackKind::Builtin + && pack.id == crate::types::BUILTIN_STYLE_PACK_RAW_ID + && pack.prompt == crate::types::StyleSystemPrompts::default().raw) +} + +fn raw_mode_uses_llm(style_system_prompt: &str) -> bool { + style_system_prompt != crate::types::StyleSystemPrompts::default().raw +} + // ─────────────────────────── hotkey bridging ─────────────────────────── fn hotkey_supervisor_loop(inner: Arc) { @@ -1314,36 +1358,36 @@ 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(); + let packs = match inner.style_packs.list() { + Ok(packs) => packs, + Err(error) => { + log::warn!("[coord] switch style hotkey failed to load style packs: {error}"); + return; + } + }; + let enabled: Vec = + packs.into_iter().filter(|pack| pack.enabled).collect(); if enabled.len() <= 1 { log::info!("[coord] switch style hotkey ignored: enabled style count <= 1"); return; } let current_index = enabled .iter() - .position(|mode| *mode == prefs.default_mode) + .position(|pack| pack.id == prefs.active_style_pack_id) .unwrap_or(0); let next_index = if current_index == 0 { enabled.len() - 1 } else { current_index - 1 }; - prefs.default_mode = enabled[next_index]; + prefs.active_style_pack_id = enabled[next_index].id.clone(); + sync_style_pack_preferences(&mut prefs, &enabled); 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() + "[coord] switch style hotkey changed active style pack to {}", + prefs.active_style_pack_id ); } } @@ -2057,6 +2101,7 @@ pub async fn polish_or_passthrough_streaming( raw: &RawTranscript, mode: PolishMode, hotwords: &[String], + style_system_prompt: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, @@ -2070,7 +2115,7 @@ where F: Fn(&str) + Send + Sync, C: Fn() -> bool + Send + Sync, { - if mode == PolishMode::Raw { + if mode == PolishMode::Raw && !raw_mode_uses_llm(style_system_prompt) { log::info!("[coord] streaming polish skipped: mode=Raw, fall back to one-shot"); return StreamingPolishOutcome::UnsupportedFallback; } @@ -2105,6 +2150,7 @@ where &raw.text, mode, hotwords, + style_system_prompt, working_languages, chinese_script_preference, output_language_preference, @@ -2134,6 +2180,7 @@ async fn polish_or_passthrough( raw: &RawTranscript, mode: PolishMode, hotwords: &[String], + style_system_prompt: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, @@ -2141,13 +2188,14 @@ async fn polish_or_passthrough( front_app: Option<&str>, prior_turns: &[(String, String)], ) -> (String, Option) { - if mode == PolishMode::Raw { + if mode == PolishMode::Raw && !raw_mode_uses_llm(style_system_prompt) { return (raw.text.clone(), None); } match polish_text( &raw.text, mode, hotwords, + style_system_prompt, working_languages, chinese_script_preference, output_language_preference, @@ -2170,6 +2218,7 @@ async fn polish_text( raw: &str, mode: PolishMode, hotwords: &[String], + style_system_prompt: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, @@ -2191,6 +2240,7 @@ async fn polish_text( raw, mode, hotwords, + style_system_prompt, working_languages, chinese_script_preference, output_language_preference, @@ -2206,6 +2256,7 @@ async fn polish_text( raw, mode, hotwords, + style_system_prompt, working_languages, chinese_script_preference, output_language_preference, diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index dc2a37b3..1c391380 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use crate::coordinator_state::request_stop_during_starting_state; use crate::correction::apply_correction_rules; -use crate::types::{CorrectionRule, HotkeyMode}; +use crate::types::HotkeyMode; use super::qa::handle_qa_option_edge; use super::resources::*; @@ -38,56 +38,13 @@ const HOTKEY_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(25 /// **不在流式路径里做**:`apply_chinese_script_preference` / `apply_correction_rules` /// 这两步在 v1 跳过 —— 字符已经一边流一边落出去了,不好回退。需要的话只能关 toggle 走 /// 一次性路径。 -fn append_typed_prefix(typed_text: &mut String, delta: &str, typed_chars: usize) { - typed_text.extend(delta.chars().take(typed_chars)); -} - -fn finalize_dictation_text( - mut text: String, - already_streamed: bool, - translation_active: bool, - mode: PolishMode, - polish_failed: bool, - chinese_script_preference: crate::types::ChineseScriptPreference, - correction_rules: &[CorrectionRule], -) -> String { - if already_streamed { - return text; - } - - // 仅在“ASR 直出文本”场景做强制简繁收敛,避免误伤成功的翻译/常规 LLM 输出: - // - 非翻译模式:mode=Raw(本来就不走润色)或润色失败回退 raw - // - 翻译模式:仅翻译失败回退 raw 时才收敛 - let should_force_script = if translation_active { - polish_failed - } else { - mode == PolishMode::Raw || polish_failed - }; - if should_force_script { - text = apply_chinese_script_preference(&text, chinese_script_preference); - } - - if correction_rules.is_empty() { - return text; - } - - let corrected = apply_correction_rules(&text, correction_rules); - if corrected != text { - log::info!( - "[coord] correction rules adjusted final text ({} → {} chars)", - text.chars().count(), - corrected.chars().count() - ); - } - corrected -} - #[allow(clippy::too_many_arguments)] async fn run_streaming_polish( inner: &Arc, raw: &RawTranscript, mode: PolishMode, hotwords: &[String], + style_system_prompt: &str, working_languages: &[String], chinese_script_preference: crate::types::ChineseScriptPreference, output_language_preference: crate::types::OutputLanguagePreference, @@ -107,6 +64,7 @@ async fn run_streaming_polish( raw, mode, hotwords, + style_system_prompt, working_languages, chinese_script_preference, output_language_preference, @@ -136,6 +94,7 @@ async fn run_streaming_polish( raw, mode, hotwords, + style_system_prompt, working_languages, chinese_script_preference, output_language_preference, @@ -164,12 +123,10 @@ async fn run_streaming_polish( continue; } match crate::unicode_keystroke::type_unicode_chunk(&delta) { - Ok(typed_chars) => { - append_typed_prefix(&mut typed_text, &delta, typed_chars); + Ok(()) => { + typed_text.push_str(&delta); } Err(e) => { - let typed_chars = e.typed_chars(); - append_typed_prefix(&mut typed_text, &delta, typed_chars); log::error!( "[coord] streaming_insert: type_unicode_chunk failed at typed={} chars: {e}; \ dropping remaining deltas", @@ -189,6 +146,7 @@ async fn run_streaming_polish( raw, mode, hotwords, + style_system_prompt, working_languages, chinese_script_preference, output_language_preference, @@ -281,6 +239,7 @@ async fn run_streaming_polish( raw, mode, hotwords, + style_system_prompt, working_languages, chinese_script_preference, output_language_preference, @@ -1239,6 +1198,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { Vec::new() } }; + let front_app = inner.state.lock().front_app.clone(); if !correction_rules.is_empty() { let corrected = apply_correction_rules(&raw.text, &correction_rules); if corrected != raw.text { @@ -1250,26 +1210,51 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { raw.text = corrected; } } - emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None); let prefs = inner.prefs.get(); - let mode = prefs.default_mode; + let pack = match inner + .style_packs + .get_or_default_active(&prefs.active_style_pack_id) + { + Ok(pack) => pack, + Err(error) => { + log::warn!( + "[coord] active style pack unavailable, falling back to builtin light: {error}" + ); + crate::types::builtin_style_pack_for_mode(PolishMode::Light) + } + }; + let mode = pack.base_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 llm_thinking_enabled = prefs.llm_thinking_enabled; - let front_app = inner.state.lock().front_app.clone(); + let style_system_prompt = pack.prompt.clone(); + let raw_uses_llm = mode == PolishMode::Raw && super::raw_style_pack_uses_llm(&pack); let translation_target = prefs.translation_target_language.trim().to_string(); let translation_active = inner.translation_modifier_seen.load(Ordering::SeqCst) && !translation_target.is_empty(); + log::info!( + "[style-pack] runtime dispatch session_id={} active_pack={} kind={:?} mode={:?} raw_chars={} prompt_chars={} raw_uses_llm={} translation_active={} hotwords={} working_languages={:?}", + current_session_id, + pack.id, + pack.kind, + mode, + raw.text.chars().count(), + style_system_prompt.chars().count(), + raw_uses_llm, + translation_active, + hotword_strs.len(), + working_languages + ); // 对话感知 polish:拉最近 N 分钟的会话作为 LLM 上下文。仅在非翻译路径且非 Raw mode // 才有意义(Raw 不走 LLM、翻译走单轮独立 prompt)。窗口=0 时 prior_turns 是空 Vec, // polish 路径自动退化成单轮单消息——跟历史行为一致。 let polish_context_window_minutes = prefs.polish_context_window_minutes; let prior_turns: Vec<(String, String)> = if !translation_active - && mode != PolishMode::Raw + && (mode != PolishMode::Raw || raw_uses_llm) && polish_context_window_minutes > 0 { match inner @@ -1294,7 +1279,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { // 流式插入 opt-in 路径:开关打开 + 非翻译 + 非 Raw 模式 → 进入流式分支。 // 任何不满足都走原一次性 polish_or_passthrough 路径,行为跟历史完全一致。 let streaming_eligible = - prefs.streaming_insert && !translation_active && mode != PolishMode::Raw; + prefs.streaming_insert && !translation_active && (mode != PolishMode::Raw || raw_uses_llm); log::info!( "[coord] polish dispatch: translation={translation_active} mode={mode:?} streaming_eligible={streaming_eligible}" ); @@ -1323,6 +1308,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { &raw, mode, &hotword_strs, + &style_system_prompt, &working_languages, chinese_script_preference, output_language_preference, @@ -1336,6 +1322,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { &raw, mode, &hotword_strs, + &style_system_prompt, &working_languages, chinese_script_preference, output_language_preference, @@ -1347,16 +1334,32 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { (p, e, false) }; - let polished = finalize_dictation_text( - polished, - already_streamed, - translation_active, - mode, - polish_error.is_some(), - chinese_script_preference, - &correction_rules, - ); - + // 仅在“ASR 直出文本”场景做强制简繁收敛,避免误伤成功的翻译/常规 LLM 输出: + // - 非翻译模式:mode=Raw(本来就不走润色)或润色失败回退 raw + // - 翻译模式:仅翻译失败回退 raw 时才收敛 + let should_force_script = if translation_active { + polish_error.is_some() + } else { + (!raw_uses_llm && mode == PolishMode::Raw) || polish_error.is_some() + }; + let polished = if should_force_script { + apply_chinese_script_preference(&polished, chinese_script_preference) + } else { + polished + }; + let polished = if correction_rules.is_empty() { + polished + } else { + let corrected = apply_correction_rules(&polished, &correction_rules); + if corrected != polished { + log::info!( + "[coord] correction rules adjusted final text ({} → {} chars)", + polished.chars().count(), + corrected.chars().count() + ); + } + corrected + }; // 原子化最后一次 cancel 检查 + 转 Inserting: // 在同一 lock 内决定「丢弃」还是「进入 Inserting」。一旦设到 Inserting, // cancel_session 就拒绝介入(Cmd+V 已发出,撤销不掉)。这是 audit HIGH #2 的修复, @@ -1460,9 +1463,11 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { .map(str::to_string); let tsf_required_insert_failed = error_code.as_deref() == Some("windowsImeTsfRequired"); + let history_session_id = Uuid::new_v4().to_string(); + let history_created_at = Utc::now().to_rfc3339(); let session = DictationSession { - id: Uuid::new_v4().to_string(), - created_at: Utc::now().to_rfc3339(), + id: history_session_id.clone(), + created_at: history_created_at.clone(), raw_transcript: raw.text.clone(), final_text: polished.clone(), mode, @@ -1481,7 +1486,6 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { { log::error!("[coord] history append failed: {e}"); } - let done_message = if tsf_required_insert_failed { Some("TSF 未上屏,已禁止非 TSF 兜底".to_string()) } else if polish_error.is_some() { @@ -1566,69 +1570,3 @@ pub(super) fn cancel_session(inner: &Arc) { log::info!("[coord] session cancelled (was {:?})", decision.phase); schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); } - -#[cfg(test)] -mod tests { - use super::{append_typed_prefix, finalize_dictation_text}; - use crate::types::PolishMode; - - #[test] - fn append_typed_prefix_truncates_by_chars_not_bytes() { - let mut typed = String::from("已"); - append_typed_prefix(&mut typed, "你好🙂abc", 3); - assert_eq!(typed, "已你好🙂"); - } - - #[test] - fn append_typed_prefix_ignores_overlarge_count() { - let mut typed = String::new(); - append_typed_prefix(&mut typed, "好", 99); - assert_eq!(typed, "好"); - } - - #[test] - fn streamed_text_skips_mutating_final_postprocess() { - let rules = vec![crate::types::CorrectionRule { - id: "r1".into(), - pattern: "错词".into(), - replacement: "正词".into(), - enabled: true, - created_at: "2026-01-01T00:00:00Z".into(), - }]; - - let text = finalize_dictation_text( - "錯詞".to_string(), - true, - false, - PolishMode::Raw, - true, - crate::types::ChineseScriptPreference::Simplified, - &rules, - ); - - assert_eq!(text, "錯詞"); - } - - #[test] - fn non_streamed_text_still_applies_final_postprocess() { - let rules = vec![crate::types::CorrectionRule { - id: "r1".into(), - pattern: "错词".into(), - replacement: "正词".into(), - enabled: true, - created_at: "2026-01-01T00:00:00Z".into(), - }]; - - let text = finalize_dictation_text( - "錯詞".to_string(), - false, - false, - PolishMode::Raw, - false, - crate::types::ChineseScriptPreference::Simplified, - &rules, - ); - - assert_eq!(text, "正词"); - } -} diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 99452110..26855bae 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -265,6 +265,7 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ commands::get_settings, + commands::get_default_style_system_prompts, commands::set_settings, commands::get_update_channel, commands::set_update_channel, @@ -299,6 +300,14 @@ pub fn run() { #[cfg(debug_assertions)] commands::inject_hotkey_click_for_dev, commands::repolish, + commands::list_style_packs, + commands::save_style_pack, + commands::set_active_style_pack, + commands::set_style_pack_enabled, + commands::reset_builtin_style_pack, + commands::delete_style_pack, + commands::import_style_pack_from_zip, + commands::export_style_pack_to_zip, commands::set_default_polish_mode, commands::set_style_enabled, commands::check_accessibility_permission, diff --git a/openless-all/app/src-tauri/src/llm_gemini.rs b/openless-all/app/src-tauri/src/llm_gemini.rs index 88f25603..378ed811 100644 --- a/openless-all/app/src-tauri/src/llm_gemini.rs +++ b/openless-all/app/src-tauri/src/llm_gemini.rs @@ -82,6 +82,7 @@ impl GeminiProvider { raw_text: &str, mode: PolishMode, hotwords: &[String], + style_system_prompt: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, @@ -92,6 +93,7 @@ impl GeminiProvider { raw_text, mode, hotwords, + style_system_prompt, working_languages, chinese_script_preference, output_language_preference, diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index 43f45e7d..1041173b 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -12,6 +12,7 @@ //! after a successful vault write; new writes never persist plaintext secrets. use std::fs; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::sync::OnceLock; @@ -22,12 +23,17 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::types::{ - CorrectionRule, DictationSession, DictionaryEntry, UserPreferences, VocabPresetStore, + builtin_style_pack_for_mode, builtin_style_pack_id, builtin_style_packs, + default_active_style_pack_id, CorrectionRule, DictationSession, DictionaryEntry, PolishMode, + StylePack, StylePackExample, StylePackKind, UserPreferences, VocabPresetStore, + BUILTIN_STYLE_PACK_LIGHT_ID, }; const HISTORY_CAP: usize = 200; const HISTORY_FILE: &str = "history.json"; const PREFERENCES_FILE: &str = "preferences.json"; +const STYLE_PACKS_FILE: &str = "style-packs.json"; +const STYLE_PACK_ASSETS_DIR: &str = "style-pack-assets"; /// 与 Swift `Sources/OpenLessPersistence/DictionaryStore.swift` 同名, /// 让旧版词汇表在升级后无缝继承。**不要**改成 `vocab.json`,会丢用户数据。 const VOCAB_FILE: &str = "dictionary.json"; @@ -900,6 +906,725 @@ impl PreferencesStore { } } +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StylePackArchiveManifest { + schema_version: u32, + id: String, + name: String, + description: String, + author: Option, + version: String, + base_mode: PolishMode, + tags: Vec, + prompt_file: String, + examples_file: String, + icon_file: Option, + recommended_model: Option, + compatible_app_version: Option, +} + +pub struct StylePackStore { + path: PathBuf, + asset_root: PathBuf, + state: Mutex>, +} + +impl StylePackStore { + pub fn new(prefs: &PreferencesStore) -> Result { + let dir = data_dir()?; + ensure_dir(&dir)?; + let path = dir.join(STYLE_PACKS_FILE); + let asset_root = dir.join(STYLE_PACK_ASSETS_DIR); + ensure_dir(&asset_root)?; + + let mut packs = if path.exists() { + read_or_default::>(&path).unwrap_or_else(|error| { + log::warn!( + "[style-packs] load {} failed, using builtin defaults: {}", + path.display(), + error + ); + Vec::new() + }) + } else { + Vec::new() + }; + + let mut prefs_snapshot = prefs.get(); + let mut changed = migrate_style_packs_from_preferences(&mut packs, &prefs_snapshot); + if ensure_at_least_one_style_pack_enabled(&mut packs) { + changed = true; + } + let active_pref_for_log = prefs_snapshot.active_style_pack_id.clone(); + let enabled_modes_for_log = prefs_snapshot.enabled_modes.clone(); + if sync_style_pack_preferences(&mut prefs_snapshot, &packs) { + prefs.set(prefs_snapshot)?; + } + if changed { + write_style_packs_file(&path, &packs)?; + } + log::info!( + "[style-pack] store ready: file={} packs={} changed={} active_pref={} enabled_modes={:?}", + path.display(), + packs.len(), + changed, + active_pref_for_log, + enabled_modes_for_log + ); + + Ok(Self { + path, + asset_root, + state: Mutex::new(packs), + }) + } + + pub fn list(&self) -> Result> { + Ok(self.state.lock().clone()) + } + + pub fn list_with_active(&self, active_style_pack_id: &str) -> Result> { + let mut packs = self.list()?; + for pack in &mut packs { + pack.active = pack.id == active_style_pack_id; + } + Ok(packs) + } + + pub fn get(&self, id: &str) -> Result { + self.state + .lock() + .iter() + .find(|pack| pack.id == id) + .cloned() + .ok_or_else(|| anyhow!("style pack {} not found", id)) + } + + pub fn get_or_default_active(&self, active_style_pack_id: &str) -> Result { + let packs = self.state.lock().clone(); + if let Some(pack) = packs + .iter() + .find(|pack| pack.id == active_style_pack_id && pack.enabled) + .cloned() + { + return Ok(pack); + } + if let Some(pack) = packs + .iter() + .find(|pack| pack.id == BUILTIN_STYLE_PACK_LIGHT_ID) + .cloned() + { + return Ok(pack); + } + packs + .into_iter() + .find(|pack| pack.enabled) + .ok_or_else(|| anyhow!("no enabled style pack available")) + } + + pub fn upsert(&self, incoming: StylePack) -> Result { + let mut packs = self.state.lock(); + let index = packs + .iter() + .position(|pack| pack.id == incoming.id) + .ok_or_else(|| anyhow!("style pack {} not found", incoming.id))?; + let existing = packs[index].clone(); + let updated = merge_style_pack_update(existing, incoming)?; + packs[index] = updated.clone(); + write_style_packs_file(&self.path, &packs)?; + log::info!( + "[style-pack] saved id={} kind={:?} base_mode={:?} prompt_chars={} examples={} tags={} version={}", + updated.id, + updated.kind, + updated.base_mode, + updated.prompt.chars().count(), + updated.examples.len(), + updated.tags.len(), + updated.version + ); + Ok(updated) + } + + pub fn set_enabled(&self, id: &str, enabled: bool) -> Result { + let mut packs = self.state.lock(); + let index = packs + .iter() + .position(|pack| pack.id == id) + .ok_or_else(|| anyhow!("style pack {} not found", id))?; + packs[index].enabled = enabled; + packs[index].updated_at = Some(Utc::now().to_rfc3339()); + if ensure_at_least_one_style_pack_enabled(&mut packs) { + packs[index].updated_at = Some(Utc::now().to_rfc3339()); + } + let updated = packs[index].clone(); + write_style_packs_file(&self.path, &packs)?; + log::info!( + "[style-pack] set_enabled id={} enabled={} base_mode={:?}", + updated.id, + updated.enabled, + updated.base_mode + ); + Ok(updated) + } + + pub fn reset_builtin(&self, id: &str) -> Result { + let mode = builtin_mode_from_style_pack_id(id) + .ok_or_else(|| anyhow!("style pack {} is not a builtin pack", id))?; + let mut packs = self.state.lock(); + let index = packs + .iter() + .position(|pack| pack.id == id) + .ok_or_else(|| anyhow!("style pack {} not found", id))?; + let existing = packs[index].clone(); + let mut reset = builtin_style_pack_for_mode(mode); + reset.enabled = existing.enabled; + reset.created_at = existing + .created_at + .or_else(|| Some(Utc::now().to_rfc3339())); + reset.updated_at = Some(Utc::now().to_rfc3339()); + packs[index] = reset.clone(); + write_style_packs_file(&self.path, &packs)?; + log::info!( + "[style-pack] reset_builtin id={} base_mode={:?} prompt_chars={} examples={}", + reset.id, + reset.base_mode, + reset.prompt.chars().count(), + reset.examples.len() + ); + Ok(reset) + } + + pub fn remove_imported(&self, id: &str) -> Result<()> { + let mut packs = self.state.lock(); + let index = packs + .iter() + .position(|pack| pack.id == id) + .ok_or_else(|| anyhow!("style pack {} not found", id))?; + if packs[index].kind == StylePackKind::Builtin { + return Err(anyhow!("builtin style pack cannot be deleted")); + } + let removed = packs[index].clone(); + remove_style_pack_assets(&self.asset_root, &packs[index]); + packs.remove(index); + if ensure_at_least_one_style_pack_enabled(&mut packs) { + // write updated fallback state as well + } + write_style_packs_file(&self.path, &packs)?; + log::info!( + "[style-pack] removed imported id={} base_mode={:?}", + removed.id, + removed.base_mode + ); + Ok(()) + } + + pub fn import_from_zip(&self, zip_path: &Path) -> Result { + let file = fs::File::open(zip_path) + .with_context(|| format!("open style pack zip failed: {}", zip_path.display()))?; + let mut archive = zip::ZipArchive::new(file).context("open style pack zip archive")?; + let manifest: StylePackArchiveManifest = + read_zip_json_entry(&mut archive, "manifest.json")?; + let prompt = read_zip_string_entry(&mut archive, &manifest.prompt_file)?; + let examples = + read_zip_json_entry::>(&mut archive, &manifest.examples_file)?; + + let mut packs = self.state.lock(); + let now = Utc::now().to_rfc3339(); + let pack_id = unique_imported_style_pack_id(&packs, &manifest.id); + let icon_path = if let Some(icon_file) = manifest.icon_file.as_deref() { + extract_style_pack_icon(&mut archive, &self.asset_root, &pack_id, icon_file)? + } else { + None + }; + let pack = StylePack { + id: pack_id, + name: manifest.name.trim().to_string(), + description: manifest.description.trim().to_string(), + author: manifest + .author + .and_then(|value| normalize_optional_text(Some(value))), + version: normalize_version(&manifest.version), + kind: StylePackKind::Imported, + base_mode: manifest.base_mode, + prompt, + examples, + tags: normalize_tags(&manifest.tags), + icon_path, + created_at: Some(now.clone()), + updated_at: Some(now), + enabled: true, + active: false, + recommended_model: manifest + .recommended_model + .and_then(|value| normalize_optional_text(Some(value))), + compatible_app_version: manifest + .compatible_app_version + .and_then(|value| normalize_optional_text(Some(value))), + }; + packs.insert(0, pack.clone()); + write_style_packs_file(&self.path, &packs)?; + log::info!( + "[style-pack] imported source={} installed_id={} manifest_id={} base_mode={:?} prompt_chars={} examples={} tags={} icon={}", + zip_path.display(), + pack.id, + manifest.id, + pack.base_mode, + pack.prompt.chars().count(), + pack.examples.len(), + pack.tags.len(), + pack.icon_path.is_some() + ); + Ok(pack) + } + + pub fn export_to_zip(&self, id: &str, target_path: &Path) -> Result<()> { + let pack = self.get(id)?; + if let Some(parent) = target_path.parent() { + ensure_dir(parent)?; + } + let file = fs::File::create(target_path) + .with_context(|| format!("create style pack zip failed: {}", target_path.display()))?; + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + + let icon_file = pack + .icon_path + .as_deref() + .and_then(|path| Path::new(path).file_name()) + .and_then(|file_name| file_name.to_str()) + .map(|name| format!("assets/{name}")); + + let manifest = StylePackArchiveManifest { + schema_version: 1, + id: pack.id.clone(), + name: pack.name.clone(), + description: pack.description.clone(), + author: pack.author.clone(), + version: pack.version.clone(), + base_mode: pack.base_mode, + tags: pack.tags.clone(), + prompt_file: "prompt.md".into(), + examples_file: "examples.json".into(), + icon_file: icon_file.clone(), + recommended_model: pack.recommended_model.clone(), + compatible_app_version: pack.compatible_app_version.clone(), + }; + + zip.start_file("manifest.json", options) + .context("write style pack manifest entry")?; + zip.write_all( + serde_json::to_string_pretty(&manifest) + .context("encode style pack manifest")? + .as_bytes(), + ) + .context("write style pack manifest body")?; + + zip.start_file("prompt.md", options) + .context("write style pack prompt entry")?; + zip.write_all(pack.prompt.as_bytes()) + .context("write style pack prompt body")?; + + zip.start_file("examples.json", options) + .context("write style pack examples entry")?; + zip.write_all( + serde_json::to_string_pretty(&pack.examples) + .context("encode style pack examples")? + .as_bytes(), + ) + .context("write style pack examples body")?; + + if let (Some(source_icon_path), Some(zip_icon_path)) = (&pack.icon_path, &icon_file) { + let icon_source = Path::new(source_icon_path); + if icon_source.exists() { + zip.start_file(zip_icon_path, options) + .context("write style pack icon entry")?; + let bytes = fs::read(icon_source).with_context(|| { + format!("read style pack icon failed: {}", icon_source.display()) + })?; + zip.write_all(&bytes) + .context("write style pack icon body")?; + } + } + + zip.finish().context("finalize style pack zip")?; + log::info!( + "[style-pack] exported id={} target={} base_mode={:?} prompt_chars={} examples={} icon={}", + pack.id, + target_path.display(), + pack.base_mode, + pack.prompt.chars().count(), + pack.examples.len(), + pack.icon_path.is_some() + ); + Ok(()) + } +} + +fn write_style_packs_file(path: &Path, packs: &[StylePack]) -> Result<()> { + let json = serde_json::to_vec_pretty(packs).context("encode style packs failed")?; + atomic_write(path, &json) +} + +fn migrate_style_packs_from_preferences( + packs: &mut Vec, + prefs: &UserPreferences, +) -> bool { + let mut changed = false; + let legacy_prompts = prefs.style_system_prompts.clone(); + for builtin in builtin_style_packs() { + if let Some(index) = packs.iter().position(|pack| pack.id == builtin.id) { + let pack = &mut packs[index]; + if pack.kind != StylePackKind::Builtin { + pack.kind = StylePackKind::Builtin; + changed = true; + } + if pack.name.trim().is_empty() { + pack.name = builtin.name.clone(); + changed = true; + } + if pack.description.trim().is_empty() { + pack.description = builtin.description.clone(); + changed = true; + } + if pack.prompt.trim().is_empty() { + pack.prompt = builtin.prompt.clone(); + changed = true; + } + if pack.examples.is_empty() { + pack.examples = builtin.examples.clone(); + changed = true; + } + if pack.tags.is_empty() { + pack.tags = builtin.tags.clone(); + changed = true; + } + if pack.version.trim().is_empty() { + pack.version = builtin.version.clone(); + changed = true; + } + if pack.author.is_none() { + pack.author = builtin.author.clone(); + changed = true; + } + if pack.compatible_app_version.is_none() { + pack.compatible_app_version = builtin.compatible_app_version.clone(); + changed = true; + } + if pack.created_at.is_none() { + pack.created_at = Some(Utc::now().to_rfc3339()); + changed = true; + } + if pack.base_mode != builtin.base_mode { + pack.base_mode = builtin.base_mode; + changed = true; + } + } else { + let mut pack = builtin.clone(); + pack.prompt = legacy_prompts.for_mode(pack.base_mode).to_string(); + pack.enabled = prefs.enabled_modes.contains(&pack.base_mode); + pack.created_at = Some(Utc::now().to_rfc3339()); + pack.updated_at = Some(Utc::now().to_rfc3339()); + packs.push(pack); + changed = true; + } + } + packs.sort_by(|left, right| { + style_pack_sort_key(left) + .cmp(&style_pack_sort_key(right)) + .then_with(|| left.name.cmp(&right.name)) + }); + changed +} + +fn style_pack_sort_key(pack: &StylePack) -> (u8, u8) { + let kind_rank = match pack.kind { + StylePackKind::Builtin => 0, + StylePackKind::Imported => 1, + }; + let mode_rank = match pack.base_mode { + PolishMode::Raw => 0, + PolishMode::Light => 1, + PolishMode::Structured => 2, + PolishMode::Formal => 3, + }; + (kind_rank, mode_rank) +} + +fn ensure_at_least_one_style_pack_enabled(packs: &mut [StylePack]) -> bool { + if packs.iter().any(|pack| pack.enabled) { + return false; + } + if let Some(pack) = packs + .iter_mut() + .find(|pack| pack.id == default_active_style_pack_id()) + { + pack.enabled = true; + pack.updated_at = Some(Utc::now().to_rfc3339()); + return true; + } + if let Some(first) = packs.first_mut() { + first.enabled = true; + first.updated_at = Some(Utc::now().to_rfc3339()); + return true; + } + false +} + +pub fn sync_style_pack_preferences(prefs: &mut UserPreferences, packs: &[StylePack]) -> bool { + let previous_active_style_pack_id = prefs.active_style_pack_id.clone(); + let previous_default_mode = prefs.default_mode; + let previous_enabled_modes = prefs.enabled_modes.clone(); + let enabled: Vec<&StylePack> = packs.iter().filter(|pack| pack.enabled).collect(); + let active = packs + .iter() + .find(|pack| pack.id == prefs.active_style_pack_id && pack.enabled) + .or_else(|| { + packs + .iter() + .find(|pack| pack.id == builtin_style_pack_id(prefs.default_mode) && pack.enabled) + }) + .or_else(|| enabled.first().copied()); + + let Some(active_pack) = active else { + return false; + }; + + let mut changed = false; + if prefs.active_style_pack_id != active_pack.id { + prefs.active_style_pack_id = active_pack.id.clone(); + changed = true; + } + if prefs.default_mode != active_pack.base_mode { + prefs.default_mode = active_pack.base_mode; + changed = true; + } + + let next_enabled_modes = enabled_modes_from_style_packs(packs); + if prefs.enabled_modes != next_enabled_modes { + prefs.enabled_modes = next_enabled_modes; + changed = true; + } + + if changed { + log::info!( + "[style-pack] sync_prefs active:{}->{} default_mode:{:?}->{:?} enabled_modes:{:?}->{:?}", + previous_active_style_pack_id, + prefs.active_style_pack_id, + previous_default_mode, + prefs.default_mode, + previous_enabled_modes, + prefs.enabled_modes + ); + } + + changed +} + +pub fn enabled_modes_from_style_packs(packs: &[StylePack]) -> Vec { + let mut modes = Vec::new(); + for mode in [ + PolishMode::Raw, + PolishMode::Light, + PolishMode::Structured, + PolishMode::Formal, + ] { + if packs + .iter() + .any(|pack| pack.enabled && pack.base_mode == mode) + { + modes.push(mode); + } + } + modes +} + +fn builtin_mode_from_style_pack_id(id: &str) -> Option { + for mode in [ + PolishMode::Raw, + PolishMode::Light, + PolishMode::Structured, + PolishMode::Formal, + ] { + if builtin_style_pack_id(mode) == id { + return Some(mode); + } + } + None +} + +fn merge_style_pack_update(existing: StylePack, incoming: StylePack) -> Result { + if existing.id != incoming.id { + return Err(anyhow!("style pack id cannot be changed")); + } + let mut updated = existing; + updated.name = normalize_required_text(&incoming.name, "style pack name")?; + updated.description = incoming.description.trim().to_string(); + updated.author = normalize_optional_text(incoming.author); + updated.version = normalize_version(&incoming.version); + updated.prompt = incoming.prompt; + updated.examples = normalize_examples(incoming.examples); + updated.tags = normalize_tags(&incoming.tags); + updated.recommended_model = normalize_optional_text(incoming.recommended_model); + updated.compatible_app_version = normalize_optional_text(incoming.compatible_app_version); + updated.updated_at = Some(Utc::now().to_rfc3339()); + Ok(updated) +} + +fn normalize_examples(examples: Vec) -> Vec { + examples + .into_iter() + .filter_map(|example| { + let input = example.input.trim().to_string(); + let output = example.output.trim().to_string(); + if input.is_empty() && output.is_empty() { + return None; + } + Some(StylePackExample { + title: normalize_optional_text(example.title), + input, + output, + }) + }) + .collect() +} + +fn normalize_tags(tags: &[String]) -> Vec { + let mut normalized = Vec::new(); + for tag in tags { + let trimmed = tag.trim(); + if trimmed.is_empty() || normalized.iter().any(|existing| existing == trimmed) { + continue; + } + normalized.push(trimmed.to_string()); + } + normalized +} + +fn normalize_optional_text(value: Option) -> Option { + value.and_then(|text| { + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn normalize_required_text(value: &str, field: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(anyhow!("{field} is empty")); + } + Ok(trimmed.to_string()) +} + +fn normalize_version(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + "1.0.0".into() + } else { + trimmed.to_string() + } +} + +fn unique_imported_style_pack_id(existing: &[StylePack], requested_id: &str) -> String { + let base = sanitize_style_pack_id(requested_id); + if !existing.iter().any(|pack| pack.id == base) { + return base; + } + let mut index = 2usize; + loop { + let candidate = format!("{base}-{index}"); + if !existing.iter().any(|pack| pack.id == candidate) { + return candidate; + } + index = index.saturating_add(1); + } +} + +fn sanitize_style_pack_id(requested_id: &str) -> String { + let mut output = String::new(); + for ch in requested_id.trim().chars() { + if ch.is_ascii_alphanumeric() { + output.push(ch.to_ascii_lowercase()); + } else if matches!(ch, '-' | '_' | '.') { + output.push(ch); + } else if matches!(ch, ' ' | '/' | '\\') { + output.push('-'); + } + } + let compact = output.trim_matches('-').trim_matches('.').trim_matches('_'); + if compact.is_empty() { + format!("imported-{}", Uuid::new_v4().simple()) + } else if compact.starts_with("builtin.") { + format!("imported.{compact}") + } else { + compact.to_string() + } +} + +fn read_zip_json_entry Deserialize<'de>>( + archive: &mut zip::ZipArchive, + entry_name: &str, +) -> Result { + let text = read_zip_string_entry(archive, entry_name)?; + serde_json::from_str(&text) + .with_context(|| format!("decode style pack zip entry failed: {entry_name}")) +} + +fn read_zip_string_entry( + archive: &mut zip::ZipArchive, + entry_name: &str, +) -> Result { + let mut file = archive + .by_name(entry_name) + .with_context(|| format!("missing style pack zip entry: {entry_name}"))?; + let mut buffer = String::new(); + file.read_to_string(&mut buffer) + .with_context(|| format!("read style pack zip entry failed: {entry_name}"))?; + Ok(buffer) +} + +fn extract_style_pack_icon( + archive: &mut zip::ZipArchive, + asset_root: &Path, + pack_id: &str, + entry_name: &str, +) -> Result> { + let mut file = archive + .by_name(entry_name) + .with_context(|| format!("missing style pack icon entry: {entry_name}"))?; + let file_name = Path::new(entry_name) + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow!("invalid style pack icon file name"))?; + let target_dir = asset_root.join(pack_id); + ensure_dir(&target_dir)?; + let target_path = target_dir.join(file_name); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .with_context(|| format!("read style pack icon failed: {entry_name}"))?; + fs::write(&target_path, &bytes) + .with_context(|| format!("write style pack icon failed: {}", target_path.display()))?; + Ok(Some(target_path.to_string_lossy().to_string())) +} + +fn remove_style_pack_assets(asset_root: &Path, pack: &StylePack) { + if let Some(icon_path) = pack.icon_path.as_deref() { + let path = Path::new(icon_path); + let _ = fs::remove_file(path); + if let Some(parent) = path.parent() { + let _ = fs::remove_dir(parent); + } + } else { + let dir = asset_root.join(&pack.id); + let _ = fs::remove_dir_all(dir); + } +} + // ───────────────────────── DictionaryStore ───────────────────────── pub struct DictionaryStore { diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 8877d745..e74fbb6c 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -100,6 +100,7 @@ impl ActiveLLMProvider { raw_text: &str, mode: PolishMode, hotwords: &[String], + style_system_prompt: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, @@ -119,6 +120,7 @@ impl ActiveLLMProvider { raw_text, mode, hotwords, + style_system_prompt, working_languages, chinese_script_preference, output_language_preference, @@ -140,6 +142,7 @@ impl ActiveLLMProvider { raw_text: &str, mode: PolishMode, hotwords: &[String], + style_system_prompt: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, @@ -153,6 +156,7 @@ impl ActiveLLMProvider { raw_text, mode, hotwords, + style_system_prompt, working_languages, chinese_script_preference, output_language_preference, @@ -167,6 +171,7 @@ impl ActiveLLMProvider { raw_text, mode, hotwords, + style_system_prompt, working_languages, chinese_script_preference, output_language_preference, @@ -285,6 +290,7 @@ impl OpenAICompatibleLLMProvider { raw_text: &str, mode: PolishMode, hotwords: &[String], + style_system_prompt: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, @@ -295,6 +301,7 @@ impl OpenAICompatibleLLMProvider { raw_text, mode, hotwords, + style_system_prompt, working_languages, chinese_script_preference, output_language_preference, @@ -318,6 +325,7 @@ impl OpenAICompatibleLLMProvider { raw_text: &str, mode: PolishMode, hotwords: &[String], + style_system_prompt: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, @@ -334,6 +342,7 @@ impl OpenAICompatibleLLMProvider { raw_text, mode, hotwords, + style_system_prompt, working_languages, chinese_script_preference, output_language_preference, @@ -912,29 +921,24 @@ impl CodexOAuthLLMProvider { raw_text: &str, mode: PolishMode, hotwords: &[String], + style_system_prompt: &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); - if let Some(premise) = context_premise( + let (system_prompt, user_prompt) = compose_polish_prompts( + raw_text, + mode, + hotwords, + style_system_prompt, working_languages, chinese_script_preference, output_language_preference, front_app, - ) { - system_prompt = format!("{}\n\n{}", premise, system_prompt); - } - if !prior_turns.is_empty() { - system_prompt = format!( - "{}\n\n{}", - system_prompt, - prompts::polish_context_instruction() - ); - } - let user_prompt = prompts::user_prompt(raw_text); + !prior_turns.is_empty(), + ); let messages = build_polish_history_messages(&system_prompt, prior_turns, &user_prompt); self.codex_responses(messages, |_| {}, || false).await } @@ -1580,15 +1584,16 @@ fn context_premise( /// polish_context_instruction 追加条件上慢慢漂移。 pub(crate) fn compose_polish_prompts( raw_text: &str, - mode: PolishMode, + _mode: PolishMode, hotwords: &[String], + style_system_prompt: &str, working_languages: &[String], chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, front_app: Option<&str>, has_prior_turns: bool, ) -> (String, String) { - let mut system_prompt = compose_system_prompt(mode, hotwords); + let mut system_prompt = compose_system_prompt(style_system_prompt, hotwords); if let Some(premise) = context_premise( working_languages, chinese_script_preference, @@ -1652,8 +1657,8 @@ pub(crate) fn compose_qa_system_prompt( system_prompt } -fn compose_system_prompt(mode: PolishMode, hotwords: &[String]) -> String { - let base = prompts::system_prompt(mode); +fn compose_system_prompt(style_system_prompt: &str, hotwords: &[String]) -> String { + let base = style_system_prompt.trim_end().to_string(); let cleaned: Vec = hotwords .iter() .map(|h| h.trim().to_string()) @@ -2790,8 +2795,10 @@ 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( + &prompts::system_prompt(PolishMode::Light), + &["GitHub".into(), "OpenLess".into()], + ); assert!(prompt.contains("用户希望以下写法在输出中保持准确")); assert!(prompt.contains("同音 / 近形误识别时,优先按上述写法输出")); @@ -2799,6 +2806,13 @@ mod tests { assert!(prompt.contains("- OpenLess")); } + #[test] + fn compose_system_prompt_uses_user_style_system_prompt_as_base() { + let prompt = compose_system_prompt("像正式邮件,但结尾不要客套话", &[]); + + assert_eq!(prompt, "像正式邮件,但结尾不要客套话"); + } + #[test] fn common_rules_include_auto_correction_and_natural_organization() { // 所有 mode 都要带上"自动纠错"(规则 5)和"按整体意图组织成自然书面表达" @@ -2957,6 +2971,7 @@ mod tests { "原文", PolishMode::Raw, &[], + "", &[], ChineseScriptPreference::Auto, OutputLanguagePreference::Auto, @@ -3015,6 +3030,7 @@ mod tests { "原文", PolishMode::Raw, &[], + "", &[], ChineseScriptPreference::Auto, OutputLanguagePreference::Auto, diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index c0bc5e37..b766b281 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -147,6 +147,276 @@ pub struct VocabPresetStore { pub disabled_builtin_preset_ids: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +pub struct CustomStylePrompts { + pub raw: String, + pub light: String, + pub structured: String, + pub formal: String, +} + +impl CustomStylePrompts { + pub fn for_mode(&self, mode: PolishMode) -> &str { + match mode { + PolishMode::Raw => &self.raw, + PolishMode::Light => &self.light, + PolishMode::Structured => &self.structured, + PolishMode::Formal => &self.formal, + } + } + + pub fn has_for_mode(&self, mode: PolishMode) -> bool { + !self.for_mode(mode).trim().is_empty() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +pub struct StyleSystemPrompts { + pub raw: String, + pub light: String, + pub structured: String, + pub formal: String, +} + +impl StyleSystemPrompts { + pub fn for_mode(&self, mode: PolishMode) -> &str { + match mode { + PolishMode::Raw => &self.raw, + PolishMode::Light => &self.light, + PolishMode::Structured => &self.structured, + PolishMode::Formal => &self.formal, + } + } + + pub fn is_default_for_mode(&self, mode: PolishMode) -> bool { + self.for_mode(mode) == StyleSystemPrompts::default().for_mode(mode) + } + + pub fn with_legacy_custom_prompts(mut self, legacy: &CustomStylePrompts) -> Self { + for mode in [ + PolishMode::Raw, + PolishMode::Light, + PolishMode::Structured, + PolishMode::Formal, + ] { + let legacy_prompt = legacy.for_mode(mode).trim(); + if legacy_prompt.is_empty() { + continue; + } + let merged = format!( + "{}\n\n# 用户自定义附加要求\n{}", + self.for_mode(mode).trim_end(), + legacy_prompt + ); + match mode { + PolishMode::Raw => self.raw = merged, + PolishMode::Light => self.light = merged, + PolishMode::Structured => self.structured = merged, + PolishMode::Formal => self.formal = merged, + } + } + self + } +} + +impl Default for StyleSystemPrompts { + fn default() -> Self { + Self { + raw: default_raw_style_system_prompt(), + light: default_light_style_system_prompt(), + structured: default_structured_style_system_prompt(), + formal: default_formal_style_system_prompt(), + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum StylePackKind { + Builtin, + Imported, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct StylePackExample { + pub title: Option, + pub input: String, + pub output: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +pub struct StylePack { + pub id: String, + pub name: String, + pub description: String, + pub author: Option, + pub version: String, + pub kind: StylePackKind, + pub base_mode: PolishMode, + pub prompt: String, + pub examples: Vec, + pub tags: Vec, + pub icon_path: Option, + pub created_at: Option, + pub updated_at: Option, + pub enabled: bool, + pub active: bool, + pub recommended_model: Option, + pub compatible_app_version: Option, +} + +impl Default for StylePack { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + description: String::new(), + author: None, + version: "1.0.0".into(), + kind: StylePackKind::Imported, + base_mode: PolishMode::Light, + prompt: String::new(), + examples: Vec::new(), + tags: Vec::new(), + icon_path: None, + created_at: None, + updated_at: None, + enabled: true, + active: false, + recommended_model: None, + compatible_app_version: None, + } + } +} + +pub const BUILTIN_STYLE_PACK_RAW_ID: &str = "builtin.raw"; +pub const BUILTIN_STYLE_PACK_LIGHT_ID: &str = "builtin.light"; +pub const BUILTIN_STYLE_PACK_STRUCTURED_ID: &str = "builtin.structured"; +pub const BUILTIN_STYLE_PACK_FORMAL_ID: &str = "builtin.formal"; + +pub fn builtin_style_pack_id(mode: PolishMode) -> &'static str { + match mode { + PolishMode::Raw => BUILTIN_STYLE_PACK_RAW_ID, + PolishMode::Light => BUILTIN_STYLE_PACK_LIGHT_ID, + PolishMode::Structured => BUILTIN_STYLE_PACK_STRUCTURED_ID, + PolishMode::Formal => BUILTIN_STYLE_PACK_FORMAL_ID, + } +} + +pub fn default_active_style_pack_id() -> String { + BUILTIN_STYLE_PACK_LIGHT_ID.to_string() +} + +pub fn builtin_style_pack_for_mode(mode: PolishMode) -> StylePack { + match mode { + PolishMode::Raw => StylePack { + id: BUILTIN_STYLE_PACK_RAW_ID.into(), + name: "原文".into(), + description: "尽量保留原话的顺序、语气和信息密度,只做必要断句与标点整理。".into(), + author: Some("OpenLess".into()), + version: "1.0.0".into(), + kind: StylePackKind::Builtin, + base_mode: PolishMode::Raw, + prompt: default_raw_style_system_prompt(), + examples: vec![StylePackExample { + title: Some("最小整理".into()), + input: "今天下午那个会先别取消我晚点再确认一下然后把下周二也先空出来".into(), + output: "今天下午那个会先别取消,我晚点再确认一下。然后把下周二也先空出来。".into(), + }], + tags: vec!["原文".into(), "最小改写".into()], + icon_path: None, + created_at: None, + updated_at: None, + enabled: true, + active: false, + recommended_model: None, + compatible_app_version: Some(env!("CARGO_PKG_VERSION").into()), + }, + PolishMode::Light => StylePack { + id: BUILTIN_STYLE_PACK_LIGHT_ID.into(), + name: "轻度润色".into(), + description: "把口语整理成顺畅、自然、可直接发送的文字,但不扩写事实。".into(), + author: Some("OpenLess".into()), + version: "1.0.0".into(), + kind: StylePackKind::Builtin, + base_mode: PolishMode::Light, + prompt: default_light_style_system_prompt(), + examples: vec![StylePackExample { + title: Some("聊天消息".into()), + input: "你帮我跟设计那边说一下这个首页先别上线我晚上再过一遍".into(), + output: "你帮我跟设计那边说一下,这个首页先别上线,我今晚再过一遍。".into(), + }], + tags: vec!["日常沟通".into(), "顺滑".into()], + icon_path: None, + created_at: None, + updated_at: None, + enabled: true, + active: false, + recommended_model: None, + compatible_app_version: Some(env!("CARGO_PKG_VERSION").into()), + }, + PolishMode::Structured => StylePack { + id: BUILTIN_STYLE_PACK_STRUCTURED_ID.into(), + name: "清晰结构".into(), + description: "适合多事项、多主题口述,自动整理为层次清晰的结构化输出。".into(), + author: Some("OpenLess".into()), + version: "1.0.0".into(), + kind: StylePackKind::Builtin, + base_mode: PolishMode::Structured, + prompt: default_structured_style_system_prompt(), + examples: vec![StylePackExample { + title: Some("任务整理".into()), + input: "这周要做三件事一个是把登录页 bug 修掉第二个是补 README 第三个是把发版脚本再走一遍".into(), + output: "这周要完成以下三件事:\n1. 登录页修复\n(a) 修复登录页相关 bug。\n2. 文档补充\n(a) 补充 README。\n3. 发版准备\n(a) 再完整走一遍发版脚本。".into(), + }], + tags: vec!["结构化".into(), "条理".into()], + icon_path: None, + created_at: None, + updated_at: None, + enabled: true, + active: false, + recommended_model: None, + compatible_app_version: Some(env!("CARGO_PKG_VERSION").into()), + }, + PolishMode::Formal => StylePack { + id: BUILTIN_STYLE_PACK_FORMAL_ID.into(), + name: "正式表达".into(), + description: "适合邮件、周报、跨团队同步等场景,语气更完整、专业、克制。".into(), + author: Some("OpenLess".into()), + version: "1.0.0".into(), + kind: StylePackKind::Builtin, + base_mode: PolishMode::Formal, + prompt: default_formal_style_system_prompt(), + examples: vec![StylePackExample { + title: Some("工作同步".into()), + input: "你帮我发个消息说一下这个需求今天先不上了等测试和产品都确认完我们再一起推进".into(), + output: "麻烦帮我同步一下:这个需求今天先不上线,待测试和产品都确认完成后,我们再统一推进。".into(), + }], + tags: vec!["正式".into(), "工作沟通".into()], + icon_path: None, + created_at: None, + updated_at: None, + enabled: true, + active: false, + recommended_model: None, + compatible_app_version: Some(env!("CARGO_PKG_VERSION").into()), + }, + } +} + +pub fn builtin_style_packs() -> Vec { + vec![ + builtin_style_pack_for_mode(PolishMode::Raw), + builtin_style_pack_for_mode(PolishMode::Light), + builtin_style_pack_for_mode(PolishMode::Structured), + builtin_style_pack_for_mode(PolishMode::Formal), + ] +} + fn default_true() -> bool { true } @@ -158,6 +428,12 @@ pub struct UserPreferences { pub dictation_hotkey: ShortcutBinding, pub default_mode: PolishMode, pub enabled_modes: Vec, + #[serde(default = "default_active_style_pack_id")] + pub active_style_pack_id: String, + #[serde(default)] + pub style_system_prompts: StyleSystemPrompts, + #[serde(default)] + pub custom_style_prompts: CustomStylePrompts, pub launch_at_login: bool, pub show_capsule: bool, /// 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。 @@ -338,6 +614,12 @@ struct UserPreferencesWire { dictation_hotkey: Option, default_mode: PolishMode, enabled_modes: Vec, + #[serde(default = "default_active_style_pack_id")] + active_style_pack_id: String, + #[serde(default)] + style_system_prompts: StyleSystemPrompts, + #[serde(default)] + custom_style_prompts: CustomStylePrompts, launch_at_login: bool, show_capsule: bool, #[serde(default)] @@ -399,6 +681,9 @@ impl Default for UserPreferencesWire { dictation_hotkey: None, default_mode: prefs.default_mode, enabled_modes: prefs.enabled_modes, + active_style_pack_id: prefs.active_style_pack_id, + style_system_prompts: prefs.style_system_prompts, + custom_style_prompts: prefs.custom_style_prompts, launch_at_login: prefs.launch_at_login, show_capsule: prefs.show_capsule, mute_during_recording: prefs.mute_during_recording, @@ -452,6 +737,15 @@ impl<'de> Deserialize<'de> for UserPreferences { dictation_hotkey, default_mode: wire.default_mode, enabled_modes: wire.enabled_modes, + active_style_pack_id: if wire.active_style_pack_id.trim().is_empty() { + builtin_style_pack_id(wire.default_mode).to_string() + } else { + wire.active_style_pack_id + }, + style_system_prompts: wire + .style_system_prompts + .with_legacy_custom_prompts(&wire.custom_style_prompts), + custom_style_prompts: wire.custom_style_prompts, launch_at_login: wire.launch_at_login, show_capsule: wire.show_capsule, mute_during_recording: wire.mute_during_recording, @@ -556,6 +850,42 @@ fn default_working_languages() -> Vec { vec!["简体中文".into()] } +fn style_prompt_role_block() -> &'static str { + "# 角色\n\ +你是一个语音输入后的文本整理器。\n\ +你的输出会被直接插入到用户当前光标所在的输入框里。" +} + +fn style_prompt_common_rules() -> &'static str { + "# 通用规则\n\ +1) 保留用户原意,不补充用户没说过的事实,不替用户回答问题。\n\ +2) 人名、地名、品牌名、产品名、代码、命令、路径、URL、数字、单位、emoji 原样保留。\n\ +3) 可以修正常见 ASR 同音字、形近字和明显口误,但不要把不确定的专有名词强行改错。\n\ +4) 只输出最终正文,不要加“以下是整理结果”“优化如下”之类的说明。\n\ +5) 不要输出 markdown 代码围栏。" +} + +fn style_prompt_output_block() -> &'static str { + "# 输出\n\ +直接输出最终文本正文。需要结构化时,直接从标题、段落、编号或列表开始。" +} + +fn default_raw_style_system_prompt() -> String { + crate::polish::prompts::system_prompt(PolishMode::Raw) +} + +fn default_light_style_system_prompt() -> String { + crate::polish::prompts::system_prompt(PolishMode::Light) +} + +fn default_structured_style_system_prompt() -> String { + crate::polish::prompts::system_prompt(PolishMode::Structured) +} + +fn default_formal_style_system_prompt() -> String { + crate::polish::prompts::system_prompt(PolishMode::Formal) +} + impl Default for UserPreferences { fn default() -> Self { Self { @@ -572,6 +902,9 @@ impl Default for UserPreferences { PolishMode::Structured, PolishMode::Formal, ], + active_style_pack_id: default_active_style_pack_id(), + style_system_prompts: StyleSystemPrompts::default(), + custom_style_prompts: CustomStylePrompts::default(), launch_at_login: false, show_capsule: true, mute_during_recording: false, @@ -1206,6 +1539,35 @@ mod tests { assert!(prefs.allow_non_tsf_insertion_fallback); } + #[test] + fn missing_custom_style_prompts_defaults_to_empty() { + let prefs: UserPreferences = serde_json::from_str("{}").unwrap(); + + assert_eq!(prefs.custom_style_prompts, CustomStylePrompts::default()); + assert!(!prefs.custom_style_prompts.has_for_mode(PolishMode::Raw)); + } + + #[test] + fn custom_style_prompts_round_trip_explicit_values() { + let prefs: UserPreferences = serde_json::from_str( + r#"{ + "customStylePrompts": { + "raw": "保留我的口头禅", + "light": "更像微信消息", + "structured": "按项目符号整理", + "formal": "像正式周报" + } + }"#, + ) + .unwrap(); + + assert_eq!(prefs.custom_style_prompts.raw, "保留我的口头禅"); + assert_eq!(prefs.custom_style_prompts.light, "更像微信消息"); + assert_eq!(prefs.custom_style_prompts.structured, "按项目符号整理"); + assert_eq!(prefs.custom_style_prompts.formal, "像正式周报"); + assert!(prefs.custom_style_prompts.has_for_mode(PolishMode::Formal)); + } + /// issue #360: 默认值必须是 CtrlV,跟历史行为一致;老配置文件没有 /// pasteShortcut 字段时反序列化也得回到 CtrlV,否则会把现有用户的粘贴 /// 行为静默改掉。 From 8174cb4e8c3eb3a031561b5446ce454b84022604 Mon Sep 17 00:00:00 2001 From: cooper Date: Wed, 13 May 2026 13:47:40 +0800 Subject: [PATCH 02/16] feat: add style pack management UI --- openless-all/app/src/i18n/en.ts | 14 + openless-all/app/src/i18n/ja.ts | 14 + openless-all/app/src/i18n/ko.ts | 14 + openless-all/app/src/i18n/zh-CN.ts | 14 + openless-all/app/src/i18n/zh-TW.ts | 14 + openless-all/app/src/lib/ipc.ts | 348 +++++- openless-all/app/src/lib/stylePrefs.test.ts | 8 + openless-all/app/src/lib/types.ts | 45 + openless-all/app/src/pages/Settings.tsx | 101 ++ openless-all/app/src/pages/Style.tsx | 1056 +++++++++++++++---- 10 files changed, 1402 insertions(+), 226 deletions(-) diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 71a0c593..7bf843cc 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -210,6 +210,12 @@ export const en: typeof zhCN = { currentDefault: 'Current default', ariaSetDefault: 'Set as default', saveFailed: 'Save failed: {{error}}', + customPromptTitle: 'Custom prompt', + customPromptPlaceholder: 'Optional. Appended to this style’s built-in system prompt.', + customPromptHint: 'Leave empty to preserve current behavior. After saving, it applies to both this style’s live polish path and repolish. Press Ctrl/Cmd+Enter to save as well.', + customPromptSave: 'Save prompt', + customPromptDirty: 'Unsaved', + systemPromptMovedHint: 'Full system prompt editing has moved to Settings -> Providers. This page now only controls which styles are enabled and which one is the default.', 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." }, @@ -375,6 +381,14 @@ export const en: typeof zhCN = { llmProviderDesc: 'Selecting a preset auto-fills the default Base URL.', credentialStorageNotice: 'Credentials are stored in the OS credential vault. Legacy local JSON credentials are migrated into the vault and removed after a successful write.', codexOAuthNotice: 'Codex OAuth uses the local Codex login state (~/.codex/auth.json). OpenLess does not store an API key or Base URL for this provider.', + styleSystemPromptTitle: 'Polish system prompts', + styleSystemPromptDesc: 'Each built-in style now exposes its full system prompt here. Saved edits are shared by live polish and History repolish.', + styleSystemPromptPlaceholder: 'Enter the full system prompt for this style', + styleSystemPromptHint: 'This edits the full prompt, not just an appended suffix. Press Ctrl/Cmd+Enter to save as well.', + styleSystemPromptSave: 'Save prompt', + styleSystemPromptReset: 'Reset to default', + styleSystemPromptDirty: 'Unsaved', + styleSystemPromptSaveFailed: 'Failed to save system prompt: {{error}}', asrProviderDesc: 'Switching providers automatically loads the matching credentials.', asrTitle: 'ASR (transcription)', asrDesc: 'Used to turn speech into text in real time.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index aae1e2b9..cbc1b18b 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -212,6 +212,12 @@ export const ja: typeof zhCN = { currentDefault: '現在のデフォルト', ariaSetDefault: 'デフォルトに設定', saveFailed: '保存に失敗しました: {{error}}', + customPromptTitle: 'カスタムプロンプト', + customPromptPlaceholder: '任意。このスタイルの組み込み system prompt の末尾に追加されます。', + customPromptHint: '空のままなら現在の挙動を維持します。保存後、このスタイルの整文と repolish の両方に適用されます。Ctrl/Cmd+Enter でも保存できます。', + customPromptSave: 'プロンプトを保存', + customPromptDirty: '未保存', + systemPromptMovedHint: 'フルの system prompt 編集は Settings -> Providers に移動しました。このページではスタイルの有効化とデフォルト設定だけを扱います。', modes: { raw: { name: '原文', desc: '句読点と必要な区切りのみ補い、書き換えや拡張はしません。', sample: '元の話し言葉を保持。「えー」「あの」などの口癖は除去しますが、文の組み替えはしません。' }, light: { name: '軽い整文', desc: '口癖の除去、句読点の補完、自然な送信可能テキストへの整理。', sample: '原稿読み上げのようにならないよう、語気と表現の癖を残しつつ、文章をなめらかにします。' }, @@ -377,6 +383,14 @@ export const ja: typeof zhCN = { llmProviderDesc: '選択するとデフォルトの Base URL が自動入力されます。', credentialStorageNotice: '認証情報は OS の認証情報ストアに保存されます。旧バージョンのローカル JSON 認証情報はストアへ移行され、書き込み成功後に削除されます。', codexOAuthNotice: 'Codex OAuth はローカルの Codex ログイン状態(~/.codex/auth.json)を使用します。OpenLess は API Key や Base URL を保存しません。', + styleSystemPromptTitle: 'Polish system prompts', + styleSystemPromptDesc: 'Each built-in style now exposes its full system prompt here. Saved edits are shared by live polish and History repolish.', + styleSystemPromptPlaceholder: 'Enter the full system prompt for this style', + styleSystemPromptHint: 'This edits the full prompt, not just an appended suffix. Press Ctrl/Cmd+Enter to save as well.', + styleSystemPromptSave: 'Save prompt', + styleSystemPromptReset: 'Reset to default', + styleSystemPromptDirty: 'Unsaved', + styleSystemPromptSaveFailed: 'Failed to save system prompt: {{error}}', asrProviderDesc: '切り替えると対応する認証情報が自動選択されます。', asrTitle: 'ASR 音声(転写)', asrDesc: '口述をリアルタイムでテキストに転写。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 864c0aea..5dccad18 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -212,6 +212,12 @@ export const ko: typeof zhCN = { currentDefault: '현재 기본', ariaSetDefault: '기본으로 설정', saveFailed: '저장 실패: {{error}}', + customPromptTitle: '사용자 프롬프트', + customPromptPlaceholder: '선택 사항입니다. 이 스타일의 기본 system prompt 끝에 추가됩니다.', + customPromptHint: '비워 두면 현재 동작이 그대로 유지됩니다. 저장 후 이 스타일의 실시간 다듬기와 repolish 모두에 적용됩니다. Ctrl/Cmd+Enter로도 저장할 수 있습니다.', + customPromptSave: '프롬프트 저장', + customPromptDirty: '미저장', + systemPromptMovedHint: '전체 system prompt 편집은 Settings -> Providers 로 이동했습니다. 이 페이지는 이제 스타일 활성화와 기본값만 다룹니다.', modes: { raw: { name: '원문', desc: '구두점과 필요한 문장 구분만 보충하고 다시 쓰거나 확장하지 않습니다.', sample: '원래 구어체 유지. "음", "그게" 같은 입버릇은 제거하지만 문장을 재구성하지 않습니다.' }, light: { name: '가벼운 정리', desc: '입버릇 제거, 구두점 보충, 자연스럽게 보낼 수 있는 텍스트로 정리합니다.', sample: '원고를 읽는 듯한 느낌이 들지 않도록 어조와 표현 습관은 남기되, 문장이 매끄럽게 흐르도록 합니다.' }, @@ -380,6 +386,14 @@ export const ko: typeof zhCN = { asrProviderDesc: '전환 시 해당하는 자격 증명이 자동 선택됩니다.', asrTitle: 'ASR 음성(전사)', asrDesc: '구술을 실시간으로 텍스트로 전사합니다.', + styleSystemPromptTitle: 'Polish system prompts', + styleSystemPromptDesc: 'Each built-in style now exposes its full system prompt here. Saved edits are shared by live polish and History repolish.', + styleSystemPromptPlaceholder: 'Enter the full system prompt for this style', + styleSystemPromptHint: 'This edits the full prompt, not just an appended suffix. Press Ctrl/Cmd+Enter to save as well.', + styleSystemPromptSave: 'Save prompt', + styleSystemPromptReset: 'Reset to default', + styleSystemPromptDirty: 'Unsaved', + styleSystemPromptSaveFailed: 'Failed to save system prompt: {{error}}', presets: { ark: 'ARK (Volcengine Ark)', deepseek: 'DeepSeek', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 646d2ebc..efcbac8a 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -208,6 +208,12 @@ export const zhCN = { currentDefault: '当前默认', ariaSetDefault: '设为默认', saveFailed: '保存失败:{{error}}', + customPromptTitle: '自定义提示词', + customPromptPlaceholder: '可选,追加到这个风格的内置 system prompt 末尾。', + customPromptHint: '留空则保持当前行为不变。保存后会在该风格的润色和 repolish 中生效;按 Ctrl/Cmd+Enter 也可保存。', + customPromptSave: '保存提示词', + customPromptDirty: '未保存', + systemPromptMovedHint: '完整 System Prompt 已移到 设置 -> Providers 页面统一编辑。这里现在只负责风格启停和默认风格。', modes: { raw: { name: '原文', desc: '只补标点和必要分句,不改写不扩写。', sample: '保留原始口语;嗯、那个等口癖会被去除,但不会重组语句。' }, light: { name: '轻度润色', desc: '去口癖、补标点,整理为可发送的自然文字。', sample: '让转写听起来不像念稿——保留语气和表达习惯,但行文流畅。' }, @@ -373,6 +379,14 @@ export const zhCN = { llmProviderDesc: '选择后将自动填入 Base URL 默认值。', credentialStorageNotice: '凭据保存在系统凭据库中。旧版本地 JSON 凭据会迁移到系统凭据库,并在成功写入后删除。', codexOAuthNotice: 'Codex OAuth 使用本机 Codex 登录状态(~/.codex/auth.json),无需在 OpenLess 中保存 API Key 或 Base URL。', + styleSystemPromptTitle: '润色 System Prompt', + styleSystemPromptDesc: '每个内置风格都可以直接编辑完整 system prompt。保存后,实时润色和 History 里的 repolish 会共用这一套提示词。', + styleSystemPromptPlaceholder: '输入该风格的完整 system prompt', + styleSystemPromptHint: '这里编辑的是完整提示词,不再只是附加片段。按 Ctrl/Cmd+Enter 也可保存。', + styleSystemPromptSave: '保存 Prompt', + styleSystemPromptReset: '恢复默认', + styleSystemPromptDirty: '未保存', + styleSystemPromptSaveFailed: '保存 System Prompt 失败:{{error}}', asrProviderDesc: '切换后将自动选用对应凭据。', asrTitle: 'ASR 语音(转写)', asrDesc: '用于将口述实时转写为文本。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 3c872353..18b09693 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -210,6 +210,12 @@ export const zhTW: typeof zhCN = { currentDefault: '當前默認', ariaSetDefault: '設爲默認', saveFailed: '保存失敗:{{error}}', + customPromptTitle: '自定義提示詞', + customPromptPlaceholder: '可選,追加到這個風格的內建 system prompt 末尾。', + customPromptHint: '留空則保持當前行為不變。保存後會在該風格的潤色和 repolish 中生效;按 Ctrl/Cmd+Enter 也可保存。', + customPromptSave: '保存提示詞', + customPromptDirty: '未保存', + systemPromptMovedHint: '完整 System Prompt 已移到 設定 -> Providers 頁面統一編輯。這裡現在只負責風格啟停和預設風格。', modes: { raw: { name: '原文', desc: '只補標點和必要分句,不改寫不擴寫。', sample: '保留原始口語;嗯、那個等口癖會被去除,但不會重組語句。' }, light: { name: '輕度潤色', desc: '去口癖、補標點,整理爲可發送的自然文字。', sample: '讓轉寫聽起來不像念稿——保留語氣和表達習慣,但行文流暢。' }, @@ -375,6 +381,14 @@ export const zhTW: typeof zhCN = { llmProviderDesc: '選擇後將自動填入 Base URL 默認值。', credentialStorageNotice: '憑據儲存在系統憑據庫中。舊版本機 JSON 憑據會遷移到系統憑據庫,並在成功寫入後刪除。', codexOAuthNotice: 'Codex OAuth 使用本機 Codex 登入狀態(~/.codex/auth.json),無需在 OpenLess 中保存 API Key 或 Base URL。', + styleSystemPromptTitle: '潤色 System Prompt', + styleSystemPromptDesc: '每個內建風格都可以直接編輯完整 system prompt。儲存後,即時潤色和 History 裡的 repolish 會共用這一套提示詞。', + styleSystemPromptPlaceholder: '輸入該風格的完整 system prompt', + styleSystemPromptHint: '這裡編輯的是完整提示詞,不再只是附加片段。按 Ctrl/Cmd+Enter 也可儲存。', + styleSystemPromptSave: '儲存 Prompt', + styleSystemPromptReset: '恢復預設', + styleSystemPromptDirty: '未儲存', + styleSystemPromptSaveFailed: '儲存 System Prompt 失敗:{{error}}', asrProviderDesc: '切換後將自動選用對應憑據。', asrTitle: 'ASR 語音(轉寫)', asrDesc: '用於將口述實時轉寫爲文本。', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index f700c9cc..03b293d9 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -15,10 +15,14 @@ import type { PolishMode, QaHotkeyBinding, ShortcutBinding, + StylePack, + StylePackExample, + StylePackKind, + StyleSystemPrompts, UpdateChannel, UserPreferences, - WindowsImeStatus, VocabPresetStore, + WindowsImeStatus, } from './types'; export type { UpdateChannel } from './types'; import { OL_DATA } from './mockData'; @@ -46,11 +50,19 @@ export async function invokeOrMock( } // ── Mock fixtures ────────────────────────────────────────────────────── -const mockSettings: UserPreferences = { +let mockSettings: UserPreferences = { hotkey: { trigger: 'rightControl', mode: 'toggle', keys: [{ code: 'ControlRight' }] }, dictationHotkey: { primary: 'RightControl', modifiers: [] }, defaultMode: 'structured', enabledModes: ['raw', 'light', 'structured', 'formal'], + activeStylePackId: 'builtin.structured', + styleSystemPrompts: { + raw: '只做最小化整理:补全标点、必要分句,保留原话顺序、用词和语气。', + light: '把口语转写整理成自然文字,去掉口癖和重复,保留原意与语气。', + structured: '把口述整理成结构清晰的文本,必要时按主题分组输出。', + formal: '输出适合工作沟通与邮件场景的正式表达,不扩写事实。', + }, + customStylePrompts: { raw: '', light: '', structured: '', formal: '' }, launchAtLogin: false, showCapsule: true, muteDuringRecording: false, @@ -86,6 +98,168 @@ const mockSettings: UserPreferences = { streamingInsertSaveClipboard: true, }; +const mockDefaultStyleSystemPrompts: StyleSystemPrompts = { + ...mockSettings.styleSystemPrompts, +}; + +const mockBuiltinExamples: Record = { + raw: [ + { + title: '最小整理', + input: '今天下午那个会先别取消我晚点再确认一下然后把下周二也先空出来', + output: '今天下午那个会先别取消,我晚点再确认一下。然后把下周二也先空出来。', + }, + ], + light: [ + { + title: '聊天消息', + input: '你帮我跟设计那边说一下这个首页先别上线我晚上再过一遍', + output: '你帮我跟设计那边说一下,这个首页先别上线,我今晚再过一遍。', + }, + ], + structured: [ + { + title: '任务整理', + input: '这周要做三件事一个是把登录页 bug 修掉第二个是补 README 第三个是把发版脚本再走一遍', + output: '这周要完成以下三件事:\n1. 修复登录页相关 bug。\n2. 补充 README 文档。\n3. 重新走一遍发版脚本。', + }, + ], + formal: [ + { + title: '工作同步', + input: '你帮我发个消息说这个需求今天先不上了等测试和产品都确认完我们再一起推进', + output: '麻烦帮我同步一下:这个需求今天先不上线,待测试和产品都确认完成后,我们再统一推进。', + }, + ], +}; + +function makeMockStylePack( + id: string, + kind: StylePackKind, + baseMode: PolishMode, + name: string, + description: string, + prompt: string, + tags: string[], +): StylePack { + return { + id, + name, + description, + author: 'OpenLess', + version: '1.0.0', + kind, + baseMode, + prompt, + examples: mockBuiltinExamples[baseMode].map(example => ({ ...example })), + tags, + iconPath: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + enabled: true, + active: false, + recommendedModel: null, + compatibleAppVersion: '1.0.0', + }; +} + +let mockStylePacks: StylePack[] = [ + makeMockStylePack( + 'builtin.raw', + 'builtin', + 'raw', + '原文', + '尽量保留原话顺序和语气,只做必要的断句与标点整理。', + mockSettings.styleSystemPrompts.raw, + ['原文', '最小改写'], + ), + makeMockStylePack( + 'builtin.light', + 'builtin', + 'light', + '轻度润色', + '把口述整理成顺畅、自然、可直接发送的文字,不扩写事实。', + mockSettings.styleSystemPrompts.light, + ['沟通', '自然'], + ), + makeMockStylePack( + 'builtin.structured', + 'builtin', + 'structured', + '清晰结构', + '适合多事项和多主题口述,自动整理为层次清楚的结构化输出。', + mockSettings.styleSystemPrompts.structured, + ['结构化', '条理'], + ), + makeMockStylePack( + 'builtin.formal', + 'builtin', + 'formal', + '正式表达', + '适合邮件、同步和工作沟通场景,语气更完整、专业、克制。', + mockSettings.styleSystemPrompts.formal, + ['正式', '工作沟通'], + ), + { + ...makeMockStylePack( + 'imported.creator-note', + 'imported', + 'light', + '创作者口播', + '给短视频口播和社区帖文使用,句子更紧凑,保留情绪和节奏。', + '你是一个负责整理创作者口播稿的编辑。请把输入整理成适合发帖和口播的自然文本,保留节奏感,不要补充原文没有的信息。', + ['社区', '口播', '节奏感'], + ), + author: 'Demo Community', + }, +]; + +function cloneStylePack(stylePack: StylePack): StylePack { + return { + ...stylePack, + tags: [...stylePack.tags], + examples: stylePack.examples.map(example => ({ ...example })), + }; +} + +function cloneMockStylePacks(): StylePack[] { + return mockStylePacks.map(cloneStylePack); +} + +function syncMockSettingsFromStylePacks() { + const enabled = mockStylePacks.filter(pack => pack.enabled); + const active = + mockStylePacks.find(pack => pack.id === mockSettings.activeStylePackId && pack.enabled) ?? + enabled[0] ?? + mockStylePacks[0]; + mockStylePacks = mockStylePacks.map(pack => ({ + ...pack, + active: pack.id === active.id, + })); + mockSettings = { + ...mockSettings, + activeStylePackId: active.id, + defaultMode: active.baseMode, + enabledModes: ['raw', 'light', 'structured', 'formal'].filter(mode => + mockStylePacks.some(pack => pack.enabled && pack.baseMode === mode), + ) as PolishMode[], + styleSystemPrompts: { + raw: mockStylePacks.find(pack => pack.id === 'builtin.raw')?.prompt ?? mockSettings.styleSystemPrompts.raw, + light: + mockStylePacks.find(pack => pack.id === 'builtin.light')?.prompt ?? + mockSettings.styleSystemPrompts.light, + structured: + mockStylePacks.find(pack => pack.id === 'builtin.structured')?.prompt ?? + mockSettings.styleSystemPrompts.structured, + formal: + mockStylePacks.find(pack => pack.id === 'builtin.formal')?.prompt ?? + mockSettings.styleSystemPrompts.formal, + }, + }; +} + +syncMockSettingsFromStylePacks(); + const mockHotkeyCapability: HotkeyCapability = { adapter: 'windowsLowLevel', availableTriggers: ['rightControl', 'rightAlt', 'leftControl', 'rightCommand', 'custom'], @@ -167,11 +341,29 @@ const mockCorrectionRules: CorrectionRule[] = [ // ── Settings ─────────────────────────────────────────────────────────── export function getSettings(): Promise { - return invokeOrMock('get_settings', undefined, () => mockSettings); + return invokeOrMock('get_settings', undefined, () => ({ ...mockSettings })); +} + +export function getDefaultStyleSystemPrompts(): Promise { + return invokeOrMock('get_default_style_system_prompts', undefined, () => ({ ...mockDefaultStyleSystemPrompts })); } export function setSettings(prefs: UserPreferences): Promise { - return invokeOrMock('set_settings', { prefs }, () => undefined); + return invokeOrMock('set_settings', { prefs }, () => { + mockSettings = { ...prefs }; + mockStylePacks = mockStylePacks.map(pack => { + if (pack.kind === 'builtin') { + return { + ...pack, + enabled: prefs.enabledModes.includes(pack.baseMode), + prompt: prefs.styleSystemPrompts[pack.baseMode], + }; + } + return { ...pack }; + }); + syncMockSettingsFromStylePacks(); + return undefined; + }); } // ── Release channel (Beta opt-in) ────────────────────────────────────── @@ -360,11 +552,155 @@ export function repolish(rawText: string, mode: PolishMode): Promise { } export function setDefaultPolishMode(mode: PolishMode): Promise { - return invokeOrMock('set_default_polish_mode', { mode }, () => undefined); + return invokeOrMock('set_default_polish_mode', { mode }, () => { + const packId = `builtin.${mode}`; + mockStylePacks = mockStylePacks.map(pack => ({ + ...pack, + enabled: pack.id === packId ? true : pack.enabled, + active: pack.id === packId, + })); + mockSettings = { ...mockSettings, activeStylePackId: packId }; + syncMockSettingsFromStylePacks(); + return undefined; + }); } export function setStyleEnabled(mode: PolishMode, enabled: boolean): Promise { - return invokeOrMock('set_style_enabled', { mode, enabled }, () => undefined); + return invokeOrMock('set_style_enabled', { mode, enabled }, () => { + const packId = `builtin.${mode}`; + mockStylePacks = mockStylePacks.map(pack => + pack.id === packId ? { ...pack, enabled } : { ...pack }, + ); + syncMockSettingsFromStylePacks(); + return undefined; + }); +} + +export function listStylePacks(): Promise { + return invokeOrMock('list_style_packs', undefined, () => cloneMockStylePacks()); +} + +export function saveStylePack(stylePack: StylePack): Promise { + return invokeOrMock('save_style_pack', { stylePack }, () => { + mockStylePacks = mockStylePacks.map(pack => (pack.id === stylePack.id ? cloneStylePack(stylePack) : pack)); + syncMockSettingsFromStylePacks(); + return cloneStylePack(mockStylePacks.find(pack => pack.id === stylePack.id) ?? stylePack); + }); +} + +export function setActiveStylePack(id: string): Promise { + return invokeOrMock('set_active_style_pack', { id }, () => { + mockStylePacks = mockStylePacks.map(pack => ({ + ...pack, + enabled: pack.id === id ? true : pack.enabled, + active: pack.id === id, + })); + mockSettings = { ...mockSettings, activeStylePackId: id }; + syncMockSettingsFromStylePacks(); + return cloneStylePack(mockStylePacks.find(pack => pack.id === id)!); + }); +} + +export function setStylePackEnabled(id: string, enabled: boolean): Promise { + return invokeOrMock('set_style_pack_enabled', { id, enabled }, () => { + mockStylePacks = mockStylePacks.map(pack => + pack.id === id ? { ...pack, enabled } : { ...pack }, + ); + syncMockSettingsFromStylePacks(); + return cloneMockStylePacks(); + }); +} + +export function resetBuiltinStylePack(id: string): Promise { + return invokeOrMock('reset_builtin_style_pack', { id }, () => { + const builtinDefaults: Record = { + 'builtin.raw': makeMockStylePack( + 'builtin.raw', + 'builtin', + 'raw', + '原文', + '尽量保留原话顺序和语气,只做必要的断句与标点整理。', + mockDefaultStyleSystemPrompts.raw, + ['原文', '最小改写'], + ), + 'builtin.light': makeMockStylePack( + 'builtin.light', + 'builtin', + 'light', + '轻度润色', + '把口述整理成顺畅、自然、可直接发送的文字,不扩写事实。', + '把口述整理成自然、顺畅、可直接发送的文字,去掉口头禅和重复,保留原意与语气。', + ['沟通', '自然'], + ), + 'builtin.structured': makeMockStylePack( + 'builtin.structured', + 'builtin', + 'structured', + '清晰结构', + '适合多事项和多主题口述,自动整理为层次清楚的结构化输出。', + '把口述整理成结构清楚的文本,必要时按主题分组或分点输出。', + ['结构化', '条理'], + ), + 'builtin.formal': makeMockStylePack( + 'builtin.formal', + 'builtin', + 'formal', + '正式表达', + '适合邮件、同步和工作沟通场景,语气更完整、专业、克制。', + '输出适合工作沟通、邮件和汇报场景的正式表达,不扩写事实。', + ['正式', '工作沟通'], + ), + }; + const current = mockStylePacks.find(pack => pack.id === id); + const reset = builtinDefaults[id]; + if (!current || !reset) { + throw new Error(`style pack not found: ${id}`); + } + mockStylePacks = mockStylePacks.map(pack => + pack.id === id + ? { + ...reset, + enabled: current.enabled, + active: current.active, + } + : pack, + ); + syncMockSettingsFromStylePacks(); + return cloneStylePack(mockStylePacks.find(pack => pack.id === id)!); + }); +} + +export function deleteStylePack(id: string): Promise { + return invokeOrMock('delete_style_pack', { id }, () => { + mockStylePacks = mockStylePacks.filter(pack => pack.id !== id); + syncMockSettingsFromStylePacks(); + return undefined; + }); +} + +export function importStylePackFromZip(zipPath: string): Promise { + return invokeOrMock('import_style_pack_from_zip', { zipPath }, () => { + const seed = Date.now(); + const pack = { + ...makeMockStylePack( + `imported.mock-${seed}`, + 'imported', + 'light', + '导入风格包', + `从 ${zipPath.split(/[\\\\/]/).pop() || 'ZIP'} 导入的风格包`, + '你是一个负责把口述整理成清晰、利落、适合社区分享文本的编辑,请完整保留事实,不要补充原文没有的信息。', + ['导入', 'ZIP'], + ), + author: 'Imported ZIP', + }; + mockStylePacks = [pack, ...mockStylePacks]; + syncMockSettingsFromStylePacks(); + return cloneStylePack(pack); + }); +} + +export function exportStylePackToZip(id: string, targetPath: string): Promise { + return invokeOrMock('export_style_pack_to_zip', { id, targetPath }, () => targetPath); } // ── Permissions ──────────────────────────────────────────────────────── diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 74a044cb..848fdd5f 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -20,6 +20,14 @@ const previousPrefs: UserPreferences = { dictationHotkey: { primary: 'RightOption', modifiers: [] }, defaultMode: 'light', enabledModes: ['raw', 'light', 'structured'], + activeStylePackId: '', + styleSystemPrompts: { + raw: 'raw system prompt', + light: 'light system prompt', + structured: 'structured system prompt', + formal: 'formal system prompt', + }, + customStylePrompts: { raw: '', light: '', structured: '', formal: '' }, launchAtLogin: false, showCapsule: true, muteDuringRecording: false, diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index b45242fa..0ae43814 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -134,11 +134,56 @@ export interface WindowsImeStatus { * 手动下载 Beta 的入口。不影响 plugin-updater 的自动检查路径。 */ export type UpdateChannel = 'stable' | 'beta'; +export interface CustomStylePrompts { + raw: string; + light: string; + structured: string; + formal: string; +} + +export interface StyleSystemPrompts { + raw: string; + light: string; + structured: string; + formal: string; +} + +export type StylePackKind = 'builtin' | 'imported'; + +export interface StylePackExample { + title?: string | null; + input: string; + output: string; +} + +export interface StylePack { + id: string; + name: string; + description: string; + author?: string | null; + version: string; + kind: StylePackKind; + baseMode: PolishMode; + prompt: string; + examples: StylePackExample[]; + tags: string[]; + iconPath?: string | null; + createdAt?: string | null; + updatedAt?: string | null; + enabled: boolean; + active: boolean; + recommendedModel?: string | null; + compatibleAppVersion?: string | null; +} + export interface UserPreferences { hotkey: HotkeyBinding; dictationHotkey: ShortcutBinding; defaultMode: PolishMode; enabledModes: PolishMode[]; + activeStylePackId: string; + styleSystemPrompts: StyleSystemPrompts; + customStylePrompts: CustomStylePrompts; launchAtLogin: boolean; showCapsule: boolean; /** 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。 */ diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 72e89d36..8c750235 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -1388,6 +1388,11 @@ function ProvidersSection() { const asrSwitchSeqRef = useRef(0); const [llmModelRevision, setLlmModelRevision] = useState(0); const [asrModelRevision, setAsrModelRevision] = useState(0); + const [promptDrafts, setPromptDrafts] = useState(null); + const [defaultPrompts, setDefaultPrompts] = useState(null); + const [savingPromptMode, setSavingPromptMode] = useState(null); + const [savedPromptMode, setSavedPromptMode] = useState(null); + const [promptError, setPromptError] = useState(null); const os = detectOS(); // 主 ASR 下拉只列云端选项;本地推理(local-qwen3 / foundry-local-whisper) // 移到「高级」标签页,防止新手误开 CPU 推理。详见 AdvancedSection。 @@ -1410,6 +1415,30 @@ function ProvidersSection() { setCommittedAsrProvider(asrId); }, [prefs, os]); + useEffect(() => { + if (!prefs) return; + setPromptDrafts(cloneStyleSystemPrompts(prefs.styleSystemPrompts)); + setSavingPromptMode(null); + setSavedPromptMode(null); + setPromptError(null); + }, [prefs]); + + useEffect(() => { + let cancelled = false; + void getDefaultStyleSystemPrompts() + .then(prompts => { + if (!cancelled) setDefaultPrompts(prompts); + }) + .catch(error => { + if (!cancelled) { + console.warn('[settings] failed to load default style system prompts', error); + } + }); + return () => { + cancelled = true; + }; + }, []); + // issue #219 / #220 P2: // 1. 立刻 setLlmProvider —— 受控 patchDraft({ name: event.target.value })} + style={inputStyle} + /> + + + + + + +