diff --git a/docs/windows-edit-learning.md b/docs/windows-edit-learning.md new file mode 100644 index 000000000..c5cc4d203 --- /dev/null +++ b/docs/windows-edit-learning.md @@ -0,0 +1,58 @@ +# Windows 手改学习(实验) + +## 功能与使用 + +将“发现听写后的手改 → 用户确认 → 记住改法”扩展到 Windows。 + +1. 在设置 / 数据存储中开启“手改词条学习(Windows 实验)”。沿用默认关闭的 `cursorContextEnabled` 偏好;不会自动为现有用户打开。 +2. 听写成功插入后,在原输入框内修改一个短词,停顿约 1.2 秒。 +3. 在建议卡片确认后,才将这条替换写入本机纠正规则。取消不会保存。 +4. 后续听写(包括 raw 模式)复用已有纠正规则流程。在词汇表 / 纠正规则中可停用或删除。 + +这是明确确认的文字替换,不是训练 ASR,也不是模型原生热词偏置。macOS 仍保留原来的词汇表学习路径;Windows 不会向润色模型发送输入框上下文。 + +## 实现与隐私边界 + +- 使用现有 `windows` 依赖的 UI Automation TextPattern;COM 对象仅在专属 MTA 线程使用。 +- 250 ms 轮询,最多 60 秒;连接和事务超时均为 500 ms。不主动聚焦控件。 +- 仅观察原前台窗口、原进程、原焦点控件。切换窗口或控件、关闭开关或停止监听后不再提交建议。 +- 输入文本必须在字段中唯一匹配,以固定前后文定位听写区域;不明确的位置不会学习。 +- 读取字段限制为 8192 个 UTF-16 单元,超限跳过。匹配所需字段快照只存在内存,不写入日志或上传;日志仅记录修改的字符数。 +- 每次读取前检查密码标记、焦点、启用状态和进程。跳过已知密码管理器、终端、带终端的编辑器以及 OpenLess 自身;不支持 TextPattern 的控件直接跳过。 +- 不使用键盘记录、剪贴板、OCR 或全局文档监控。UIA 属性依赖宿主实现,进程名单不是对所有敏感应用的完整识别。 +- 单字来源、空替换、通配符、重复冲突和已知连锁替换被拒绝;保存失败在卡片显示错误,不提前移除建议。 + +## 限制 + +富文本编辑器、网页自绘控件、不同进程的内嵌控件或不公开 UIA TextPattern 的应用可能无法学习。追加文字、发送/清空输入框、整句重写和不明确的匹配不会生成规则。固定词替换仍可能在不同语境误改,必须由用户确认,并允许停用/删除。 + +本补丁基于 1.3.18 稳定版;提交目标为 `main`。`beta` 已重构部分 coordinator/core 模块,不能把稳定版构建结果当作 beta 的验证证据。 + +## 验证记录 + +- Windows release 构建、`cargo check --locked --lib` 和前端构建已在本地完成。 +- 提交前重新执行 `npm run build`:通过。 +- 提交前重新运行这份代码先前编译的 release 测试可执行文件:host_document 77 项、persistence::correction 4 项、edit_watch 4 项、raw 纠正规则 1 项,合计 86 项全部通过。此次复跑并非重新编译 Rust 测试。 +- 修改后的 Windows 应用已启动;完整“实际输入框听写 → 手改 → 确认卡片 → 下一次听写应用规则”尚未完成端到端人工验收。macOS/Linux 未在本次环境构建验证。 + +可复现测试命令(在 `openless-all/app`,原生依赖按仓库构建说明准备): + +```sh +npm run build +cd src-tauri +cargo test --locked --release --lib host_document +cargo test --locked --release --lib persistence::correction +cargo test --locked --release --lib coordinator::dictation::tests::edit_watch +cargo test --locked --release --lib non_streamed_output_still_applies_correction_rules +``` + +人工验收清单: + +- [ ] 开关关闭时不出现学习卡片。 +- [ ] 支持 UIA 的输入框中修改短词后出现卡片,拒绝不会创建规则。 +- [ ] 确认后重启应用仍保留规则,raw 听写应用规则,停用/删除后不再替换。 +- [ ] 切换窗口或输入框后不再学习原字段。 +- [ ] 密码框、终端、超长字段和重复匹配字段不产生建议。 +- [ ] 保存冲突时卡片显示错误而不是虚假成功。 + +本提交不包含模型权重、录音、个人词库、凭据、本机服务脚本或已编译程序。 diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 453ffa643..8f44b41a1 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -152,6 +152,7 @@ windows = { version = "0.58", features = [ "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_HiDpi", + "Win32_UI_Accessibility", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_TextServices", diff --git a/openless-all/app/src-tauri/src/commands/dictionary.rs b/openless-all/app/src-tauri/src/commands/dictionary.rs index 07b5dc0dd..00c0f2d8f 100644 --- a/openless-all/app/src-tauri/src/commands/dictionary.rs +++ b/openless-all/app/src-tauri/src/commands/dictionary.rs @@ -48,10 +48,10 @@ pub fn add_correction_rule( .map_err(|e| e.to_string()) } -/// 卡片上点了勾:把这个词收进词汇表,打「自动收集」标记,随时能在词汇表页删掉。 +/// 卡片上点了勾:macOS 保存词条;Windows 保存明确确认的纠正规则,可在词汇表撤销。 #[tauri::command] -pub fn accept_pending_correction(coord: CoordinatorState<'_>, id: String) { - coord.accept_pending_correction(&id); +pub fn accept_pending_correction(coord: CoordinatorState<'_>, id: String) -> Result<(), String> { + coord.accept_pending_correction(&id).map_err(|e| e.to_string()) } /// 卡片上点了叉:丢掉这一条,什么都不记(没有拒绝名单)。 diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 743255b81..4b30c5088 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2295,10 +2295,22 @@ impl Coordinator { } /// 用户在卡片上点了勾 —— 这一条进词汇表。 - pub fn accept_pending_correction(&self, id: &str) { - let Some(taken) = self.take_pending_correction(id) else { - return; + pub fn accept_pending_correction(&self, id: &str) -> anyhow::Result<()> { + let pending = self.inner.pending_corrections.lock().iter().find(|p| p.id == id).cloned(); + let Some(taken) = pending else { + anyhow::bail!("建议已过期,请重新修改后确认"); }; + // Windows card explicitly asks to remember a replacement. This works + // with local ASR/raw mode without changing the model or adding an LLM. + #[cfg(target_os = "windows")] + { + self.inner.correction_rules.add_confirmed(taken.pattern.clone(), taken.replacement.clone())?; + log::info!("[edit-learning] confirmed correction saved"); + if let Some(app) = self.inner.app.lock().clone() { + let _ = app.emit("vocab:updated", 0u64); + } + } + #[cfg(not(target_os = "windows"))] dictation::commit_learned_rule( &self.inner, &crate::host_document::LearnedRule { @@ -2306,7 +2318,9 @@ impl Coordinator { replacement: taken.replacement, }, ); + self.take_pending_correction(id); self.refresh_vocab_card(); + Ok(()) } /// 用户在卡片上点了叉 —— 这一条丢掉,什么都不记。 diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 9268b9ca6..386474290 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -824,9 +824,9 @@ fn arm_edit_watch(inner: &Arc, status: InsertStatus, typed_text: &str) { return; } log::info!( - "[cursor-context] user edit detected: source={:?} target={:?}", - edit.source, - edit.target + "[cursor-context] user edit detected: source_chars={} target_chars={}", + edit.source.chars().count(), + edit.target.chars().count() ); handle_user_edit(&inner_for_edit, edit); }); @@ -894,9 +894,9 @@ fn queue_correction_suggestion(inner: &Arc, rule: &crate::host_document:: }); } log::info!( - "[cursor-context] vocabulary suggested (awaiting confirmation): {:?} (was {:?})", - rule.replacement, - rule.pattern + "[cursor-context] vocabulary suggested (awaiting confirmation): target_chars={} source_chars={}", + rule.replacement.chars().count(), + rule.pattern.chars().count() ); super::show_vocab_suggestion_card(inner); } diff --git a/openless-all/app/src-tauri/src/host_document/edit_session.rs b/openless-all/app/src-tauri/src/host_document/edit_session.rs new file mode 100644 index 000000000..ba52aec60 --- /dev/null +++ b/openless-all/app/src-tauri/src/host_document/edit_session.rs @@ -0,0 +1,91 @@ +//! Bounded, position-anchored edit tracking. No OS calls and no persistence. +use super::{learned_rule, minimal_edit, EditPair}; + +pub(super) const MAX_FIELD_CHARS: usize = 8192; + +pub(super) struct EditSession { + prefix: String, + suffix: String, + baseline: String, +} + +impl EditSession { + pub fn anchor(field: &str, inserted: &str) -> Option { + let inserted = inserted.trim_end(); + if inserted.is_empty() || field.chars().count() > MAX_FIELD_CHARS { + return None; + } + let mut matches = field.match_indices(inserted); + let (offset, _) = matches.next()?; + // Ambiguous location: never learn edits to another occurrence. + if matches.next().is_some() { + return None; + } + Some(Self { + prefix: field[..offset].into(), + suffix: field[offset + inserted.len()..].into(), + baseline: inserted.into(), + }) + } + + /// None means the original region can no longer be tracked safely. + pub fn region<'a>(&self, field: &'a str) -> Option<&'a str> { + if field.chars().count() > MAX_FIELD_CHARS { + return None; + } + field.strip_prefix(&self.prefix)?.strip_suffix(&self.suffix) + } + + /// Called only after the input has been stable (IME typing debounce). + pub fn settled_edit(&mut self, field: &str) -> Option { + let current = self.region(field)?.to_string(); + let edit = minimal_edit(&self.baseline, ¤t)?; + // Keep the baseline through delete-then-retype and reject sentence rewrites. + learned_rule(&edit)?; + self.baseline = current; + Some(edit) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tracks_only_the_inserted_region() { + let mut s = + EditSession::anchor("前文。今天讨论大禹养殖。后文", "今天讨论大禹养殖。").unwrap(); + assert!(s.region("改前文。今天讨论大禹养殖。后文").is_none()); + let edit = s.settled_edit("前文。今天讨论大鱼养殖。后文").unwrap(); + let rule = learned_rule(&edit).unwrap(); + assert_eq!( + (rule.pattern.as_str(), rule.replacement.as_str()), + ("大禹", "大鱼") + ); + assert!(s.settled_edit("前文。今天讨论大鱼养殖。后文").is_none()); + } + + #[test] + fn rejects_ambiguous_and_oversized_fields() { + assert!(EditSession::anchor("重复重复", "重复").is_none()); + assert!(EditSession::anchor(&"字".repeat(MAX_FIELD_CHARS + 1), "字").is_none()); + assert!(EditSession::anchor("空白", "").is_none()); + } + + #[test] + fn delete_then_retype_does_not_lose_the_original_word() { + let mut s = EditSession::anchor("请用扣德克斯。", "请用扣德克斯。").unwrap(); + assert!(s.settled_edit("请用。").is_none()); + let edit = s.settled_edit("请用Codex。").unwrap(); + assert_eq!(edit.source, "扣德克斯"); + assert_eq!(edit.target, "Codex"); + } + + #[test] + fn rejects_append_send_and_sentence_rewrite() { + let mut s = EditSession::anchor("今天讨论大禹养殖。", "今天讨论大禹养殖。").unwrap(); + assert!(s.settled_edit("今天讨论大禹养殖。继续输入").is_none()); + assert!(s.settled_edit("").is_none()); + assert!(s.settled_edit("明天不用开会了,取消安排。").is_none()); + } +} diff --git a/openless-all/app/src-tauri/src/host_document/mod.rs b/openless-all/app/src-tauri/src/host_document/mod.rs index ddd2675d9..32c4ebe53 100644 --- a/openless-all/app/src-tauri/src/host_document/mod.rs +++ b/openless-all/app/src-tauri/src/host_document/mod.rs @@ -5,9 +5,8 @@ //! //! ## 边界 //! -//! 所有平台差异关在本模块内。非 macOS 一律返回 [`HostDocumentStatus::Unsupported`]: -//! Windows 没有任何 UIAutomation 代码且 TSF 只在提交瞬间激活;Linux 的 fcitx5 -//! SurroundingText 多数客户端不支持。留着接口形状一致,将来补实现不用改调用方。 +//! 所有平台差异关在本模块内。光标上下文读取仅支持 macOS;Windows 实现有界 UIA +//! 手改监听(不读取/上传用于润色的光标上下文)。Linux 暂不支持。 //! //! ## 三条硬约束(新代码不得违反,哪怕仓库里的旧 AX 代码就是这么写的) //! @@ -27,6 +26,10 @@ mod diff; mod window; +#[cfg(any(target_os = "windows", test))] +mod edit_session; +#[cfg(target_os = "windows")] +mod windows; #[cfg(target_os = "macos")] mod macos; @@ -346,14 +349,14 @@ fn blocked_result(reason: BlockReason) -> HostDocumentReadResult { /// 的每次击键唤醒。所以除了这里的 RAII,观察线程自己还有 60 秒硬超时和「前台 app 一换 /// 就自杀」两道保险。 pub struct EditWatcher { - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "windows"))] stop: std::sync::Arc, } impl EditWatcher { /// 主动解除。幂等,drop 时会自动调用。 pub fn disarm(&self) { - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "windows"))] self.stop .store(true, std::sync::atomic::Ordering::Relaxed); } @@ -385,7 +388,13 @@ where let stop = macos::spawn_edit_watcher(typed_text, Box::new(on_edit))?; Some(EditWatcher { stop }) } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "windows")] + { + if typed_text.trim().is_empty() { return None; } + let stop = windows::spawn_edit_watcher(typed_text, Box::new(on_edit))?; + Some(EditWatcher { stop }) + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] { let _ = (typed_text, on_edit); None @@ -404,7 +413,7 @@ mod tests { /// /// 这条链一旦断了,症状是**静默的**:观察器活到 60 秒硬超时才停,期间继续读用户 /// 正在写的文档、继续上报,还会和新武装的那个并行跑。所以钉一个测试在这里。 - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "windows"))] #[test] fn dropping_the_watcher_stops_the_observer_thread() { use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/openless-all/app/src-tauri/src/host_document/windows.rs b/openless-all/app/src-tauri/src/host_document/windows.rs new file mode 100644 index 000000000..27378eddc --- /dev/null +++ b/openless-all/app/src-tauri/src/host_document/windows.rs @@ -0,0 +1,248 @@ +//! Windows UI Automation edit observer. All COM work stays on one MTA thread. +//! No keyboard logging, clipboard access, OCR, or document upload. +use super::{ + edit_session::{EditSession, MAX_FIELD_CHARS}, + EditPair, +}; +use ::windows::{ + core::{Interface, PWSTR}, + Win32::{ + Foundation::{CloseHandle, HWND}, + System::{ + Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER, + COINIT_MULTITHREADED, + }, + Threading::{ + OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_WIN32, + PROCESS_QUERY_LIMITED_INFORMATION, + }, + }, + UI::{ + Accessibility::{ + CUIAutomation8, IUIAutomation, IUIAutomation2, IUIAutomationElement, + IUIAutomationTextPattern, UIA_TextPatternId, + }, + WindowsAndMessaging::{GetForegroundWindow, GetWindowThreadProcessId}, + }, + }, +}; +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread, + time::{Duration, Instant}, +}; + +const POLL: Duration = Duration::from_millis(250); +const SETTLE: Duration = Duration::from_millis(1200); +const LIFETIME: Duration = Duration::from_secs(60); + +struct ComApartment; +impl Drop for ComApartment { + fn drop(&mut self) { + unsafe { CoUninitialize() } + } +} + +fn blocked_process(path: &str) -> bool { + let name = path + .rsplit(['\\', '/']) + .next() + .unwrap_or(path) + .to_ascii_lowercase(); + [ + "openless.exe", + "1password.exe", + "bitwarden.exe", + "keepass.exe", + "keepassxc.exe", + "dashlane.exe", + "lastpass.exe", + "keeperpasswordmanager.exe", + "enpass.exe", + "cmd.exe", + "powershell.exe", + "pwsh.exe", + "windowsterminal.exe", + "wt.exe", + "conhost.exe", + "openconsole.exe", + "mintty.exe", + "wezterm-gui.exe", + "alacritty.exe", + "code.exe", + "cursor.exe", + "windsurf.exe", + ] + .contains(&name.as_str()) +} + +unsafe fn safe_foreground(hwnd: HWND) -> Option { + if hwnd.0.is_null() || GetForegroundWindow() != hwnd { + return None; + } + let mut pid = 0; + GetWindowThreadProcessId(hwnd, Some(&mut pid)); + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?; + let mut path = [0u16; 32768]; + let mut size = path.len() as u32; + let result = QueryFullProcessImageNameW( + handle, + PROCESS_NAME_WIN32, + PWSTR(path.as_mut_ptr()), + &mut size, + ); + let _ = CloseHandle(handle); + result.ok()?; + if blocked_process(&String::from_utf16_lossy(&path[..size as usize])) { + return None; + } + Some(pid) +} + +unsafe fn read_field(element: &IUIAutomationElement, pid: u32) -> Option { + // Privacy checks precede every content read, including initial anchoring. + if element.CurrentProcessId().ok()? as u32 != pid + || element.CurrentIsPassword().ok()?.as_bool() + || !element.CurrentHasKeyboardFocus().ok()?.as_bool() + || !element.CurrentIsEnabled().ok()?.as_bool() + { + return None; + } + let class_name = element + .CurrentClassName() + .ok()? + .to_string() + .to_ascii_lowercase(); + if ["terminal", "termcontrol", "console", "xterm"] + .iter() + .any(|s| class_name.contains(s)) + { + return None; + } + let pattern: IUIAutomationTextPattern = element.GetCurrentPatternAs(UIA_TextPatternId).ok()?; + let text = pattern + .DocumentRange() + .ok()? + .GetText((MAX_FIELD_CHARS + 1) as i32) + .ok()? + .to_string(); + // UIA's maxLength is UTF-16, not Rust char count. Reject truncated emoji + // documents too, rather than treating a truncated prefix as the full field. + if text.encode_utf16().count() > MAX_FIELD_CHARS { + return None; + } + Some(text.replace("\r\n", "\n").replace('\r', "\n")) +} + +pub(super) fn spawn_edit_watcher( + typed: String, + on_edit: Box, +) -> Option> { + // Capture the target now, not after a delayed background task starts. + let hwnd_value = unsafe { GetForegroundWindow().0 as usize }; + let stop = Arc::new(AtomicBool::new(false)); + let worker_stop = Arc::clone(&stop); + thread::Builder::new() + .name("openless-edit-watch".into()) + .spawn(move || { + let result = + unsafe { observe(HWND(hwnd_value as *mut _), &typed, &worker_stop, on_edit) }; + if result.is_none() { + log::debug!("[edit-learning] input unavailable or safety gate closed"); + } + }) + .ok()?; + Some(stop) +} + +unsafe fn observe( + hwnd: HWND, + typed: &str, + stop: &AtomicBool, + on_edit: Box, +) -> Option<()> { + let started = Instant::now(); + let pid = safe_foreground(hwnd)?; + CoInitializeEx(None, COINIT_MULTITHREADED).ok().ok()?; + let _apartment = ComApartment; + let uia2: IUIAutomation2 = + CoCreateInstance(&CUIAutomation8, None, CLSCTX_INPROC_SERVER).ok()?; + uia2.SetConnectionTimeout(500).ok()?; + uia2.SetTransactionTimeout(500).ok()?; + uia2.SetAutoSetFocus(false).ok()?; + let uia: IUIAutomation = uia2.cast().ok()?; + let element = uia.GetFocusedElement().ok()?; + let typed = typed.replace("\r\n", "\n").replace('\r', "\n"); + let mut snapshot = String::new(); + let mut session = None; + // TSF/paste delivery may finish just after insertion returns. + for _ in 0..8 { + if stop.load(Ordering::Relaxed) || safe_foreground(hwnd)? != pid { + return None; + } + if !uia + .CompareElements(&element, &uia.GetFocusedElement().ok()?) + .ok()? + .as_bool() + { + return None; + } + snapshot = read_field(&element, pid)?; + session = EditSession::anchor(&snapshot, &typed); + if session.is_some() { + break; + } + thread::sleep(POLL); + } + let mut session = session?; + log::info!("[edit-learning] Windows observer armed (60s maximum)"); + let mut changed_at = Instant::now(); + let mut unsettled = false; + while !stop.load(Ordering::Relaxed) && started.elapsed() < LIFETIME { + thread::sleep(POLL); + if stop.load(Ordering::Relaxed) || safe_foreground(hwnd)? != pid { + return None; + } + if !uia + .CompareElements(&element, &uia.GetFocusedElement().ok()?) + .ok()? + .as_bool() + { + return None; + } + let next = read_field(&element, pid)?; + if next.is_empty() || session.region(&next).is_none() { + return None; + } + if next != snapshot { + snapshot = next; + changed_at = Instant::now(); + unsettled = true; + } else if unsettled && changed_at.elapsed() >= SETTLE { + unsettled = false; + if let Some(edit) = session.settled_edit(&snapshot) { + if !stop.load(Ordering::Relaxed) { + on_edit(edit); + } + } + } + } + Some(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn blocks_sensitive_processes_case_insensitively() { + assert!(blocked_process(r"C:\Apps\KeePassXC.EXE")); + assert!(blocked_process(r"C:\Windows\System32\cmd.exe")); + assert!(blocked_process("Code.exe")); // embedded terminals + assert!(!blocked_process("notepad.exe")); + assert!(!blocked_process("chrome.exe")); + } +} diff --git a/openless-all/app/src-tauri/src/persistence/correction.rs b/openless-all/app/src-tauri/src/persistence/correction.rs index bcaf7ecbe..b90d363ce 100644 --- a/openless-all/app/src-tauri/src/persistence/correction.rs +++ b/openless-all/app/src-tauri/src/persistence/correction.rs @@ -55,6 +55,33 @@ impl CorrectionRuleStore { self.add_with_source(pattern, replacement, RuleSource::Manual) } + /// Only called after an explicit confirmation. Never create wildcard or + /// chained rules from observed edits; duplicates and conflicts are visible. + #[cfg(any(target_os = "windows", test))] + pub fn add_confirmed(&self, pattern: String, replacement: String) -> Result { + let pattern = pattern.trim().to_string(); + let replacement = replacement.trim().to_string(); + if pattern.chars().count() < 2 || replacement.is_empty() || pattern == replacement + || pattern.contains(CORRECTION_NUM_TOKEN) || replacement.contains(CORRECTION_NUM_TOKEN) + || replacement.contains(&pattern) { + return Err(anyhow!("该改法不适合作为固定纠正规则,请在词汇表手动设置")); + } + let _guard = self.lock.lock(); + let mut rules = self.read_locked()?; + if let Some(rule) = rules.iter().find(|r| r.pattern == pattern) { + if rule.enabled && rule.replacement == replacement { return Ok(rule.clone()); } + return Err(anyhow!("此写法已有不同或已停用的规则,请在词汇表检查")); + } + if rules.iter().filter(|r| r.enabled).any(|r| + replacement.contains(&r.pattern) || r.replacement.contains(&pattern)) { + return Err(anyhow!("此改法会与已有规则产生连锁替换,请在词汇表检查")); + } + let rule = new_rule(pattern, replacement, RuleSource::Learned); + rules.insert(0, rule.clone()); + self.write_locked(&rules)?; + Ok(rule) + } + fn add_with_source( &self, pattern: String, @@ -148,6 +175,35 @@ mod tests { use super::validate_correction_rule_syntax; use crate::types::{CorrectionRule, RuleSource}; + #[test] + fn confirmed_rules_persist_deduplicate_and_reject_chains() { + let directory = std::env::temp_dir().join(format!("openless-confirmed-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&directory).unwrap(); + let path = directory.join("rules.json"); + let store = super::CorrectionRuleStore::new_at(path.clone()); + assert!(store.list().unwrap().is_empty()); + let rule = store.add_confirmed("玄策".into(), "旋测".into()).unwrap(); + assert_eq!(rule.source, RuleSource::Learned); + let again = store.add_confirmed("玄策".into(), "旋测".into()).unwrap(); + assert_eq!(rule.id, again.id); + let reopened = super::CorrectionRuleStore::new_at(path.clone()); + let rules = reopened.list().unwrap(); + assert_eq!(rules.len(), 1); + assert_eq!(crate::correction::apply_correction_rules("江苏玄策", &rules), "江苏旋测"); + assert!(store.add_confirmed("旋测".into(), "玄策".into()).is_err()); + assert!(store.add_confirmed("另词".into(), "玄策".into()).is_err()); + assert!(store.add_confirmed("玄策".into(), "其他".into()).is_err()); + assert!(store.add_confirmed("{num}".into(), "数字".into()).is_err()); + assert!(store.add_confirmed("甲".into(), "乙".into()).is_err()); + store.set_enabled(&rule.id, false).unwrap(); + assert_eq!(crate::correction::apply_correction_rules("江苏玄策", &store.list().unwrap()), "江苏玄策"); + assert!(store.add_confirmed("玄策".into(), "旋测".into()).is_err()); + store.remove(&rule.id).unwrap(); + assert!(store.list().unwrap().is_empty()); + std::fs::remove_file(path).unwrap(); + std::fs::remove_dir(directory).unwrap(); + } + #[test] fn correction_rule_syntax_rejects_silent_noops() { assert!(validate_correction_rule_syntax("{num}粒", "{num}例").is_ok()); @@ -160,8 +216,7 @@ mod tests { /// 老的 correction-rules.json 没有 `source` 字段,反序列化必须落到 Manual。 /// - /// 学习路径已经不再写纠正规则了(只写词汇表),但**早期版本写进去的 `learned` - /// 规则还躺在用户的文件里**,前端要能认出它们、让用户删掉。所以这个字段留着。 + /// Windows 用户明确确认的替换使用 Learned;手动规则仍使用 Manual。 #[test] fn a_rule_without_a_source_field_deserializes_as_manual() { let json = r#"{"id":"1","pattern":"甲","replacement":"乙","enabled":true,"createdAt":""}"#; diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 65afce0c0..2a8ba2161 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -382,7 +382,7 @@ pub struct CorrectionRule { #[serde(rename_all = "camelCase")] pub struct PendingCorrection { pub id: String, - /// 改之前那个(错的)写法。只用来在卡片上让用户看清改的是什么,不入库。 + /// 改之前的写法。macOS 只展示;Windows 明确确认替换后保存为纠正规则。 pub pattern: String, /// 用户最后要的那个词 —— 点「好」之后进词汇表的就是它。 pub replacement: String, diff --git a/openless-all/app/src/components/VocabSuggestionCard.tsx b/openless-all/app/src/components/VocabSuggestionCard.tsx index ebcdde9f9..66850ed17 100644 --- a/openless-all/app/src/components/VocabSuggestionCard.tsx +++ b/openless-all/app/src/components/VocabSuggestionCard.tsx @@ -18,6 +18,7 @@ import { rejectPendingCorrection, } from '../lib/ipc'; import type { PendingCorrection } from '../lib/types'; +import { detectOS } from './WindowChrome'; /// 卡片自己消失的时间,与后端 `VOCAB_SUGGESTION_TTL_MS` 对齐。 const TTL_MS = 10_000; @@ -30,6 +31,7 @@ export function VocabSuggestionCard({ suggestions }: VocabSuggestionCardProps) { const { t } = useTranslation(); // 点过的立刻从卡片上消失——不等后端回音,点了就该有反应。 const [resolved, setResolved] = useState>(new Set()); + const [error, setError] = useState(''); const timerRef = useRef(null); // 10 秒倒计时。列表一变就重新计时:同一次听写里连着改了几个词会陆续追加进来, @@ -53,7 +55,9 @@ export function VocabSuggestionCard({ suggestions }: VocabSuggestionCardProps) { setResolved(prev => new Set(prev).add(id)); try { await commit(id); - } catch { + setError(''); + } catch (reason) { + setError(String(reason)); setResolved(prev => { const next = new Set(prev); next.delete(id); @@ -96,6 +100,7 @@ export function VocabSuggestionCard({ suggestions }: VocabSuggestionCardProps) { }} >
- {t('vocabCard.title')} + {error || t(detectOS() === 'win' ? 'vocabCard.windowsTitle' : 'vocabCard.title')}
@@ -136,7 +141,7 @@ export function VocabSuggestionCard({ suggestions }: VocabSuggestionCardProps) { void resolve(s.id, acceptPendingCorrection)} /> { const { listen } = await import('@tauri-apps/api/event'); const handle = await listen('vocab:updated', () => { - void refresh(); + refreshAll(); }); if (cancelled) handle(); else unlisten = handle; diff --git a/openless-all/app/src/pages/settings/DataStorageSection.tsx b/openless-all/app/src/pages/settings/DataStorageSection.tsx index 0ab28bd06..daef44db4 100644 --- a/openless-all/app/src/pages/settings/DataStorageSection.tsx +++ b/openless-all/app/src/pages/settings/DataStorageSection.tsx @@ -80,13 +80,12 @@ export function DataStorageSection() { style={{ ...inputStyle, width: 80, textAlign: 'right' }} /> - {/* 光标上下文。放在「隐私」而不是「润色」下是有意的:这个开关真正的代价不是 - token,而是「把别的 app 里的文字发给 LLM 服务商」。只在 macOS 显示—— - 其余平台没有实现,摆一个拨不动结果的开关只会误导。 */} - {detectOS() === 'mac' && ( + {/* macOS: cursor context and edit learning. Windows: local-only edit + learning; host-document context is never supplied to the LLM. */} + {(detectOS() === 'mac' || detectOS() === 'win') && (