diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 6803ac60a..4bb83a7c3 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -488,6 +488,9 @@ fn handle_selection_workspace_hotkey_pressed(inner: &Arc) { "selectionPolishNoSelection" | "selected text must not be empty" => { "未选中内容" } + "selectionPolishSelectionTooLong" => { + "选区超过 4000 字,未替换,以免删掉中间没有读到的原文" + } "selectionPolishTargetUnavailable" => "目标输入框不可用,请重新选择", "selectionPolishTargetChanged" | "selectionPolishSelectionChanged" => { "选区已变化,未替换" diff --git a/openless-all/app/src-tauri/src/coordinator/selection_voice_session.rs b/openless-all/app/src-tauri/src/coordinator/selection_voice_session.rs index f9bc7c729..db6e330ae 100644 --- a/openless-all/app/src-tauri/src/coordinator/selection_voice_session.rs +++ b/openless-all/app/src-tauri/src/coordinator/selection_voice_session.rs @@ -121,6 +121,9 @@ fn selection_voice_user_message(error: &str) -> String { match error { "dictationActive" => "正在听写,请先结束录音".into(), "selectionVoiceNoSelection" => "请先选中文字,或将光标放在可输入的文本框中".into(), + "selectionVoiceSelectionTooLong" => { + "选区超过 4000 字,已停止,以免用截断结果覆盖并删掉中间原文".into() + } "selectionVoiceTargetUnavailable" => "无法定位输入目标,请先点击文本框后再试".into(), "selectionVoiceBusy" => "选区语音会话进行中".into(), other => other.into(), @@ -377,9 +380,20 @@ async fn begin_selection_voice_session(inner: &Arc) -> Result<(), String> crate::selection::SelectionContext { text: String::new(), source_app: capture_diag.front_app.clone(), + omits_middle: false, } } }; + // The stored text is only a head/tail stand-in. Continuing would paste a + // rewrite of that stand-in over the whole live selection and delete the middle. + if selection.omits_middle { + log::warn!( + "[selection-voice] begin refused: selection longer than the replacement limit ({})", + capture_diag.summary() + ); + release_selection_voice_capsule_claim(inner); + return Err("selectionVoiceSelectionTooLong".into()); + } let session_id = match inner .backend @@ -778,6 +792,9 @@ impl Coordinator { if !crate::selection::reactivate_selection_insertion_target(&insertion_target) { return Err("selectionVoiceTargetUnavailable".to_string()); } + if crate::selection::is_truncated_selection_stand_in(&ticket.source_text) { + return Err("selectionVoiceSelectionTooLong".to_string()); + } // Empty source_text = caret insert (Help me write). Skip Ctrl+C content // re-validation that assumes a non-empty selection (#1014). if !ticket.source_text.trim().is_empty() { diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index dd900c4ac..084a64027 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -1028,6 +1028,15 @@ impl SelectionPlatformBridge for NativeSelectionPlatformBridge { "selectionPolishNoSelection", ) })?; + // A head/tail stand-in must never become the source of a paste. The + // final validation also compares against the full live selection, but + // refusing here avoids sending the omitted middle's absence to the model. + if selection.omits_middle { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "selectionPolishSelectionTooLong", + )); + } if !crate::selection::selection_insertion_target_is_captured(&target) { return Err(BackendError::new( BackendErrorCode::Platform, @@ -1056,6 +1065,12 @@ impl SelectionPlatformBridge for NativeSelectionPlatformBridge { "selectionPolishTargetUnavailable", )); } + if crate::selection::is_truncated_selection_stand_in(source_text) { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "selectionPolishSelectionTooLong", + )); + } let validation = crate::selection::validate_selection_insertion_target(target, source_text); if let Some(code) = validation.error_code() { let error_code = match validation { diff --git a/openless-all/app/src-tauri/src/mobile_stubs/selection.rs b/openless-all/app/src-tauri/src/mobile_stubs/selection.rs index f09e6f92a..03201ca32 100644 --- a/openless-all/app/src-tauri/src/mobile_stubs/selection.rs +++ b/openless-all/app/src-tauri/src/mobile_stubs/selection.rs @@ -9,6 +9,8 @@ const SELECTION_TRUNCATED_MARKER: &str = "\n[…truncated…]\n"; pub struct SelectionContext { pub text: String, pub source_app: Option, + /// True when `text` dropped the middle of a selection longer than 4000 chars. + pub omits_middle: bool, } pub struct SelectionCaptureOutcome { @@ -70,9 +72,11 @@ pub fn capture_selection() -> Option { "[selection] Android accessibility read OK ({} chars)", trimmed.chars().count() ); + let omits_middle = trimmed.chars().count() > SELECTION_MAX_CHARS; Some(SelectionContext { text: truncate_selection(trimmed), source_app: Some("Android accessibility".to_string()), + omits_middle, }) } @@ -88,6 +92,12 @@ pub(crate) fn current_front_app_parts() -> (Option, Option) { (None, None) } +pub(crate) fn is_truncated_selection_stand_in(text: &str) -> bool { + let marker_len = SELECTION_TRUNCATED_MARKER.chars().count(); + text.contains(SELECTION_TRUNCATED_MARKER) + && text.chars().count() == SELECTION_TRUNCATE_HEAD + marker_len + SELECTION_TRUNCATE_TAIL +} + fn truncate_selection(text: &str) -> String { let total: usize = text.chars().count(); if total <= SELECTION_MAX_CHARS { diff --git a/openless-all/app/src-tauri/src/selection.rs b/openless-all/app/src-tauri/src/selection.rs index c4223ddf3..190e2a3b3 100644 --- a/openless-all/app/src-tauri/src/selection.rs +++ b/openless-all/app/src-tauri/src/selection.rs @@ -8,7 +8,9 @@ //! 后读出新内容 → 还原原剪贴板。 //! //! Truncation policy: selections over 4000 chars keep head 2000 + tail 2000 + the -//! `[…truncated…]` marker, avoiding an overly long LLM context. +//! `[…truncated…]` marker for QA context only. `omits_middle` is set so selection +//! polish and selection voice refuse to paste a rewrite of that stand-in over the +//! real selection (the omitted middle would otherwise be deleted). //! //! 模块依赖:`arboard`(跨平台剪贴板)+ libc + 平台 native 框架。 //! Linux 桌面已改由 egui 前端(`openless-all/app/linux-egui`)承担,Tauri 版不再提供 @@ -24,12 +26,16 @@ const SELECTION_TRUNCATE_TAIL: usize = 2000; const SELECTION_TRUNCATED_MARKER: &str = "\n[…truncated…]\n"; /// Selection context read from the foreground app. -/// `text` is already truncated; `source_app` is a human-readable label for the foreground app -/// (optional). +/// `text` may be a head/tail stand-in when `omits_middle` is set. Replacement +/// flows must refuse those captures; QA may still use the stand-in as context. +/// `source_app` is a human-readable label for the foreground app (optional). #[derive(Debug, Clone)] pub struct SelectionContext { pub text: String, pub source_app: Option, + /// True when `text` dropped the middle of a selection longer than + /// [`SELECTION_MAX_CHARS`]. Pasting a rewrite of `text` would delete that middle. + pub omits_middle: bool, } /// The target that was active when Selection Polish began. This deliberately @@ -440,21 +446,22 @@ pub(crate) fn validate_selection_insertion_target( } } -/// macOS only: re-read the current selection in the same form as at capture time (trim + -/// truncate) for validate to compare against expected_selection. Degrades to simulated -/// Cmd+C + clipboard snapshot when AX is unauthorized or the direct read fails (same fallback as -/// `capture_selection_with_status`). +/// macOS only: re-read the full current selection (trimmed, not head/tail truncated) +/// for validate to compare against expected_selection. A truncated stand-in must not +/// compare equal to the live selection, or paste would delete the omitted middle. +/// Degrades to simulated Cmd+C + clipboard snapshot when AX is unauthorized or the +/// direct read fails (same fallback as `capture_selection_with_status`). #[cfg(target_os = "macos")] fn read_selection_for_validation() -> Option { if let Some(text) = macos_ax::read_selected_text() { let trimmed = text.trim(); if !trimmed.is_empty() { - return Some(truncate_selection(trimmed)); + return Some(trimmed.to_string()); } } let text = simulate_copy_and_read()?; let trimmed = text.trim(); - (!trimmed.is_empty()).then(|| truncate_selection(trimmed)) + (!trimmed.is_empty()).then(|| trimmed.to_string()) } /// Return focus to the original selection target after the preview is confirmed. The preview @@ -594,11 +601,13 @@ fn capture_selection_with_status_diag() -> (SelectionCaptureOutcome, SelectionCa .map(|a| format!(" front_app={a}")) .unwrap_or_default() ); + let (text, omits_middle) = stored_selection_text(trimmed); return ( SelectionCaptureOutcome { selection: Some(SelectionContext { - text: truncate_selection(trimmed), + text, source_app, + omits_middle, }), }, SelectionCaptureMissReason::Ok, @@ -628,11 +637,13 @@ fn capture_selection_with_status_diag() -> (SelectionCaptureOutcome, SelectionCa .map(|a| format!(" front_app={a}")) .unwrap_or_default() ); + let (text, omits_middle) = stored_selection_text(trimmed); return ( SelectionCaptureOutcome { selection: Some(SelectionContext { - text: truncate_selection(trimmed), + text, source_app, + omits_middle, }), }, SelectionCaptureMissReason::Ok, @@ -671,6 +682,25 @@ fn capture_selection_with_status_diag() -> (SelectionCaptureOutcome, SelectionCa ) } +/// True when `text` is the head/tail stand-in produced for an over-long selection. +/// The stand-in length is fixed, so ordinary text that merely mentions the marker +/// is not treated as a truncated capture. +pub(crate) fn is_truncated_selection_stand_in(text: &str) -> bool { + let marker_len = SELECTION_TRUNCATED_MARKER.chars().count(); + text.contains(SELECTION_TRUNCATED_MARKER) + && text.chars().count() == SELECTION_TRUNCATE_HEAD + marker_len + SELECTION_TRUNCATE_TAIL +} + +/// Store a trimmed selection. Over-long text keeps a head/tail stand-in for QA +/// context and sets `omits_middle` so replacement flows can refuse to paste it. +fn stored_selection_text(trimmed: &str) -> (String, bool) { + if trimmed.chars().count() > SELECTION_MAX_CHARS { + (truncate_selection(trimmed), true) + } else { + (trimmed.to_string(), false) + } +} + /// Truncate lengthwise to head + tail + marker. fn truncate_selection(text: &str) -> String { let total: usize = text.chars().count(); @@ -782,14 +812,15 @@ fn classify_simulated_copy_result( Ok(captured) } -/// Read the current selection in the same normalized/truncated form stored by -/// [`SelectionContext`]. This is used only by the Windows final safety check; -/// the clipboard helper snapshots and restores the user's clipboard. +/// Read the full current selection (trimmed, not head/tail truncated). This is +/// used only by the Windows final safety check; the clipboard helper snapshots +/// and restores the user's clipboard. Comparing against the untruncated text +/// stops a head/tail stand-in from authorizing a paste over the omitted middle. #[cfg(target_os = "windows")] fn selected_text_for_validation() -> Option { let text = simulate_copy_and_read()?; let trimmed = text.trim(); - (!trimmed.is_empty()).then(|| truncate_selection(trimmed)) + (!trimmed.is_empty()).then(|| trimmed.to_string()) } #[cfg(any(target_os = "windows", target_os = "macos", test))] @@ -1404,6 +1435,35 @@ mod tests { assert!(!out.contains(&"b".repeat(20))); } + #[test] + fn overlong_selection_is_flagged_and_does_not_match_the_live_text() { + let head: String = "a".repeat(SELECTION_TRUNCATE_HEAD); + let middle: String = "b".repeat(2_000); + let tail: String = "c".repeat(SELECTION_TRUNCATE_TAIL); + let live = format!("{head}{middle}{tail}"); + let (stored, omits_middle) = stored_selection_text(&live); + assert!(omits_middle); + assert!(stored.contains("[…truncated…]")); + assert!(!stored.contains(&"b".repeat(20))); + // Final paste validation compares the stored stand-in with the full + // live selection. They must not match, or the paste deletes `middle`. + assert!(!selection_text_matches(&stored, Some(&live))); + assert!(is_truncated_selection_stand_in(&stored)); + assert!(!is_truncated_selection_stand_in(&live)); + assert!(!is_truncated_selection_stand_in( + "keep this\n[…truncated…]\nnote" + )); + } + + #[test] + fn selection_at_the_replacement_limit_is_kept_whole() { + let live = "字".repeat(SELECTION_MAX_CHARS); + let (stored, omits_middle) = stored_selection_text(&live); + assert!(!omits_middle); + assert_eq!(stored, live); + assert!(selection_text_matches(&stored, Some(&live))); + } + #[test] fn final_selection_check_requires_the_original_text() { assert!(selection_text_matches("original", Some("original")));