Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/windows-edit-learning.md
Original file line number Diff line number Diff line change
@@ -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 听写应用规则,停用/删除后不再替换。
- [ ] 切换窗口或输入框后不再学习原字段。
- [ ] 密码框、终端、超长字段和重复匹配字段不产生建议。
- [ ] 保存冲突时卡片显示错误而不是虚假成功。

本提交不包含模型权重、录音、个人词库、凭据、本机服务脚本或已编译程序。
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions openless-all/app/src-tauri/src/commands/dictionary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}

/// 卡片上点了叉:丢掉这一条,什么都不记(没有拒绝名单)。
Expand Down
20 changes: 17 additions & 3 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2295,18 +2295,32 @@ 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 {
pattern: taken.pattern,
replacement: taken.replacement,
},
);
self.take_pending_correction(id);
self.refresh_vocab_card();
Ok(())
}

/// 用户在卡片上点了叉 —— 这一条丢掉,什么都不记。
Expand Down
12 changes: 6 additions & 6 deletions openless-all/app/src-tauri/src/coordinator/dictation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -824,9 +824,9 @@ fn arm_edit_watch(inner: &Arc<Inner>, 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);
});
Expand Down Expand Up @@ -894,9 +894,9 @@ fn queue_correction_suggestion(inner: &Arc<Inner>, 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);
}
Expand Down
91 changes: 91 additions & 0 deletions openless-all/app/src-tauri/src/host_document/edit_session.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
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<EditPair> {
let current = self.region(field)?.to_string();
let edit = minimal_edit(&self.baseline, &current)?;
// 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());
}
}
23 changes: 16 additions & 7 deletions openless-all/app/src-tauri/src/host_document/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@
//!
//! ## 边界
//!
//! 所有平台差异关在本模块内。非 macOS 一律返回 [`HostDocumentStatus::Unsupported`]:
//! Windows 没有任何 UIAutomation 代码且 TSF 只在提交瞬间激活;Linux 的 fcitx5
//! SurroundingText 多数客户端不支持。留着接口形状一致,将来补实现不用改调用方。
//! 所有平台差异关在本模块内。光标上下文读取仅支持 macOS;Windows 实现有界 UIA
//! 手改监听(不读取/上传用于润色的光标上下文)。Linux 暂不支持。
//!
//! ## 三条硬约束(新代码不得违反,哪怕仓库里的旧 AX 代码就是这么写的)
//!
Expand All @@ -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;
Expand Down Expand Up @@ -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<std::sync::atomic::AtomicBool>,
}

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);
}
Expand Down Expand Up @@ -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
Expand All @@ -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};
Expand Down
Loading