diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 9a79386f..c9cd9b6e 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -19,9 +19,9 @@ use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVaul use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; use crate::recorder::{AudioConsumer, Recorder}; use crate::types::{ - ChineseScriptPreference, ComboBinding, CredentialsStatus, DictationSession, DictionaryEntry, - HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode, ShortcutBinding, - UpdateChannel, UserPreferences, VocabPresetStore, WindowsImeStatus, + ChineseScriptPreference, ComboBinding, CorrectionRule, CredentialsStatus, DictationSession, + DictionaryEntry, HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode, + ShortcutBinding, UpdateChannel, UserPreferences, VocabPresetStore, WindowsImeStatus, }; type CoordinatorState<'a> = State<'a, Arc>; @@ -160,7 +160,10 @@ pub fn set_settings( if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) { log::warn!("[tray] refresh microphone menu after settings save failed: {err}"); let tray_state = app_for_main.state::(); - sync_tray_microphone_selection(&tray_state.lock(), &prefs_for_main.microphone_device_name); + sync_tray_microphone_selection( + &tray_state.lock(), + &prefs_for_main.microphone_device_name, + ); } }); // 抑制 unused 警告:tray_microphones 现在改在闭包里通过 app.state 取, @@ -246,7 +249,10 @@ pub async fn fetch_latest_beta_release() -> Result, St /// 用 `/releases/tag/` 这个唯一锚点抓 tag。 fn parse_latest_beta_from_atom(body: &str) -> Option { for entry in body.split("").skip(1) { - let entry_body = entry.split_once("").map(|(b, _)| b).unwrap_or(entry); + let entry_body = entry + .split_once("") + .map(|(b, _)| b) + .unwrap_or(entry); let needle = "/releases/tag/"; let tag_start = match entry_body.find(needle) { Some(i) => i + needle.len(), @@ -260,11 +266,9 @@ fn parse_latest_beta_from_atom(body: &str) -> Option { if !tag_name.ends_with("-beta-tauri") { continue; } - let html_url = format!( - "https://github.com/appergb/openless/releases/tag/{tag_name}" - ); - let published_at = extract_between(entry_body, "", "") - .unwrap_or_default(); + let html_url = format!("https://github.com/appergb/openless/releases/tag/{tag_name}"); + let published_at = + extract_between(entry_body, "", "").unwrap_or_default(); return Some(LatestBetaRelease { tag_name, html_url, @@ -926,6 +930,43 @@ pub fn set_vocab_enabled( .map_err(|e| e.to_string()) } +#[tauri::command] +pub fn list_correction_rules(coord: CoordinatorState<'_>) -> Result, String> { + coord.correction_rules().list().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn add_correction_rule( + coord: CoordinatorState<'_>, + pattern: String, + replacement: String, +) -> Result { + coord + .correction_rules() + .add(pattern, replacement) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn remove_correction_rule(coord: CoordinatorState<'_>, id: String) -> Result<(), String> { + coord + .correction_rules() + .remove(&id) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_correction_rule_enabled( + coord: CoordinatorState<'_>, + id: String, + enabled: bool, +) -> Result<(), String> { + coord + .correction_rules() + .set_enabled(&id, enabled) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub fn list_vocab_presets() -> Result { crate::persistence::list_vocab_presets().map_err(|e| e.to_string()) @@ -991,7 +1032,10 @@ pub fn set_default_polish_mode( ) -> Result<(), String> { let mut prefs = coord.prefs().get(); prefs.default_mode = mode; - coord.prefs().set(prefs.clone()).map_err(|e| e.to_string())?; + coord + .prefs() + .set(prefs.clone()) + .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(); @@ -1975,14 +2019,20 @@ mod tests { }; assert!(llm_configured_for_provider("custom", &keyless_ready)); assert!(llm_configured_for_provider("self-hosted", &keyless_ready)); - assert!(llm_configured_for_provider("openrouterFree", &keyless_ready)); + assert!(llm_configured_for_provider( + "openrouterFree", + &keyless_ready + )); let hosted_keyless = CredentialsSnapshot { ark_endpoint: Some("https://openrouter.ai/api/v1".into()), ark_model_id: Some("qwen/qwen3-coder:free".into()), ..snapshot() }; - assert!(!llm_configured_for_provider("openrouterFree", &hosted_keyless)); + assert!(!llm_configured_for_provider( + "openrouterFree", + &hosted_keyless + )); let hosted_ready = CredentialsSnapshot { ark_api_key: Some("key".into()), @@ -1997,13 +2047,19 @@ mod tests { ark_model_id: Some("qwen".into()), ..snapshot() }; - assert!(!llm_configured_for_provider("custom", &key_without_endpoint)); + assert!(!llm_configured_for_provider( + "custom", + &key_without_endpoint + )); let endpoint_without_model = CredentialsSnapshot { ark_endpoint: Some("http://localhost:11434/v1".into()), ..snapshot() }; - assert!(!llm_configured_for_provider("custom", &endpoint_without_model)); + assert!(!llm_configured_for_provider( + "custom", + &endpoint_without_model + )); } impl SettingsWriter for FakeSettingsWriter { diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 4428a8f7..283bc7a6 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -32,20 +32,21 @@ use crate::coordinator_state::{ use crate::hotkey::{HotkeyEvent, HotkeyMonitor}; use crate::insertion::TextInserter; use crate::persistence::{ - CredentialAccount, CredentialsVault, DictionaryStore, HistoryStore, PreferencesStore, + CorrectionRuleStore, CredentialAccount, CredentialsVault, DictionaryStore, HistoryStore, + PreferencesStore, }; use crate::polish::{OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor}; use crate::recorder::{Recorder, RecorderError}; use crate::selection::capture_selection; +#[cfg(target_os = "windows")] +use crate::types::PasteShortcut; use crate::types::{ CapsulePayload, CapsuleState, ChineseScriptPreference, DictationSession, HotkeyCapability, HotkeyStatus, HotkeyStatusState, InsertStatus, OutputLanguagePreference, PolishMode, }; #[cfg(target_os = "windows")] -use crate::types::PasteShortcut; -#[cfg(target_os = "windows")] use crate::windows_ime_ipc::ImeSubmitTarget; #[cfg(target_os = "windows")] use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionController}; @@ -96,6 +97,7 @@ struct Inner { history: HistoryStore, prefs: PreferencesStore, vocab: DictionaryStore, + correction_rules: CorrectionRuleStore, inserter: TextInserter, #[cfg(target_os = "windows")] windows_ime: WindowsImeSessionController, @@ -176,6 +178,7 @@ impl Coordinator { }); let prefs = PreferencesStore::new().expect("preferences store init"); let vocab = DictionaryStore::new().expect("dictionary store init"); + let correction_rules = CorrectionRuleStore::new().expect("correction rule store init"); Self { inner: Arc::new(Inner { @@ -183,6 +186,7 @@ impl Coordinator { history, prefs, vocab, + correction_rules, inserter: TextInserter::new(), state: Mutex::new(SessionState::default()), asr: Mutex::new(None), @@ -218,6 +222,7 @@ impl Coordinator { }); let prefs = PreferencesStore::new().expect("preferences store init"); let vocab = DictionaryStore::new().expect("dictionary store init"); + let correction_rules = CorrectionRuleStore::new().expect("correction rule store init"); Self { inner: Arc::new(Inner { @@ -225,6 +230,7 @@ impl Coordinator { history, prefs, vocab, + correction_rules, inserter: TextInserter::new(), windows_ime: WindowsImeSessionController::new(), prepared_windows_ime_session: Arc::new(Mutex::new(Vec::new())), @@ -642,6 +648,9 @@ impl Coordinator { pub fn vocab(&self) -> &DictionaryStore { &self.inner.vocab } + pub fn correction_rules(&self) -> &CorrectionRuleStore { + &self.inner.correction_rules + } pub fn update_hotkey_binding(&self) { let prefs = self.inner.prefs.get(); diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 51512fba..b78b6c64 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -2,6 +2,7 @@ use std::sync::atomic::Ordering; use std::sync::Arc; use crate::coordinator_state::request_stop_during_starting_state; +use crate::correction::apply_correction_rules; use crate::types::HotkeyMode; use super::qa::handle_qa_option_edge; @@ -906,6 +907,25 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { return Err("ASR returned empty transcript".to_string()); } + let correction_rules = match inner.correction_rules.list() { + Ok(rules) => rules, + Err(e) => { + log::warn!("[coord] load correction rules failed: {e}; continue without correction"); + Vec::new() + } + }; + if !correction_rules.is_empty() { + let corrected = apply_correction_rules(&raw.text, &correction_rules); + if corrected != raw.text { + log::info!( + "[coord] correction rules adjusted raw transcript ({} → {} chars)", + raw.text.chars().count(), + corrected.chars().count() + ); + raw.text = corrected; + } + } + emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None); let prefs = inner.prefs.get(); @@ -988,6 +1008,19 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { } 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, @@ -1035,7 +1068,9 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { } #[cfg(not(target_os = "windows"))] { - inner.inserter.insert(&polished, restore_clipboard, paste_shortcut) + inner + .inserter + .insert(&polished, restore_clipboard, paste_shortcut) } } else { log::warn!( diff --git a/openless-all/app/src-tauri/src/correction.rs b/openless-all/app/src-tauri/src/correction.rs new file mode 100644 index 00000000..387135a7 --- /dev/null +++ b/openless-all/app/src-tauri/src/correction.rs @@ -0,0 +1,203 @@ +//! 用户自定义纠正规则。 +//! +//! 规则独立于词汇表:词汇表负责 ASR/LLM 热词提示,纠正规则负责在听写流水线里 +//! 做确定性的文本替换。当前只支持一个保守通配符 `{num}`,避免把任意正则暴露给 +//! 用户造成误替换。 + +use crate::types::CorrectionRule; + +const NUM_TOKEN: &str = "{num}"; + +pub fn apply_correction_rules(text: &str, rules: &[CorrectionRule]) -> String { + let mut current = text.to_string(); + for rule in rules { + if !rule.enabled { + continue; + } + let pattern = rule.pattern.trim(); + if pattern.is_empty() { + continue; + } + current = apply_rule(¤t, pattern, &rule.replacement); + } + current +} + +fn apply_rule(text: &str, pattern: &str, replacement: &str) -> String { + let token_count = pattern.matches(NUM_TOKEN).count(); + if token_count == 0 { + if replacement.contains(NUM_TOKEN) { + return text.to_string(); + } + return text.replace(pattern, replacement); + } + if token_count != 1 { + return text.to_string(); + } + apply_num_rule(text, pattern, replacement) +} + +fn apply_num_rule(text: &str, pattern: &str, replacement: &str) -> String { + let Some((prefix, suffix)) = pattern.split_once(NUM_TOKEN) else { + return text.to_string(); + }; + if prefix.is_empty() && suffix.is_empty() { + return text.to_string(); + } + + let mut output = String::with_capacity(text.len()); + let mut cursor = 0usize; + while cursor < text.len() { + let Some((match_start, token_start)) = next_prefix_match(text, cursor, prefix) else { + break; + }; + let Some(token_end) = consume_number_token(text, token_start) else { + output.push_str(&text[cursor..next_char_boundary(text, match_start)]); + cursor = next_char_boundary(text, match_start); + continue; + }; + let after_number = &text[token_end..]; + if !after_number.starts_with(suffix) { + output.push_str(&text[cursor..next_char_boundary(text, match_start)]); + cursor = next_char_boundary(text, match_start); + continue; + } + + let match_end = token_end + suffix.len(); + output.push_str(&text[cursor..match_start]); + output.push_str(&replacement.replace(NUM_TOKEN, &text[token_start..token_end])); + cursor = match_end; + } + output.push_str(&text[cursor..]); + output +} + +fn next_prefix_match(text: &str, cursor: usize, prefix: &str) -> Option<(usize, usize)> { + if prefix.is_empty() { + let match_start = next_number_start(text, cursor)?; + return Some((match_start, match_start)); + } + let relative = text[cursor..].find(prefix)?; + let match_start = cursor + relative; + Some((match_start, match_start + prefix.len())) +} + +fn next_number_start(text: &str, cursor: usize) -> Option { + text[cursor..] + .char_indices() + .find_map(|(offset, ch)| is_number_char(ch).then_some(cursor + offset)) +} + +fn consume_number_token(text: &str, start: usize) -> Option { + let mut end = start; + let mut consumed = false; + for (offset, ch) in text[start..].char_indices() { + if !is_number_char(ch) { + break; + } + consumed = true; + end = start + offset + ch.len_utf8(); + } + consumed.then_some(end) +} + +fn is_number_char(ch: char) -> bool { + ch.is_ascii_digit() + || matches!( + ch, + '零' | '〇' + | '一' + | '二' + | '两' + | '兩' + | '三' + | '四' + | '五' + | '六' + | '七' + | '八' + | '九' + | '十' + | '百' + | '千' + | '万' + | '萬' + | '亿' + | '億' + | '几' + | '幾' + ) +} + +fn next_char_boundary(text: &str, start: usize) -> usize { + text[start..] + .chars() + .next() + .map(|ch| start + ch.len_utf8()) + .unwrap_or(text.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rule(pattern: &str, replacement: &str) -> CorrectionRule { + CorrectionRule { + id: "rule".into(), + pattern: pattern.into(), + replacement: replacement.into(), + enabled: true, + created_at: String::new(), + } + } + + #[test] + fn applies_literal_replacement() { + let rules = vec![rule("几粒", "几例")]; + assert_eq!( + apply_correction_rules("这里有几粒样品", &rules), + "这里有几例样品" + ); + } + + #[test] + fn applies_num_wildcard_for_arabic_digits() { + let rules = vec![rule("{num}粒", "{num}例")]; + assert_eq!( + apply_correction_rules("2粒样品和10粒对照", &rules), + "2例样品和10例对照" + ); + } + + #[test] + fn applies_num_wildcard_for_chinese_numbers() { + let rules = vec![rule("{num}粒", "{num}例")]; + assert_eq!( + apply_correction_rules("两粒样品和幾粒对照", &rules), + "两例样品和幾例对照" + ); + } + + #[test] + fn disabled_rules_are_ignored() { + let mut disabled = rule("{num}粒", "{num}例"); + disabled.enabled = false; + assert_eq!(apply_correction_rules("10粒样品", &[disabled]), "10粒样品"); + } + + #[test] + fn malformed_rules_are_inert() { + let rules = vec![ + rule("{num}到{num}粒", "{num}例"), + rule("几粒", "{num}例"), + rule("{num}", "{num}例"), + ]; + assert_eq!(apply_correction_rules("几粒和10粒", &rules), "几粒和10粒"); + } + + #[test] + fn applies_rules_sequentially() { + let rules = vec![rule("{num}粒", "{num}例"), rule("样本", "样品")]; + assert_eq!(apply_correction_rules("10粒样本", &rules), "10例样品"); + } +} diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 1e47e16a..0f076f84 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -16,6 +16,7 @@ mod combo_hotkey; mod commands; mod coordinator; mod coordinator_state; +mod correction; mod global_hotkey_runtime; mod hotkey; mod insertion; @@ -157,8 +158,8 @@ pub fn run() { // 登录都被主窗口打扰。OPENLESS_SHOW_MAIN_ON_START=1 仍保留 // 老的强制 show 路径(手动 dispatch 测试 / dev 用),优先级高 // 于 prefs。 - let force_show = std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() - == Some("1"); + let force_show = + std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() == Some("1"); let suppress_show = !force_show && coordinator.prefs().get().start_minimized; if suppress_show { log::info!("[main] start_minimized=true → 跳过初始 show,等用户点托盘"); @@ -254,6 +255,10 @@ pub fn run() { commands::add_vocab, commands::remove_vocab, commands::set_vocab_enabled, + commands::list_correction_rules, + commands::add_correction_rule, + commands::remove_correction_rule, + commands::set_correction_rule_enabled, commands::list_vocab_presets, commands::save_vocab_presets, commands::start_dictation, @@ -1197,8 +1202,14 @@ mod tests { #[test] fn tray_style_menu_id_parsing_accepts_only_style_items() { - assert_eq!(parse_tray_polish_mode_id("style-raw"), Some(PolishMode::Raw)); - assert_eq!(parse_tray_polish_mode_id("style-light"), Some(PolishMode::Light)); + assert_eq!( + parse_tray_polish_mode_id("style-raw"), + Some(PolishMode::Raw) + ); + assert_eq!( + parse_tray_polish_mode_id("style-light"), + Some(PolishMode::Light) + ); assert_eq!( parse_tray_polish_mode_id("style-structured"), Some(PolishMode::Structured) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index 191fd2e6..f5984202 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -21,7 +21,9 @@ use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::types::{DictationSession, DictionaryEntry, UserPreferences, VocabPresetStore}; +use crate::types::{ + CorrectionRule, DictationSession, DictionaryEntry, UserPreferences, VocabPresetStore, +}; const HISTORY_CAP: usize = 200; const HISTORY_FILE: &str = "history.json"; @@ -29,6 +31,8 @@ const PREFERENCES_FILE: &str = "preferences.json"; /// 与 Swift `Sources/OpenLessPersistence/DictionaryStore.swift` 同名, /// 让旧版词汇表在升级后无缝继承。**不要**改成 `vocab.json`,会丢用户数据。 const VOCAB_FILE: &str = "dictionary.json"; +const CORRECTION_RULES_FILE: &str = "correction-rules.json"; +const CORRECTION_NUM_TOKEN: &str = "{num}"; const VOCAB_PRESETS_FILE: &str = "vocab-presets.json"; /// 旧版 plaintext JSON 凭据路径。仅作为迁移来源;成功写入系统凭据库后会删除。 @@ -1041,6 +1045,106 @@ pub fn save_vocab_presets(store: &VocabPresetStore) -> Result<()> { atomic_write(&path, &json) } +// ───────────────────────── CorrectionRuleStore ───────────────────────── + +pub struct CorrectionRuleStore { + path: PathBuf, + lock: Mutex<()>, +} + +impl CorrectionRuleStore { + pub fn new() -> Result { + let dir = data_dir()?; + ensure_dir(&dir)?; + Ok(Self { + path: dir.join(CORRECTION_RULES_FILE), + lock: Mutex::new(()), + }) + } + + pub fn list(&self) -> Result> { + let _guard = self.lock.lock(); + self.read_locked() + } + + pub fn add(&self, pattern: String, replacement: String) -> Result { + let pattern = pattern.trim().to_string(); + let replacement = replacement.trim().to_string(); + validate_correction_rule_syntax(&pattern, &replacement)?; + let _guard = self.lock.lock(); + let mut rules = self.read_locked()?; + let rule = CorrectionRule { + id: Uuid::new_v4().to_string(), + pattern, + replacement, + enabled: true, + created_at: Utc::now().to_rfc3339(), + }; + rules.insert(0, rule.clone()); + self.write_locked(&rules)?; + Ok(rule) + } + + pub fn remove(&self, id: &str) -> Result<()> { + let _guard = self.lock.lock(); + let mut rules = self.read_locked()?; + let before = rules.len(); + rules.retain(|r| r.id != id); + if rules.len() == before { + return Ok(()); + } + self.write_locked(&rules) + } + + pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<()> { + let _guard = self.lock.lock(); + let mut rules = self.read_locked()?; + let mut found = false; + for rule in rules.iter_mut() { + if rule.id == id { + rule.enabled = enabled; + found = true; + break; + } + } + if !found { + return Err(anyhow!("correction rule {} not found", id)); + } + self.write_locked(&rules) + } + + fn read_locked(&self) -> Result> { + read_or_default::>(&self.path) + } + + fn write_locked(&self, rules: &[CorrectionRule]) -> Result<()> { + let json = serde_json::to_vec_pretty(rules).context("encode correction rules failed")?; + atomic_write(&self.path, &json) + } +} + +fn validate_correction_rule_syntax(pattern: &str, replacement: &str) -> Result<()> { + if pattern.is_empty() { + return Err(anyhow!("correction rule pattern is empty")); + } + let pattern_token_count = pattern.matches(CORRECTION_NUM_TOKEN).count(); + if pattern_token_count > 1 { + return Err(anyhow!("unsupported correction rule syntax")); + } + if replacement.contains(CORRECTION_NUM_TOKEN) && pattern_token_count == 0 { + return Err(anyhow!("unsupported correction rule syntax")); + } + if pattern_token_count == 1 { + let Some((prefix, suffix)) = pattern.split_once(CORRECTION_NUM_TOKEN) else { + return Err(anyhow!("unsupported correction rule syntax")); + }; + if prefix.is_empty() && suffix.is_empty() { + return Err(anyhow!("unsupported correction rule syntax")); + } + } + Ok(()) +} + // ───────────────────────── CredentialsVault ───────────────────────── #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -1185,7 +1289,8 @@ impl CredentialsVault { #[cfg(test)] mod tests { use super::{ - chunk_json_payload, list_vocab_presets, save_vocab_presets, KEYRING_CHUNK_MAX_UTF16_UNITS, + chunk_json_payload, list_vocab_presets, save_vocab_presets, + validate_correction_rule_syntax, KEYRING_CHUNK_MAX_UTF16_UNITS, }; use crate::types::{VocabPreset, VocabPresetStore}; use std::fs; @@ -1236,4 +1341,14 @@ mod tests { assert_eq!(loaded.disabled_builtin_preset_ids, vec!["chef".to_string()]); let _ = fs::remove_dir_all(&tmp); } + + #[test] + fn correction_rule_syntax_rejects_silent_noops() { + assert!(validate_correction_rule_syntax("{num}粒", "{num}例").is_ok()); + assert!(validate_correction_rule_syntax("几粒", "几例").is_ok()); + assert!(validate_correction_rule_syntax("", "几例").is_err()); + assert!(validate_correction_rule_syntax("{num}", "{num}例").is_err()); + assert!(validate_correction_rule_syntax("{num}到{num}粒", "{num}例").is_err()); + assert!(validate_correction_rule_syntax("几粒", "{num}例").is_err()); + } } diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index e77612a3..5f658b24 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -119,6 +119,18 @@ pub struct DictionaryEntry { pub created_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CorrectionRule { + pub id: String, + pub pattern: String, + pub replacement: String, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub created_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VocabPreset { diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 608b5d18..fdfa9ba4 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -178,6 +178,17 @@ export const en: typeof zhCN = { tipDisabled: 'Click to disable this entry', tipEnabled: 'Click to enable this entry', removeAria: 'Remove', + corrections: { + title: 'Correction rules', + tip: 'Separate from hot words. Deterministically fixes common ASR mistakes; {num} matches numbers. Example: {num}粒 → {num}例.', + patternPlaceholder: 'Mistaken text, e.g. {num}粒', + replacementPlaceholder: 'Target text, e.g. {num}例', + empty: 'No correction rules yet.', + invalid: 'Only literal replacements or one {num} number wildcard are supported, for example {num}粒 → {num}例.', + tipDisabled: 'Click to disable this rule', + tipEnabled: 'Click to enable this rule', + removeAria: 'Remove correction rule', + }, presets: { title: 'Scenario presets', tip: 'Multi-select to apply in batch; supports edit/create and keeps local preset structure ready for future import/export.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 70361f8f..1fe51718 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -180,6 +180,17 @@ export const ja: typeof zhCN = { tipDisabled: 'クリックで無効化', tipEnabled: 'クリックで有効化', removeAria: '削除', + corrections: { + title: '補正ルール', + tip: 'ホットワードとは別に、ASR のよくある誤認識を文字列として補正します。{num} は数字に一致します。例:{num}粒 → {num}例。', + patternPlaceholder: '誤認識された表記(例:{num}粒)', + replacementPlaceholder: '修正後の表記(例:{num}例)', + empty: '補正ルールはまだありません。', + invalid: '文字列の置換、または {num} 数字ワイルドカードを 1 つだけ含むルールに対応しています。例:{num}粒 → {num}例。', + tipDisabled: 'クリックしてこのルールを無効化', + tipEnabled: 'クリックしてこのルールを有効化', + removeAria: '補正ルールを削除', + }, presets: { title: 'シーンプリセット', tip: '複数選択して一括有効化できます。編集と新規作成にも対応し、将来のインポート/エクスポート用のローカル構造を予約しています。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index ead25268..dec4a534 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -180,6 +180,17 @@ export const ko: typeof zhCN = { tipDisabled: '클릭하여 비활성화', tipEnabled: '클릭하여 활성화', removeAria: '삭제', + corrections: { + title: '교정 규칙', + tip: '핫워드와 별도로 ASR의 흔한 오인식을 문자 그대로 교정합니다. {num}은 숫자와 일치합니다. 예: {num}粒 → {num}例.', + patternPlaceholder: '오인식 표현, 예: {num}粒', + replacementPlaceholder: '대상 표현, 예: {num}例', + empty: '아직 교정 규칙이 없습니다.', + invalid: '문자 그대로 바꾸기 또는 {num} 숫자 와일드카드 1개가 포함된 규칙만 지원합니다. 예: {num}粒 → {num}例.', + tipDisabled: '이 규칙 비활성화', + tipEnabled: '이 규칙 활성화', + removeAria: '교정 규칙 삭제', + }, presets: { title: '시나리오 프리셋', tip: '여러 개 선택 후 일괄 활성화 가능. 편집과 새로 만들기 지원, 향후 가져오기/내보내기를 위한 로컬 구조가 예약되어 있습니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 81badd4f..8379d77d 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -176,6 +176,17 @@ export const zhCN = { tipDisabled: '点击禁用此词条', tipEnabled: '点击启用此词条', removeAria: '删除', + corrections: { + title: '纠正规则', + tip: '独立于热词,按字面修正 ASR 常见误识别;支持 {num} 通配数字。例如:{num}粒 → {num}例。', + patternPlaceholder: '误识别写法,如 {num}粒', + replacementPlaceholder: '目标写法,如 {num}例', + empty: '还没有纠正规则。', + invalid: '仅支持字面替换,或一个 {num} 通配数字的规则,例如 {num}粒 → {num}例。', + tipDisabled: '点击禁用此规则', + tipEnabled: '点击启用此规则', + removeAria: '删除纠正规则', + }, presets: { title: '场景预设', tip: '可多选后批量启用;支持编辑和新建,已为后续导入导出预留本地结构。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 5d16b941..c4d8f316 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -178,6 +178,17 @@ export const zhTW: typeof zhCN = { tipDisabled: '點擊禁用此詞條', tipEnabled: '點擊啓用此詞條', removeAria: '刪除', + corrections: { + title: '糾正規則', + tip: '獨立於熱詞,按字面修正 ASR 常見誤識別;支援 {num} 通配數字。例如:{num}粒 → {num}例。', + patternPlaceholder: '誤識別寫法,如 {num}粒', + replacementPlaceholder: '目標寫法,如 {num}例', + empty: '還沒有糾正規則。', + invalid: '僅支援字面替換,或一個 {num} 通配數字的規則,例如 {num}粒 → {num}例。', + tipDisabled: '點擊停用此規則', + tipEnabled: '點擊啟用此規則', + removeAria: '刪除糾正規則', + }, presets: { title: '場景預設', tip: '可多選後批量啓用;支持編輯和新建,已爲後續導入導出預留本地結構。', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index c64a91a9..4b347e4f 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -4,6 +4,7 @@ import type { ComboBinding, + CorrectionRule, CredentialsStatus, DictationSession, DictionaryEntry, @@ -151,6 +152,16 @@ const mockVocab: DictionaryEntry[] = OL_DATA.vocab.map((v, i) => ({ createdAt: new Date().toISOString(), })); +const mockCorrectionRules: CorrectionRule[] = [ + { + id: 'rule-quantity-classifier', + pattern: '{num}粒', + replacement: '{num}例', + enabled: true, + createdAt: new Date().toISOString(), + }, +]; + // ── Settings ─────────────────────────────────────────────────────────── export function getSettings(): Promise { return invokeOrMock('get_settings', undefined, () => mockSettings); @@ -274,6 +285,28 @@ export function setVocabEnabled(id: string, enabled: boolean): Promise { return invokeOrMock('set_vocab_enabled', { id, enabled }, () => undefined); } +export function listCorrectionRules(): Promise { + return invokeOrMock('list_correction_rules', undefined, () => mockCorrectionRules); +} + +export function addCorrectionRule(pattern: string, replacement: string): Promise { + return invokeOrMock('add_correction_rule', { pattern, replacement }, () => ({ + id: `rule-new-${Date.now()}`, + pattern, + replacement, + enabled: true, + createdAt: new Date().toISOString(), + })); +} + +export function removeCorrectionRule(id: string): Promise { + return invokeOrMock('remove_correction_rule', { id }, () => undefined); +} + +export function setCorrectionRuleEnabled(id: string, enabled: boolean): Promise { + return invokeOrMock('set_correction_rule_enabled', { id, enabled }, () => undefined); +} + export function listVocabPresets(): Promise { return invokeOrMock('list_vocab_presets', undefined, () => ({ custom: [], diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 8ab687af..56678d88 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -29,6 +29,14 @@ export interface DictionaryEntry { createdAt: string; } +export interface CorrectionRule { + id: string; + pattern: string; + replacement: string; + enabled: boolean; + createdAt: string; +} + export interface VocabPreset { id: string; name: string; diff --git a/openless-all/app/src/pages/Vocab.tsx b/openless-all/app/src/pages/Vocab.tsx index 3df4241a..98901f73 100644 --- a/openless-all/app/src/pages/Vocab.tsx +++ b/openless-all/app/src/pages/Vocab.tsx @@ -3,12 +3,35 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { addVocab, isTauri, listVocab, removeVocab, setVocabEnabled } from '../lib/ipc'; -import type { DictionaryEntry, VocabPreset } from '../lib/types'; +import { + addCorrectionRule, + addVocab, + isTauri, + listCorrectionRules, + listVocab, + removeCorrectionRule, + removeVocab, + setCorrectionRuleEnabled, + setVocabEnabled, +} from '../lib/ipc'; +import type { CorrectionRule, DictionaryEntry, VocabPreset } from '../lib/types'; import { DEFAULT_VOCAB_PRESETS, loadVocabPresets, persistVocabPresets } from '../lib/vocabPresets'; import { Btn, Card, PageHeader } from './_atoms'; const NEW_PRESET_DRAFT_ID = '__new__'; +const NUM_TOKEN = '{num}'; + +function isSupportedCorrectionRule(pattern: string, replacement: string) { + const tokenCount = pattern.split(NUM_TOKEN).length - 1; + if (!pattern) return false; + if (tokenCount > 1) return false; + if (replacement.includes(NUM_TOKEN) && tokenCount === 0) return false; + if (tokenCount === 1) { + const [prefix, suffix] = pattern.split(NUM_TOKEN); + return Boolean(prefix || suffix); + } + return true; +} export function Vocab() { const { t } = useTranslation(); @@ -22,6 +45,9 @@ export function Vocab() { const [editingPresetId, setEditingPresetId] = useState(null); const [presetNameDraft, setPresetNameDraft] = useState(''); const [presetPhrasesDraft, setPresetPhrasesDraft] = useState(''); + const [correctionRules, setCorrectionRules] = useState([]); + const [rulePatternDraft, setRulePatternDraft] = useState(''); + const [ruleReplacementDraft, setRuleReplacementDraft] = useState(''); const refresh = async () => { try { @@ -36,8 +62,22 @@ export function Vocab() { } }; + const refreshCorrectionRules = async () => { + try { + const data = await listCorrectionRules(); + setCorrectionRules(data); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + const refreshAll = () => { + void refresh(); + void refreshCorrectionRules(); + }; + useEffect(() => { - refresh(); + refreshAll(); void loadVocabPresets() .then(setPresets) .catch(err => setError(err instanceof Error ? err.message : String(err))); @@ -75,6 +115,44 @@ export function Vocab() { } }; + const onAddCorrectionRule = async () => { + const pattern = rulePatternDraft.trim(); + if (!pattern) return; + const replacement = ruleReplacementDraft.trim(); + if (!isSupportedCorrectionRule(pattern, replacement)) { + setError(t('vocab.corrections.invalid')); + return; + } + try { + const rule = await addCorrectionRule(pattern, replacement); + setCorrectionRules(prev => [rule, ...prev]); + setRulePatternDraft(''); + setRuleReplacementDraft(''); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const onRemoveCorrectionRule = async (id: string) => { + try { + await removeCorrectionRule(id); + setCorrectionRules(prev => prev.filter(r => r.id !== id)); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const onToggleCorrectionRule = async (rule: CorrectionRule) => { + const next = !rule.enabled; + setCorrectionRules(prev => prev.map(r => (r.id === rule.id ? { ...r, enabled: next } : r))); + try { + await setCorrectionRuleEnabled(rule.id, next); + } catch (err) { + setCorrectionRules(prev => prev.map(r => (r.id === rule.id ? { ...r, enabled: rule.enabled } : r))); + setError(err instanceof Error ? err.message : String(err)); + } + }; + const onRemove = async (id: string) => { await removeVocab(id); setEntries(prev => prev.filter(e => e.id !== id)); @@ -184,7 +262,7 @@ export function Vocab() { desc={t('vocab.desc')} right={
- {t('common.refresh')} + {t('common.refresh')}
} /> @@ -231,6 +309,43 @@ export function Vocab() { )} +
+
+
+ {t('vocab.corrections.title')} +
{t('vocab.corrections.tip')}
+
+
+ setRulePatternDraft(e.target.value)} + placeholder={t('vocab.corrections.patternPlaceholder')} + style={{ height: 32, padding: '0 10px', border: '0.5px solid var(--ol-line-strong)', borderRadius: 8, background: 'var(--ol-surface-2)' }} + /> + → + setRuleReplacementDraft(e.target.value)} + placeholder={t('vocab.corrections.replacementPlaceholder')} + style={{ height: 32, padding: '0 10px', border: '0.5px solid var(--ol-line-strong)', borderRadius: 8, background: 'var(--ol-surface-2)' }} + /> + void onAddCorrectionRule()}>{t('common.add')} +
+
+ {correctionRules.length === 0 && ( + {t('vocab.corrections.empty')} + )} + {correctionRules.map(rule => ( + void onToggleCorrectionRule(rule)} + onRemove={() => void onRemoveCorrectionRule(rule.id)} + /> + ))} +
+
+
void; + onRemove: () => void; +} + +function CorrectionRuleChip({ rule, onToggle, onRemove }: CorrectionRuleChipProps) { + const { t } = useTranslation(); + const enabled = rule.enabled; + return ( + + + + + ); +} + interface VocabChipProps { entry: DictionaryEntry; onRemove: () => void;