diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 00869fb23..c7824663b 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -101,6 +101,7 @@ jobs: libxdo-dev \ patchelf \ rpm \ + squashfs-tools \ wget - name: Install npm deps @@ -439,6 +440,8 @@ jobs: mkdir -p "$GITHUB_WORKSPACE/openless-all/app/src-tauri/linux-fcitx5-plugin" cp libopenless.so "$GITHUB_WORKSPACE/openless-all/app/src-tauri/linux-fcitx5-plugin/" cp openless.conf "$GITHUB_WORKSPACE/openless-all/app/src-tauri/linux-fcitx5-plugin/" + test -s "$GITHUB_WORKSPACE/openless-all/app/src-tauri/linux-fcitx5-plugin/libopenless.so" + test -s "$GITHUB_WORKSPACE/openless-all/app/src-tauri/linux-fcitx5-plugin/openless.conf" - name: Build (Linux) if: matrix.platform == 'ubuntu-22.04' @@ -453,10 +456,15 @@ jobs: # ensure_plugin_installed() 自动安装到 ~/.local/ 下。 # 插件 .so + .conf 由上一步 Build fcitx5 plugin 生成并复制到 # src-tauri/linux-fcitx5-plugin/ 下。 + test -s src-tauri/linux-fcitx5-plugin/libopenless.so + test -s src-tauri/linux-fcitx5-plugin/openless.conf cat > /tmp/tauri-linux-config.json << CONFIG_EOF { "bundle": { - "resources": ["linux-fcitx5-plugin/libopenless.so"], + "resources": [ + "linux-fcitx5-plugin/libopenless.so", + "linux-fcitx5-plugin/openless.conf" + ], "linux": { "deb": { "depends": ["fcitx5", "fcitx5-module-dbus", "libdbus-1-3"], @@ -484,6 +492,21 @@ jobs: fi npm run tauri -- build --bundles deb,rpm,appimage --config "$CONFIG_FILE" + APPIMAGE_PATH=$(find src-tauri/target/release/bundle/appimage -maxdepth 1 -name '*.AppImage' -print -quit) + if [ -z "$APPIMAGE_PATH" ]; then + echo "::error::AppImage bundle was not produced" + exit 1 + fi + APPIMAGE_CONTENTS=$(unsquashfs -l "$APPIMAGE_PATH") + if ! grep -Eq '(^|/)usr/lib/OpenLess/linux-fcitx5-plugin/libopenless\.so$' <<< "$APPIMAGE_CONTENTS"; then + echo "::error::AppImage is missing usr/lib/OpenLess/linux-fcitx5-plugin/libopenless.so" + exit 1 + fi + if ! grep -Eq '(^|/)usr/lib/OpenLess/linux-fcitx5-plugin/openless\.conf$' <<< "$APPIMAGE_CONTENTS"; then + echo "::error::AppImage is missing usr/lib/OpenLess/linux-fcitx5-plugin/openless.conf" + exit 1 + fi + - name: Disambiguate macOS updater bundle filename if: startsWith(matrix.platform, 'macos') && env.TAURI_SIGNING_PRIVATE_KEY != '' shell: bash diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index f37ee6453..fd9d152fc 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -121,6 +121,17 @@ enum CapsuleShowStrategy { FallbackShow, } +/// 是否在回答期间显示「处理中 / 润色中」胶囊反馈。 +/// +/// 语音 / 听写路径显示(用户熟悉的小录音条状态机;Linux 下映射到 fcitx5 +/// auxDown,显示在候选词栏下方);打字提问路径不显示(回答在 QA 面板内 +/// 流式可见,不应在输入法候选栏闪「✨ 润色中...」)。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CapsuleFeedback { + Show, + Hide, +} + fn capsule_show_strategy_for_platform() -> CapsuleShowStrategy { // ⚠️ 如果改下面的 cfg 列表,**必须**同步更新单元测试 // `capsule_show_strategy_matches_platform_activation_contract` 的两组 cfg — @@ -2174,7 +2185,14 @@ impl Coordinator { // callback (SIGABRT). Tauri's runtime handle is safe from either thread. tauri::async_runtime::spawn(async move { let session_id = crate::coordinator_state::new_session_id(); - if let Err(e) = dictation::run_voice_agent_transcript(&inner, session_id, text, 0).await + if let Err(e) = dictation::run_voice_agent_transcript( + &inner, + session_id, + text, + 0, + CapsuleFeedback::Hide, + ) + .await { log::warn!("[less-computer] text submit run failed: {e}"); } diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index cc2b15b96..47150d78c 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1333,20 +1333,27 @@ pub(super) async fn run_voice_agent_transcript( _session_id: SessionId, transcript: String, elapsed: u64, + // 语音路径 Show:显示胶囊「处理中」反馈(既有行为);打字路径 + // (less_computer_submit_text)Hide —— 对话在浮窗里已可见,不应在输入法 + // auxDown 闪「润色中」,用户已确认。 + capsule_feedback: super::CapsuleFeedback, ) -> Result<(), String> { log::info!( "[coord] Cloud Agent 语音:指令 {} 字", transcript.chars().count() ); // 胶囊保留「处理中」反馈(用户熟悉的小录音条状态机);聊天浮窗承载完整对话。 - emit_capsule( - inner, - CapsuleState::Polishing, - 0.0, - elapsed, - Some("Agent 处理中…".to_string()), - None, - ); + // Linux 下会映射到 fcitx5 auxDown("✨ 润色中...")显示在候选词栏下方。 + if capsule_feedback == super::CapsuleFeedback::Show { + emit_capsule( + inner, + CapsuleState::Polishing, + 0.0, + elapsed, + Some("Agent 处理中…".to_string()), + None, + ); + } // 聊天浮窗:显示窗口 + 落用户气泡(语音指令转写)。macOS only(helper 内部 gating)。 if let Some(app) = inner.app.lock().clone() { @@ -4083,8 +4090,14 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { // Cloud Agent 语音分流:长按升级的会话不走润色/插入,转写交给 Claude 跑任务、结果弹胶囊。 if inner.state.lock().voice_agent { - return run_voice_agent_transcript(inner, current_session_id, raw.text.clone(), elapsed) - .await; + return run_voice_agent_transcript( + inner, + current_session_id, + raw.text.clone(), + elapsed, + super::CapsuleFeedback::Show, + ) + .await; } emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None); @@ -4675,7 +4688,14 @@ async fn finish_dictation_multimodal( // Less Computer:转写文本交给 CLI agent,不走插入/历史(agent 流程自己收尾)。 if voice_agent { - return run_voice_agent_transcript(inner, current_session_id, output, elapsed).await; + return run_voice_agent_transcript( + inner, + current_session_id, + output, + elapsed, + super::CapsuleFeedback::Show, + ) + .await; } let correction_rules = match inner.correction_rules.list() { diff --git a/openless-all/app/src-tauri/src/coordinator/qa.rs b/openless-all/app/src-tauri/src/coordinator/qa.rs index 83c83fb80..5f5755272 100644 --- a/openless-all/app/src-tauri/src/coordinator/qa.rs +++ b/openless-all/app/src-tauri/src/coordinator/qa.rs @@ -117,7 +117,6 @@ pub(super) fn open_qa_panel(inner: &Arc) { serde_json::json!({ "kind": "idle", "session_id": session_id, - "selection_warning": null, "messages": Vec::::new(), }), ); diff --git a/openless-all/app/src-tauri/src/coordinator/qa_session.rs b/openless-all/app/src-tauri/src/coordinator/qa_session.rs index 8dc97ef58..c1914da6c 100644 --- a/openless-all/app/src-tauri/src/coordinator/qa_session.rs +++ b/openless-all/app/src-tauri/src/coordinator/qa_session.rs @@ -144,7 +144,6 @@ pub(super) async fn finalize_dictation_as_qa_question(inner: &Arc) -> Res log::info!("[coord] QA finalize from overlay: capturing selection before opening panel"); let capture = crate::selection::capture_selection_with_status(); let selection = capture.selection; - let selection_warning = capture.warning_code; let selection_preview_text = selection.as_ref().map(|s| s.text.clone()); log::info!("[coord] QA finalize from overlay: opening panel and waiting for ASR result"); @@ -173,7 +172,6 @@ pub(super) async fn finalize_dictation_as_qa_question(inner: &Arc) -> Res "kind": "loading", "session_id": session_id, "selection_preview": selection_preview_text, - "selection_warning": selection_warning, "messages": state.messages.clone(), }), ); @@ -208,6 +206,7 @@ pub(super) async fn finalize_dictation_as_qa_question(inner: &Arc) -> Res raw.duration_ms, session_id, None, + super::CapsuleFeedback::Show, ) .await } @@ -245,7 +244,6 @@ pub(super) async fn submit_qa_text_question( .selection .as_ref() .map(|selection| selection.text.clone()); - let selection_warning = capture.warning_code; { let mut state = inner.qa_state.lock(); if !qa_turn_can_continue(&state, session_id) { @@ -263,14 +261,21 @@ pub(super) async fn submit_qa_text_question( "kind": "thinking", "session_id": session_id, "selection_preview": selection_preview_text, - "selection_warning": selection_warning, "messages": state.messages.clone(), }), ); } } - answer_qa_question_text(inner, question, 0, session_id, None).await + answer_qa_question_text( + inner, + question, + 0, + session_id, + None, + super::CapsuleFeedback::Hide, + ) + .await } pub(super) async fn take_current_dictation_transcript_for_qa( @@ -310,8 +315,15 @@ pub(super) async fn take_current_dictation_transcript_for_qa( state.phase = SessionPhase::Idle; state.focus_target = None; } - answer_qa_question_text(inner, String::new(), duration_ms, qa_session_id, Some(wav)) - .await?; + answer_qa_question_text( + inner, + String::new(), + duration_ms, + qa_session_id, + Some(wav), + super::CapsuleFeedback::Show, + ) + .await?; return Ok(None); } @@ -655,6 +667,10 @@ pub(super) async fn answer_qa_question_text( duration_ms: u64, session_id: SessionId, audio_wav: Option>, + // QA 面板打字提问传 Hide:回答在面板内流式可见,不应在输入法 auxDown + // 闪「✨ 润色中...」(Linux 下 Polishing 会映射到候选词栏)。 + // 语音/听写路径保持 Show(用户熟悉的小录音条反馈)。 + capsule_feedback: super::CapsuleFeedback, ) -> Result<(), String> { { let state = inner.qa_state.lock(); @@ -706,7 +722,9 @@ pub(super) async fn answer_qa_question_text( } } - emit_capsule(inner, CapsuleState::Polishing, 0.0, 0, None, None); + if capsule_feedback == super::CapsuleFeedback::Show { + emit_capsule(inner, CapsuleState::Polishing, 0.0, 0, None, None); + } let prefs = inner.prefs.get(); let working_languages = prefs.working_languages.clone(); @@ -880,7 +898,6 @@ pub(super) async fn begin_qa_session(inner: &Arc) -> Result<(), String> { // 每轮按 Option 都重新抓一次:用户多轮提问中可以重新选别处文字。 let capture = capture_qa_turn_selection(inner); let selection = capture.selection; - let selection_warning = capture.warning_code; let selection_preview_text = selection.as_ref().map(|s| s.text.clone()); { let mut state = inner.qa_state.lock(); @@ -897,7 +914,6 @@ pub(super) async fn begin_qa_session(inner: &Arc) -> Result<(), String> { "kind": "recording", "session_id": session_id, "selection_preview": selection_preview_text, - "selection_warning": selection_warning, "messages": state.messages.clone(), }), ); @@ -1115,8 +1131,15 @@ pub(super) async fn end_qa_session(inner: &Arc) -> Result<(), String> { }; let duration_ms = pcm_consumer.duration_ms(); let wav = pcm_bytes_to_wav(&pcm_consumer.pcm()); - return answer_qa_question_text(inner, String::new(), duration_ms, session_id, Some(wav)) - .await; + return answer_qa_question_text( + inner, + String::new(), + duration_ms, + session_id, + Some(wav), + super::CapsuleFeedback::Show, + ) + .await; } let asr = match take_qa_asr_for_session(inner, session_id) { @@ -1538,7 +1561,15 @@ pub(super) async fn end_qa_session(inner: &Arc) -> Result<(), String> { return Ok(()); } - answer_qa_question_text(inner, question, raw.duration_ms, session_id, None).await + answer_qa_question_text( + inner, + question, + raw.duration_ms, + session_id, + None, + super::CapsuleFeedback::Show, + ) + .await } /// 静默收尾:发 idle 事件给前端,phase 复位。**不关浮窗**(v2:浮窗只在用户 diff --git a/openless-all/app/src-tauri/src/coordinator/selection_polish.rs b/openless-all/app/src-tauri/src/coordinator/selection_polish.rs index fd6a6485f..4b0490333 100644 --- a/openless-all/app/src-tauri/src/coordinator/selection_polish.rs +++ b/openless-all/app/src-tauri/src/coordinator/selection_polish.rs @@ -148,16 +148,13 @@ pub(super) async fn run_selection_polish(inner: &Arc) -> Result<(), Strin let insertion_target = crate::selection::capture_selection_insertion_target(); let capture = crate::selection::capture_selection_with_status(); if selection_polish_plan(capture.selection.as_ref()) == SelectionPolishPlan::NoSelection { - let code = capture - .warning_code - .unwrap_or("selectionPolishNoSelection") - .to_string(); + let code = "selectionPolishNoSelection"; finish_selection_polish_capsule( inner, CapsuleState::Cancelled, - selection_polish_feedback_message(&code), + selection_polish_feedback_message(code), ); - return Err(code); + return Err(code.to_string()); } let selection = capture.selection.expect("selection plan checked above"); if !crate::selection::selection_insertion_target_is_captured(&insertion_target) { diff --git a/openless-all/app/src-tauri/src/linux_fcitx.rs b/openless-all/app/src-tauri/src/linux_fcitx.rs index 08fb1d33c..1a0028d86 100644 --- a/openless-all/app/src-tauri/src/linux_fcitx.rs +++ b/openless-all/app/src-tauri/src/linux_fcitx.rs @@ -4,8 +4,8 @@ //! 封装对 `org.fcitx.Fcitx.OpenLess1` 接口的调用, //! 提供文字提交(替代 enigo XTest)和热键设置功能。 //! -//! 所有函数会静默返回 `None` 如果 fcitx5 / 插件不可用, -//! 调用方应当降级到原有方案(clipboard / enigo)。 +//! 插件不可用时,各调用方按自身能力记录 warning 并继续运行;Linux 选区读取 +//! 只通过插件的 `GetSelectionText()` DBus 方法,失败视为无选区,不回退外部命令。 use std::time::Duration; @@ -295,6 +295,23 @@ pub fn available() -> bool { conn.send_with_reply_and_block(msg, TIMEOUT).is_ok() } +/// 通过 fcitx5 插件获取当前 PRIMARY 选区文本。 +/// +/// 插件直接读取 fcitx5 clipboard addon 维护的 PRIMARY 缓存,不触碰用户剪贴板。 +/// 返回空字符串表示无选区;插件不可用或 DBus 调用失败则返回错误。 +pub fn get_selection_text() -> Result { + let conn = + dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; + let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "GetSelectionText") + .map_err(|e| format!("build msg: {e}"))?; + let reply = conn + .send_with_reply_and_block(msg, TIMEOUT) + .map_err(|e| format!("GetSelectionText: {e}"))?; + reply + .read1::() + .map_err(|e| format!("GetSelectionText reply: {e}")) +} + /// 启动 fcitx5 DictationKeyEvent 信号监听线程。 /// /// 当 fcitx5 OpenLess 插件检测到配置的听写热键被按下或松开时, @@ -479,13 +496,17 @@ pub fn start_dictation_signal_listener( .ok(); } -/// 检查 fcitx5 插件是否已安装到系统路径。 +/// 确保 Linux fcitx5 插件可用。 /// -/// 所有 Linux 格式(deb/rpm/AppImage)的插件安装都在打包时完成 -///(`scripts/inject-fcitx5-plugin.sh`),此处仅确认文件存在。 -/// 未安装时输出警告,不做任何文件 I/O。 +/// AppImage 从 bundled resources 同步到用户级 XDG 路径;deb/rpm 保持系统路径检查, +/// 不写入用户目录。所有同步失败只记录 warning,不阻塞应用启动。 #[cfg(target_os = "linux")] -pub fn ensure_plugin_installed(_app: &tauri::AppHandle) { +pub fn ensure_plugin_installed(app: &tauri::AppHandle) { + if is_appimage_runtime() { + ensure_appimage_plugin_installed(app); + return; + } + // fcitx5 在不同发行版的 lib 路径不同,同时支持用户 XDG 安装 let lib_dirs = [ "/usr/lib/x86_64-linux-gnu/fcitx5", // Debian multiarch @@ -541,6 +562,211 @@ pub fn ensure_plugin_installed(_app: &tauri::AppHandle) { } } +#[cfg(target_os = "linux")] +fn is_appimage_runtime() -> bool { + ["APPDIR", "APPIMAGE"].iter().any(|name| { + std::env::var_os(name) + .map(|value| !value.is_empty()) + .unwrap_or(false) + }) +} + +/// AppImage 里 bundled resources 下的插件子路径(相对 resource_dir)。 +/// +/// ⚠️ 与 `.github/workflows/release-tauri.yml` 的 `bundle.resources` 数组 +/// 和 AppImage 内容校验(`unsquashfs -l` 的 grep)**必须保持一致**—— +/// 改这里要同步改 CI,反之亦然。CI 校验能在打包时兜底抓失配,但那是 +/// 发布后才发现;此处常量让 Rust 侧所有引用(含测试)共享同一来源。 +#[cfg(target_os = "linux")] +const APPIMAGE_PLUGIN_SUBDIR: &str = "linux-fcitx5-plugin"; + +#[cfg(target_os = "linux")] +fn appimage_resource_paths( + resource_dir: &std::path::Path, +) -> (std::path::PathBuf, std::path::PathBuf) { + ( + resource_dir.join(APPIMAGE_PLUGIN_SUBDIR).join("libopenless.so"), + resource_dir.join(APPIMAGE_PLUGIN_SUBDIR).join("openless.conf"), + ) +} + +#[cfg(target_os = "linux")] +fn ensure_appimage_plugin_installed(app: &tauri::AppHandle) { + use tauri::Manager; + + let resource_dir = match app.path().resource_dir() { + Ok(path) => path, + Err(error) => { + log::warn!("[fcitx] AppImage resource directory unavailable: {error}"); + return; + } + }; + let (source_so, source_conf) = appimage_resource_paths(&resource_dir); + let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) else { + log::warn!("[fcitx] HOME is unavailable; keeping any existing user plugin"); + return; + }; + let home = std::path::PathBuf::from(home); + let target_so = home.join(".local/lib/fcitx5/libopenless.so"); + let target_conf = home.join(".local/share/fcitx5/addon/openless.conf"); + + match sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf) { + Ok(false) => return, + Ok(true) => log::info!("[fcitx] Updated AppImage fcitx5 plugin in ~/.local"), + Err(error) => { + log::warn!("[fcitx] AppImage fcitx5 plugin sync failed: {error}"); + return; + } + } + + reload_fcitx5_if_running(); +} + +#[cfg(target_os = "linux")] +fn sync_plugin_pair( + source_so: &std::path::Path, + source_conf: &std::path::Path, + target_so: &std::path::Path, + target_conf: &std::path::Path, +) -> Result { + let so = std::fs::read(source_so) + .map_err(|error| format!("read {}: {error}", source_so.display()))?; + let conf = std::fs::read(source_conf) + .map_err(|error| format!("read {}: {error}", source_conf.display()))?; + if so.is_empty() { + return Err(format!("bundled resource {} is empty", source_so.display())); + } + if conf.is_empty() { + return Err(format!( + "bundled resource {} is empty", + source_conf.display() + )); + } + + let so_changed = !target_matches(target_so, &so)?; + let conf_changed = !target_matches(target_conf, &conf)?; + if !so_changed && !conf_changed { + return Ok(false); + } + + for target in [target_so, target_conf] { + let parent = target + .parent() + .ok_or_else(|| format!("target has no parent: {}", target.display()))?; + std::fs::create_dir_all(parent) + .map_err(|error| format!("create {}: {error}", parent.display()))?; + } + + let so_temp = if so_changed { + Some(stage_atomic_write(&so, target_so)?) + } else { + None + }; + let conf_temp = if conf_changed { + match stage_atomic_write(&conf, target_conf) { + Ok(path) => Some(path), + Err(error) => { + if let Some(path) = so_temp.as_ref() { + let _ = std::fs::remove_file(path); + } + return Err(error); + } + } + } else { + None + }; + + let result = (|| { + if let Some(temp) = so_temp.as_ref() { + commit_atomic_write(temp, target_so)?; + } + if let Some(temp) = conf_temp.as_ref() { + commit_atomic_write(temp, target_conf)?; + } + Ok(true) + })(); + if result.is_err() { + for temp in [so_temp.as_ref(), conf_temp.as_ref()].into_iter().flatten() { + let _ = std::fs::remove_file(temp); + } + } + result +} + +#[cfg(target_os = "linux")] +fn target_matches(target: &std::path::Path, source: &[u8]) -> Result { + let target_bytes = match std::fs::read(target) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(format!("read {}: {error}", target.display())), + }; + Ok(sha256(&target_bytes) == sha256(source)) +} + +#[cfg(target_os = "linux")] +fn sha256(bytes: &[u8]) -> [u8; 32] { + use sha2::{Digest, Sha256}; + Sha256::digest(bytes).into() +} + +#[cfg(target_os = "linux")] +fn stage_atomic_write( + bytes: &[u8], + target: &std::path::Path, +) -> Result { + use std::io::Write; + + let parent = target + .parent() + .ok_or_else(|| format!("target has no parent: {}", target.display()))?; + let name = target + .file_name() + .ok_or_else(|| format!("target has no filename: {}", target.display()))? + .to_string_lossy(); + let temp = parent.join(format!(".{name}.tmp-{}", uuid::Uuid::new_v4())); + let result = (|| { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(|error| format!("create {}: {error}", temp.display()))?; + file.write_all(bytes) + .map_err(|error| format!("write {}: {error}", temp.display()))?; + file.sync_all() + .map_err(|error| format!("sync {}: {error}", temp.display()))?; + Ok(temp.clone()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temp); + } + result +} + +#[cfg(target_os = "linux")] +fn commit_atomic_write(temp: &std::path::Path, target: &std::path::Path) -> Result<(), String> { + std::fs::rename(temp, target) + .map_err(|error| format!("rename {} to {}: {error}", temp.display(), target.display())) +} + +#[cfg(target_os = "linux")] +fn reload_fcitx5_if_running() { + let conn = match dbus::blocking::SyncConnection::new_session() { + Ok(conn) => conn, + Err(error) => { + log::warn!("[fcitx] Cannot check fcitx5 before reload: {error}"); + return; + } + }; + if !fcitx5_name_has_owner(&conn) { + return; + } + match std::process::Command::new("fcitx5").arg("-r").status() { + Ok(status) if status.success() => log::info!("[fcitx] Reloaded fcitx5 after plugin update"), + Ok(status) => log::warn!("[fcitx] fcitx5 -r failed with status {status}"), + Err(error) => log::warn!("[fcitx] Could not run fcitx5 -r: {error}"), + } +} + /// 同步主听写热键:自定义组合键走 SetCustomDictationTrigger,预设修饰键走 SetHotkeyRaw。 fn resync_main_binding(binding: &crate::types::HotkeyBinding, custom_trigger_key: Option<&str>) { if let Some(key_string) = custom_trigger_key { @@ -573,3 +799,155 @@ fn fcitx5_name_has_owner(conn: &dbus::blocking::SyncConnection) -> bool { Err(_) => false, } } + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use std::fs; + use std::path::{Path, PathBuf}; + + struct TestDir(PathBuf); + + impl TestDir { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("openless-fcitx-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&path).expect("create test directory"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn appimage_resources_use_the_expected_subdirectory() { + // 这些字面量是**契约的一部分**(与 CI 的 bundle.resources 数组一致), + // 故意不引用 APPIMAGE_PLUGIN_SUBDIR,避免测试与实现相互印证而漏掉契约漂移。 + let (so, conf) = appimage_resource_paths(Path::new("/opt/openless/resources")); + assert_eq!( + so, + Path::new("/opt/openless/resources/linux-fcitx5-plugin/libopenless.so") + ); + assert_eq!( + conf, + Path::new("/opt/openless/resources/linux-fcitx5-plugin/openless.conf") + ); + } + + #[test] + fn missing_targets_are_created_and_parent_directories_are_made() { + let dir = TestDir::new(); + let source_so = dir.path().join("source/libopenless.so"); + let source_conf = dir.path().join("source/openless.conf"); + let target_so = dir.path().join("home/.local/lib/fcitx5/libopenless.so"); + let target_conf = dir + .path() + .join("home/.local/share/fcitx5/addon/openless.conf"); + fs::create_dir_all(source_so.parent().unwrap()).unwrap(); + fs::write(&source_so, b"so-v1").unwrap(); + fs::write(&source_conf, b"conf-v1").unwrap(); + + assert!(sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf).unwrap()); + assert_eq!(fs::read(&target_so).unwrap(), b"so-v1"); + assert_eq!(fs::read(&target_conf).unwrap(), b"conf-v1"); + } + + #[test] + fn identical_targets_are_not_rewritten() { + let dir = TestDir::new(); + let source_so = dir.path().join("source.so"); + let source_conf = dir.path().join("source.conf"); + let target_so = dir.path().join("target.so"); + let target_conf = dir.path().join("target.conf"); + fs::write(&source_so, b"same-so").unwrap(); + fs::write(&source_conf, b"same-conf").unwrap(); + fs::write(&target_so, b"same-so").unwrap(); + fs::write(&target_conf, b"same-conf").unwrap(); + + assert!(!sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf).unwrap()); + assert_eq!(fs::read(&target_so).unwrap(), b"same-so"); + assert_eq!(fs::read(&target_conf).unwrap(), b"same-conf"); + } + + #[test] + fn changed_targets_are_updated_as_a_pair() { + let dir = TestDir::new(); + let source_so = dir.path().join("source.so"); + let source_conf = dir.path().join("source.conf"); + let target_so = dir.path().join("target.so"); + let target_conf = dir.path().join("target.conf"); + fs::write(&source_so, b"new-so").unwrap(); + fs::write(&source_conf, b"new-conf").unwrap(); + fs::write(&target_so, b"old-so").unwrap(); + fs::write(&target_conf, b"old-conf").unwrap(); + + assert!(sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf).unwrap()); + assert_eq!(fs::read(&target_so).unwrap(), b"new-so"); + assert_eq!(fs::read(&target_conf).unwrap(), b"new-conf"); + } + + #[test] + fn missing_resource_keeps_existing_targets() { + let dir = TestDir::new(); + let source_so = dir.path().join("missing.so"); + let source_conf = dir.path().join("source.conf"); + let target_so = dir.path().join("target.so"); + let target_conf = dir.path().join("target.conf"); + fs::write(&source_conf, b"new-conf").unwrap(); + fs::write(&target_so, b"old-so").unwrap(); + fs::write(&target_conf, b"old-conf").unwrap(); + + assert!(sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf).is_err()); + assert_eq!(fs::read(&target_so).unwrap(), b"old-so"); + assert_eq!(fs::read(&target_conf).unwrap(), b"old-conf"); + } + + #[test] + fn unusable_target_directory_keeps_existing_targets() { + let dir = TestDir::new(); + let source_so = dir.path().join("source.so"); + let source_conf = dir.path().join("source.conf"); + let target_so = dir.path().join("targets/libopenless.so"); + let target_conf = dir.path().join("blocked/openless.conf"); + fs::write(&source_so, b"new-so").unwrap(); + fs::write(&source_conf, b"new-conf").unwrap(); + fs::create_dir_all(target_so.parent().unwrap()).unwrap(); + fs::write(&target_so, b"old-so").unwrap(); + fs::write(dir.path().join("blocked"), b"not a directory").unwrap(); + + assert!(sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf).is_err()); + assert_eq!(fs::read(&target_so).unwrap(), b"old-so"); + assert!(target_conf.is_dir() || !target_conf.exists()); + } + + #[test] + fn atomic_update_leaves_no_temporary_files() { + let dir = TestDir::new(); + let source_so = dir.path().join("source.so"); + let source_conf = dir.path().join("source.conf"); + let target_so = dir.path().join("target.so"); + let target_conf = dir.path().join("target.conf"); + fs::write(&source_so, b"so").unwrap(); + fs::write(&source_conf, b"conf").unwrap(); + + sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf).unwrap(); + let leftovers = fs::read_dir(dir.path()) + .unwrap() + .filter_map(Result::ok) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| name.contains(".tmp-")) + .collect::>(); + assert!( + leftovers.is_empty(), + "temporary files left behind: {leftovers:?}" + ); + } +} 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 3521c1849..c83d6c210 100644 --- a/openless-all/app/src-tauri/src/mobile_stubs/selection.rs +++ b/openless-all/app/src-tauri/src/mobile_stubs/selection.rs @@ -13,13 +13,11 @@ pub struct SelectionContext { pub struct SelectionCaptureOutcome { pub selection: Option, - pub warning_code: Option<&'static str>, } pub fn capture_selection_with_status() -> SelectionCaptureOutcome { SelectionCaptureOutcome { selection: capture_selection(), - warning_code: None, } } diff --git a/openless-all/app/src-tauri/src/selection.rs b/openless-all/app/src-tauri/src/selection.rs index de1173b16..c93c38de0 100644 --- a/openless-all/app/src-tauri/src/selection.rs +++ b/openless-all/app/src-tauri/src/selection.rs @@ -1,22 +1,21 @@ -#![cfg_attr( - target_os = "linux", - allow(dead_code, unused_imports, unused_variables) -)] //! 跨平台「划词捕获」工具:在用户触发 QA 快捷键时尝试拿到当前前台 app 的选区文本。 //! -//! 三级 fallback: +//! 平台路径: //! 1. **macOS** AX:`AXUIElementCopyAttributeValue(focused, kAXSelectedTextAttribute)` //! 走辅助功能 API 直读焦点元素的选区,**不**触碰剪贴板。 //! 2. **macOS / Windows** Cmd+C / Ctrl+C:snapshot 用户原剪贴板 → 模拟复制 → 80ms //! 后读出新内容 → 还原原剪贴板。 -//! 3. **Linux**:返回 `None`(AX 模式不统一,留作 best-effort 后续)。 +//! 3. **Linux**:通过 fcitx5 插件的 `GetSelectionText()` DBus 方法读取 PRIMARY +//! 选区缓存,不触碰用户剪贴板;插件不可用、调用失败或返回空文本均视为无选区。 //! //! 截断策略:超过 4000 字符的选区只保留首 2000 + 尾 2000 + `[…truncated…]` 标记, //! 避免给 LLM 灌过长 context。 //! -//! 模块依赖:仅 `arboard`(跨平台剪贴板)+ libc + 平台 native 框架;不依赖其它 -//! Rust 模块(与 CLAUDE.md 对齐)。 +//! 模块依赖:`arboard`(跨平台剪贴板)+ libc + 平台 native 框架,Linux 另依赖 +//! `linux_fcitx` 的 DBus 客户端。 +// 仅 macOS / Windows 的模拟复制路径用 sleep;Linux 走 fcitx5 DBus 直读,无 sleep。 +#[cfg(any(target_os = "macos", target_os = "windows"))] use std::time::Duration; const SELECTION_MAX_CHARS: usize = 4000; @@ -97,12 +96,8 @@ impl SelectionInsertionTargetValidation { } } -#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] -const LINUX_SELECTION_TOOLS_MISSING_WARNING: &str = "linux_selection_tools_missing"; - pub struct SelectionCaptureOutcome { pub selection: Option, - pub warning_code: Option<&'static str>, } /// Snapshot the insertion target before starting an asynchronous Selection @@ -142,9 +137,7 @@ pub(crate) fn capture_selection_insertion_target() -> SelectionInsertionTarget { /// /// 非 Windows/macOS(Linux / mobile)尚未实现等效的前台校验:Linux 依赖 /// PRIMARY selection 重读做轻量校验,移动端不提供选区润色。 -pub(crate) fn selection_insertion_target_is_captured( - target: &SelectionInsertionTarget, -) -> bool { +pub(crate) fn selection_insertion_target_is_captured(target: &SelectionInsertionTarget) -> bool { #[cfg(target_os = "windows")] { target.windows.is_some() @@ -224,7 +217,7 @@ pub(crate) fn validate_selection_insertion_target( return SelectionInsertionTargetValidation::Valid; } - #[cfg(not(any(target_os = "windows", target_os = "macos")))] + #[cfg(target_os = "linux")] { // Linux:重读 PRIMARY selection 与捕获文本比较——用户改了选区 / 清空 // PRIMARY 就拒绝粘贴(fcitx CommitText 直接写焦点输入上下文,无需 @@ -241,6 +234,11 @@ pub(crate) fn validate_selection_insertion_target( } SelectionInsertionTargetValidation::Valid } + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + SelectionInsertionTargetValidation::TargetUnavailable + } } /// macOS 专用:以与捕获时相同的形式(trim + truncate)重读当前选区,供 @@ -320,8 +318,7 @@ fn activate_app_by_pid(pid: i32) { } } -/// 捕获选区并返回可向用户展示的非阻断平台提醒。 -/// 目前仅 Linux 在 `wl-paste`、`xclip`、`xsel` 均未安装时返回提醒码。 +/// 捕获选区。Linux 只通过 fcitx5 DBus 读取 PRIMARY 选区,失败统一视为无选区。 pub fn capture_selection_with_status() -> SelectionCaptureOutcome { let source_app = current_front_app(); @@ -343,7 +340,6 @@ pub fn capture_selection_with_status() -> SelectionCaptureOutcome { text: truncate_selection(trimmed), source_app, }), - warning_code: None, }; } } @@ -366,13 +362,12 @@ pub fn capture_selection_with_status() -> SelectionCaptureOutcome { text: truncate_selection(trimmed), source_app, }), - warning_code: None, }; } } - // 3. Linux:best-effort 读 PRIMARY selection(wl-paste / xclip / xsel)。 - #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + // 3. Linux:通过 fcitx5 DBus 读取 PRIMARY selection。 + #[cfg(target_os = "linux")] match linux_selection::read_selected_text() { linux_selection::LinuxSelectionRead::Text(text) => { let trimmed = text.trim(); @@ -389,25 +384,12 @@ pub fn capture_selection_with_status() -> SelectionCaptureOutcome { text: truncate_selection(trimmed), source_app, }), - warning_code: None, - }; - } - linux_selection::LinuxSelectionRead::ToolsUnavailable => { - log::warn!( - "[selection] linux primary selection unavailable: install wl-paste, xclip, or xsel" - ); - return SelectionCaptureOutcome { - selection: None, - warning_code: Some(LINUX_SELECTION_TOOLS_MISSING_WARNING), }; } linux_selection::LinuxSelectionRead::NoSelection => {} } - SelectionCaptureOutcome { - selection: None, - warning_code: None, - } + SelectionCaptureOutcome { selection: None } } /// 长度截断到首 + 尾 + 标记。 @@ -492,12 +474,7 @@ fn selected_text_for_validation() -> Option { (!trimmed.is_empty()).then(|| truncate_selection(trimmed)) } -#[cfg(any( - target_os = "windows", - target_os = "macos", - target_os = "linux", - test -))] +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux", test))] fn selection_text_matches(expected: &str, actual: Option<&str>) -> bool { actual.is_some_and(|actual| actual == expected) } @@ -577,108 +554,58 @@ fn post_copy_shortcut() -> bool { windows_paste::send_ctrl_c().is_ok() } -#[cfg(any(all(not(target_os = "macos"), not(target_os = "windows")), test))] +#[cfg(target_os = "linux")] mod linux_selection { - use std::io::ErrorKind; - use std::process::Command; - - const PRIMARY_SELECTION_COMMANDS: &[(&str, &[&str])] = &[ - ("wl-paste", &["--primary", "--no-newline"]), - ("xclip", &["-o", "-selection", "primary"]), - ("xsel", &["--primary", "--output"]), - ]; - #[derive(Debug, PartialEq, Eq)] pub enum LinuxSelectionRead { Text(String), NoSelection, - ToolsUnavailable, - } - - #[derive(Debug, PartialEq, Eq)] - enum ReaderAttempt { - Text(String), - AvailableWithoutText, - Unavailable, } pub fn read_selected_text() -> LinuxSelectionRead { - read_selected_text_with(run_capture) + classify_selection_result(crate::linux_fcitx::get_selection_text()) } - fn read_selected_text_with(mut run: F) -> LinuxSelectionRead - where - F: FnMut(&str, &[&str]) -> ReaderAttempt, - { - let mut has_available_reader = false; - for (bin, args) in PRIMARY_SELECTION_COMMANDS { - match run(bin, args) { - ReaderAttempt::Text(text) => return LinuxSelectionRead::Text(text), - ReaderAttempt::AvailableWithoutText => has_available_reader = true, - ReaderAttempt::Unavailable => {} + fn classify_selection_result(result: Result) -> LinuxSelectionRead { + match result { + Ok(text) => { + let trimmed = text.trim(); + if trimmed.is_empty() { + LinuxSelectionRead::NoSelection + } else { + LinuxSelectionRead::Text(trimmed.to_string()) + } + } + Err(error) => { + log::debug!("[selection] fcitx5 GetSelectionText unavailable: {error}"); + LinuxSelectionRead::NoSelection } } - if has_available_reader { - LinuxSelectionRead::NoSelection - } else { - LinuxSelectionRead::ToolsUnavailable - } - } - - fn run_capture(bin: &str, args: &[&str]) -> ReaderAttempt { - let output = match Command::new(bin).args(args).output() { - Ok(output) => output, - Err(error) if error.kind() == ErrorKind::NotFound => return ReaderAttempt::Unavailable, - Err(_) => return ReaderAttempt::AvailableWithoutText, - }; - if !output.status.success() { - return ReaderAttempt::AvailableWithoutText; - } - let Ok(text) = String::from_utf8(output.stdout) else { - return ReaderAttempt::AvailableWithoutText; - }; - let trimmed = text.trim(); - if trimmed.is_empty() { - return ReaderAttempt::AvailableWithoutText; - } - ReaderAttempt::Text(trimmed.to_string()) } - #[cfg(test)] mod tests { use super::*; #[test] - fn reports_tools_unavailable_only_when_all_three_are_missing() { - let result = read_selected_text_with(|_, _| ReaderAttempt::Unavailable); - assert_eq!(result, LinuxSelectionRead::ToolsUnavailable); - - let mut attempts = 0; - let result = read_selected_text_with(|_, _| { - attempts += 1; - if attempts == 2 { - ReaderAttempt::AvailableWithoutText - } else { - ReaderAttempt::Unavailable - } - }); - assert_eq!(result, LinuxSelectionRead::NoSelection); - } - - #[test] - fn returns_text_from_first_reader_that_has_a_selection() { - let result = read_selected_text_with(|bin, _| { - if bin == "xclip" { - ReaderAttempt::Text("selected text".to_string()) - } else { - ReaderAttempt::Unavailable - } - }); + fn maps_dbus_text_to_selection() { + let result = classify_selection_result(Ok(" selected text ".to_string())); assert_eq!( result, LinuxSelectionRead::Text("selected text".to_string()) ); } + + #[test] + fn maps_empty_dbus_text_to_no_selection() { + let result = classify_selection_result(Ok(" \n".to_string())); + assert_eq!(result, LinuxSelectionRead::NoSelection); + } + + #[test] + fn maps_dbus_error_to_no_selection() { + let result = classify_selection_result(Err("DBus unavailable".to_string())); + assert_eq!(result, LinuxSelectionRead::NoSelection); + } } } @@ -1150,10 +1077,7 @@ mod tests { let mut another_control = captured; another_control.focused_window += 100; - assert!(!windows_selection_targets_match( - captured, - another_control - )); + assert!(!windows_selection_targets_match(captured, another_control)); } #[cfg(target_os = "windows")] diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index f1b163be7..654d789df 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -66,7 +66,6 @@ export const en: typeof zhCN = { micLabel: 'Ask by voice', micStop: 'Stop recording', selectionPreview: 'From selected text:', - linuxSelectionToolsMissing: 'Linux selection capture needs wl-paste, xclip, or xsel. Install one and try again.', emptyTitle: 'How can I help?', emptyDesc: 'Select any text to ask about it, or just type your question below. Answers appear here — ask as many follow-ups as you like.', recordingHint: 'Recording… press {{recordHotkey}} again to submit', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 8b0d3e10a..af6a38f8d 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -68,7 +68,6 @@ export const ja: typeof zhCN = { micLabel: '音声で質問', micStop: '録音を終了', selectionPreview: '選択テキスト:', - linuxSelectionToolsMissing: 'Linux の選択範囲を読み取れません。wl-paste、xclip、xsel のいずれかをインストールしてください。', emptyTitle: 'ご用件は?', emptyDesc: 'テキストを選択して質問するか、下に直接入力してください。回答はここに表示され、続けて質問できます。', recordingHint: '録音中… {{recordHotkey}} をもう一度押して終了し、質問します', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 423c9d9d9..72a5774a8 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -68,7 +68,6 @@ export const ko: typeof zhCN = { micLabel: '음성으로 질문', micStop: '녹음 종료', selectionPreview: '선택된 텍스트 기반:', - linuxSelectionToolsMissing: 'Linux 선택 영역을 읽으려면 wl-paste, xclip 또는 xsel 중 하나를 설치하세요.', emptyTitle: '무엇을 도와드릴까요?', emptyDesc: '텍스트를 선택해 질문하거나 아래에 직접 입력하세요. 답변이 여기에 표시되며 계속 이어서 질문할 수 있습니다.', recordingHint: '녹음 중… {{recordHotkey}} 를 다시 눌러 종료하고 질문', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 565e0d842..429d017b5 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -64,7 +64,6 @@ export const zhCN = { micLabel: '语音提问', micStop: '结束录音', selectionPreview: '基于选中文本:', - linuxSelectionToolsMissing: '无法读取 Linux 选区。请安装 wl-paste、xclip 或 xsel 后重试。', emptyTitle: '有什么可以帮你?', emptyDesc: '选中任意文字后开始追问,或直接在下方输入问题。回答会显示在这里,可以连续多轮。', recordingHint: '录音中…再按一次 {{recordHotkey}} 结束并提问', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 37da2d7d4..560d5a082 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -66,7 +66,6 @@ export const zhTW: typeof zhCN = { micLabel: '語音提問', micStop: '結束錄音', selectionPreview: '基於選中文本:', - linuxSelectionToolsMissing: '無法讀取 Linux 選區。請安裝 wl-paste、xclip 或 xsel 後重試。', emptyTitle: '有什麼可以幫你?', emptyDesc: '選中任意文字後開始追問,或直接在下方輸入問題。回答會顯示在這裏,可以連續多輪。', recordingHint: '錄音中…再按一次 {{recordHotkey}} 結束並提問', diff --git a/openless-all/app/src/lib/qaMessage.test.ts b/openless-all/app/src/lib/qaMessage.test.ts index 35cae8279..1f5141cc6 100644 --- a/openless-all/app/src/lib/qaMessage.test.ts +++ b/openless-all/app/src/lib/qaMessage.test.ts @@ -1,5 +1,5 @@ -import { acceptQaSessionEvent, nextQaSelectionWarning, splitQaUserMessage } from './qaMessage'; -import type { QaChatMessage, QaStatePayload } from './types'; +import { acceptQaSessionEvent, splitQaUserMessage } from './qaMessage'; +import type { QaChatMessage } from './types'; function assertEqual(actual: T, expected: T, message: string) { if (actual !== expected) { @@ -38,28 +38,6 @@ const legacy = splitQaUserMessage( assertEqual(legacy.selection, '旧选区', 'legacy messages still expose their selection'); assertEqual(legacy.question, '旧问题', 'legacy messages still expose their question'); -const warning: QaStatePayload['selection_warning'] = 'linux_selection_tools_missing'; -assertEqual( - nextQaSelectionWarning('', { kind: 'recording', selection_warning: warning }), - warning, - 'recording surfaces a Linux dependency warning', -); -assertEqual( - nextQaSelectionWarning(warning, { kind: 'idle' }), - '', - 'an idle reset clears a stale warning even when the field is omitted', -); -assertEqual( - nextQaSelectionWarning(warning, { kind: 'idle', selection_warning: null }), - '', - 'an explicit idle reset clears a stale warning', -); -assertEqual( - nextQaSelectionWarning(warning, { kind: 'thinking' }), - warning, - 'a transitional event without a warning preserves the current warning', -); - assertEqual( acceptQaSessionEvent('new-session', { kind: 'answer_delta', session_id: 'old-session' }).accepted, false, @@ -71,13 +49,9 @@ assertEqual( 'a recording event activates the next turn token', ); assertEqual( - acceptQaSessionEvent('old-session', { - kind: 'idle', - session_id: 'new-session', - selection_warning: null, - }).sessionId, + acceptQaSessionEvent('old-session', { kind: 'idle', session_id: 'new-session' }).sessionId, 'new-session', - 'an explicit panel-open reset activates the reopened panel token', + 'a panel-open idle activates the reopened panel token', ); console.log('qaMessage.test.ts passed'); diff --git a/openless-all/app/src/lib/qaMessage.ts b/openless-all/app/src/lib/qaMessage.ts index b0edf2c33..e58efa756 100644 --- a/openless-all/app/src/lib/qaMessage.ts +++ b/openless-all/app/src/lib/qaMessage.ts @@ -26,30 +26,19 @@ function splitQaUserContent(content: string): { selection: string; question: str return { selection: '', question: content }; } -export function nextQaSelectionWarning( - current: string, - payload: Pick, -): string { - if (payload.kind === 'idle' || payload.kind === 'recording') { - return payload.selection_warning ?? ''; - } - if (payload.kind === 'loading' || payload.kind === 'thinking') { - return payload.selection_warning === undefined ? current : (payload.selection_warning ?? ''); - } - return current; -} - export function acceptQaSessionEvent( currentSessionId: string | null, - payload: Pick, + payload: Pick, ): { accepted: boolean; sessionId: string | null } { if (!payload.session_id) { return { accepted: true, sessionId: currentSessionId }; } + // idle 一律视为新会话 token:open_qa_panel 的 idle 总是携带新生成的 session_id, + // 且事件按发送顺序到达,complete/turn 收尾的 idle 一定先于下一次 open。 const startsTurn = payload.kind === 'recording' || payload.kind === 'loading' || payload.kind === 'thinking' - || (payload.kind === 'idle' && payload.selection_warning !== undefined); + || payload.kind === 'idle'; if (currentSessionId && !startsTurn && currentSessionId !== payload.session_id) { return { accepted: false, sessionId: currentSessionId }; } diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 33c1b85dd..63f0377b5 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -578,8 +578,6 @@ export interface QaStatePayload { messages?: QaChatMessage[]; /** recording 状态时附带的选区预览(前 60 字)。 */ selection_preview?: string | null; - /** Linux 选区工具缺失时的非阻断提醒码。 */ - selection_warning?: 'linux_selection_tools_missing' | null; /** error 状态时附带的提示。 */ error?: string; /** answer_delta 事件时附带的本帧增量字符串。 */ diff --git a/openless-all/app/src/pages/QaPanel.tsx b/openless-all/app/src/pages/QaPanel.tsx index ca131e67c..9f52214c1 100644 --- a/openless-all/app/src/pages/QaPanel.tsx +++ b/openless-all/app/src/pages/QaPanel.tsx @@ -77,7 +77,7 @@ import { qaToggleRecording, qaWindowDismiss, } from '../lib/ipc'; -import { acceptQaSessionEvent, nextQaSelectionWarning, splitQaUserMessage } from '../lib/qaMessage'; +import { acceptQaSessionEvent, splitQaUserMessage } from '../lib/qaMessage'; import type { QaChatMessage, QaStatePayload } from '../lib/types'; import '../components/chat/chat.css'; @@ -126,7 +126,6 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {}) const [status, setStatus] = useState('idle'); const [errorMsg, setErrorMsg] = useState(''); const [selectionPreview, setSelectionPreview] = useState(''); - const [selectionWarning, setSelectionWarning] = useState(''); const [composerText, setComposerText] = useState(''); /** 流式 LLM 答案:answer_delta 累积、answer 事件来时清空(最终内容已落到 messages)。 */ const [streamingAnswer, setStreamingAnswer] = useState(''); @@ -162,7 +161,6 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {}) if (payload.messages) { setMessages(payload.messages); } - setSelectionWarning(current => nextQaSelectionWarning(current, payload)); switch (payload.kind) { case 'idle': setStatus('idle'); @@ -216,7 +214,6 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {}) const dismissHandle = await listen('qa:dismiss', () => { activeSessionIdRef.current = null; setSelectionPreview(''); - setSelectionWarning(''); setComposerText(''); if (embeddedRef.current) { onRequestCloseRef.current?.(); @@ -250,7 +247,6 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {}) setErrorMsg(''); setStreamingAnswer(''); setSelectionPreview(''); - setSelectionWarning(''); setComposerText(''); }, [closing]); @@ -405,16 +401,6 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {}) {status === 'recording' && selectionPreview && ( )} - {selectionWarning === 'linux_selection_tools_missing' && ( -
- {t('qa.linuxSelectionToolsMissing')} -
- )} # # Supports: .deb, .rpm -# AppImage is NOT supported — fcitx5 runs on the host and cannot load -# addons from inside the AppImage mount. +# AppImage resources are bundled separately and installed to ~/.local/ at runtime. set -euo pipefail PKG="$1" diff --git a/openless-all/scripts/linux-fcitx5-plugin/CMakeLists.txt b/openless-all/scripts/linux-fcitx5-plugin/CMakeLists.txt index ac219c3e8..745f537c8 100644 --- a/openless-all/scripts/linux-fcitx5-plugin/CMakeLists.txt +++ b/openless-all/scripts/linux-fcitx5-plugin/CMakeLists.txt @@ -6,7 +6,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Fcitx5Core REQUIRED) find_package(Fcitx5Utils REQUIRED) -find_package(Fcitx5Module REQUIRED COMPONENTS DBus) +find_package(Fcitx5Module REQUIRED COMPONENTS DBus Clipboard) # FCITX_INSTALL_*DIR comes from Fcitx5Utils message(STATUS "FCITX_INSTALL_LIBDIR: ${FCITX_INSTALL_LIBDIR}") @@ -16,7 +16,8 @@ message(STATUS "FCITX_INSTALL_ADDONDIR: ${FCITX_INSTALL_ADDONDIR}") add_library(openless MODULE openless.cpp) target_link_libraries(openless PRIVATE Fcitx5::Core - Fcitx5::Utils) + Fcitx5::Utils + Fcitx5::Module::Clipboard) # Locate fcitx5 module headers (e.g. fcitx-module/dbus/dbus_public.h) find_path(FCITX5_MODULE_INCLUDE_DIR diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp index d97c1361d..27a436f37 100644 --- a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp +++ b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp @@ -18,6 +18,7 @@ * SetTranslationHotkeyRaw(uu: sym, states) — 直接设翻译模式触发 sym+states * SetAuxDown(s: text) — 在候选词列表下方显示状态文本 * ClearAuxDown() — 清除候选词列表下方文本 + * GetSelectionText() -> s — 读取当前 PRIMARY 选区文本(由 clipboard addon 维护) * 信号: * DictationKeyEvent(uub: sym, states, isPress) — 听写热键按下/抬起 * QaShortcutEvent(uub: sym, states, isPress) — QA 快捷键按下/抬起 @@ -46,6 +47,7 @@ #include #include #include +#include #include FCITX_DEFINE_LOG_CATEGORY(openless, "openless"); @@ -454,6 +456,22 @@ class OpenLess final : public AddonInstance, << "SetTranslationHotkeyRaw: sym=" << sym << " states=" << states; } + /// 读取当前 PRIMARY 选区文本。空字符串表示无选区或 clipboard addon 不可用。 + std::string getSelectionText() { + auto *clipboard = instance_->addonManager().addon("clipboard"); + if (!clipboard) { + FCITX_LOGC(openless, Debug) + << "GetSelectionText: clipboard addon not loaded"; + return std::string(); + } + // primary() 签名接收 const InputContext*,clipboard 模块实现中未使用该参数 + // (读的是全局 primary_ 缓存),这里传 nullptr 即可。 + std::string text = clipboard->call(nullptr); + FCITX_LOGC(openless, Debug) + << "GetSelectionText: " << text.size() << " chars"; + return text; + } + FCITX_OBJECT_VTABLE_METHOD(commitText, "CommitText", "s", ""); FCITX_OBJECT_VTABLE_METHOD(setAuxDown, "SetAuxDown", "s", ""); FCITX_OBJECT_VTABLE_METHOD(clearAuxDown, "ClearAuxDown", "", ""); @@ -463,6 +481,7 @@ class OpenLess final : public AddonInstance, FCITX_OBJECT_VTABLE_METHOD(setQaHotkeyRaw, "SetQaHotkeyRaw", "uu", ""); FCITX_OBJECT_VTABLE_METHOD(setSelectionPolishHotkeyRaw, "SetSelectionPolishHotkeyRaw", "uu", ""); FCITX_OBJECT_VTABLE_METHOD(setTranslationHotkeyRaw, "SetTranslationHotkeyRaw", "uu", ""); + FCITX_OBJECT_VTABLE_METHOD(getSelectionText, "GetSelectionText", "", "s"); FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyEvent, "DictationKeyEvent", "uub"); FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyCombined, "DictationKeyCombined", "uub"); FCITX_OBJECT_VTABLE_SIGNAL(qaShortcutEvent, "QaShortcutEvent", "uub");