From e5740298fa1474149378e6a746f21986715d7958 Mon Sep 17 00:00:00 2001 From: baiqing Date: Fri, 1 May 2026 13:13:36 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=88=92?= =?UTF-8?q?=E8=AF=8D=E8=AF=AD=E9=9F=B3=E9=97=AE=E7=AD=94=EF=BC=88QA?= =?UTF-8?q?=EF=BC=89=E5=90=8E=E7=AB=AF=20Rust=20=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue #118 后端实现:用户按 Cmd+Shift+;(macOS)/ Ctrl+Shift+;(Windows) 触发,捕获前台 app 当前选区文本,同时进入语音录音;再次按下停止后将 ASR 转写 + 选区文本送给 LLM,把 Markdown 答案显示在 QA 浮窗。 主要改动: - types.rs:新增 QaHotkeyBinding 类型 + UserPreferences.qa_hotkey / qa_save_history 字段,serde(default) 兼容老 preferences.json。 - qa_hotkey.rs:新模块,用 global-hotkey crate 注册组合键并通过 mpsc 发 QaHotkeyEvent::Pressed 边沿事件,drop 时反注册。 - selection.rs:新模块,三级 fallback 抓选区 (macOS AX kAXSelectedText → Cmd+C / Ctrl+C 模拟复制 → Linux 返回 None), 超 4000 字符截断为首尾各 2000 + […truncated…],模拟复制后还原原剪贴板。 - polish.rs:新增 OpenAICompatibleLLMProvider::answer_with_selection 方法 + qa_system_prompt / qa_user_prompt,复用现有 chat_completion + context_premise。 - coordinator.rs:新增 QaSessionState(独立 phase 枚举,不抢 dictation 状态)+ qa_hotkey supervisor / bridge 线程 + begin_qa_session / end_qa_session / cancel_qa_session 全流程;静默录音不调 LLM,prefs.qa_save_history=false 时 不写 history.json。 - commands.rs / lib.rs:暴露 get_qa_hotkey_label / set_qa_hotkey / qa_window_dismiss / qa_window_pin IPC,新增 show_qa_window / hide_qa_window helper(label="qa",380×280,紧贴胶囊上方 8pt)。 CLAUDE.md 红线: - macOS hotkey 走 global-hotkey crate(内部 Carbon RegisterEventHotKey), 不引入 rdev,不破坏现有 CGEventTap dictation 路径。 - show_qa_window 不调 NSApp.activate,避免抢前台 app 焦点导致 Cmd+C 选区 捕获失败。 - bundle id / dictionary.json 不动;新模块依赖只通过 types.rs。 - ASR / LLM 失败保持「用户的话不丢」语义,前端浮窗收 error 状态自行处理。 cargo check + 现有 coordinator 测试 + 新增 6 个 qa_hotkey/selection 单测 全部通过;pre-existing 15 warnings 不涉及(仅多 1 个 source_app 暂未读 取的 dead-code 警告,是给前端 agent 预留的字段)。 --- openless-all/app/src-tauri/src/commands.rs | 38 +- openless-all/app/src-tauri/src/coordinator.rs | 549 ++++++++++++++++++ openless-all/app/src-tauri/src/lib.rs | 89 ++- openless-all/app/src-tauri/src/polish.rs | 49 ++ openless-all/app/src-tauri/src/qa_hotkey.rs | 333 +++++++++++ openless-all/app/src-tauri/src/selection.rs | 540 +++++++++++++++++ openless-all/app/src-tauri/src/types.rs | 100 ++++ 7 files changed, 1696 insertions(+), 2 deletions(-) create mode 100644 openless-all/app/src-tauri/src/qa_hotkey.rs create mode 100644 openless-all/app/src-tauri/src/selection.rs diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index ef74c7a7..8cc3df62 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -9,7 +9,7 @@ use crate::permissions::{self, PermissionStatus}; use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVault}; use crate::types::{ CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus, - PolishMode, UserPreferences, + PolishMode, QaHotkeyBinding, UserPreferences, }; type CoordinatorState<'a> = State<'a, Arc>; @@ -321,6 +321,42 @@ pub fn trigger_microphone_prompt(app: AppHandle) -> Result<(), String> { } } +// ─────────────────────────── QA (划词语音问答, issue #118) ─────────────────────────── + +/// 给前端 Settings 页渲染当前 QA 快捷键 label(如 `"Cmd+Shift+;"`)。 +/// 未启用时返回空串。 +#[tauri::command] +pub fn get_qa_hotkey_label(coord: CoordinatorState<'_>) -> String { + coord.qa_hotkey_label() +} + +/// 设置 QA 快捷键并热更新 monitor。 +/// 传入 `None` 形式的字段不在这里支持——前端用 `binding == null` 时调下面的 +/// "disable" 写法(写 prefs.qa_hotkey = None)即可。 +#[tauri::command] +pub fn set_qa_hotkey( + coord: CoordinatorState<'_>, + binding: QaHotkeyBinding, +) -> Result<(), String> { + let mut prefs = coord.prefs().get(); + prefs.qa_hotkey = Some(binding); + coord.prefs().set(prefs).map_err(|e| e.to_string())?; + coord.update_qa_hotkey_binding(); + Ok(()) +} + +/// 用户点 ✕ / 按 Esc 关 QA 浮窗。 +#[tauri::command] +pub fn qa_window_dismiss(coord: CoordinatorState<'_>) { + coord.qa_window_dismiss(); +} + +/// 用户点 📌 / 取消 📌。pinned=true 时浮窗不会自动隐藏。 +#[tauri::command] +pub fn qa_window_pin(coord: CoordinatorState<'_>, pinned: bool) { + coord.qa_window_pin(pinned); +} + // ─────────────────────────── unused but exported (silences dead_code) ─────────────────────────── #[allow(dead_code)] diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 3e1be122..f73862c8 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -26,7 +26,9 @@ use crate::persistence::{ }; use crate::polish::{OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; +use crate::qa_hotkey::{QaHotkeyEvent, QaHotkeyMonitor}; use crate::recorder::{Recorder, RecorderError}; +use crate::selection::{capture_selection, SelectionContext}; use crate::types::{ CapsulePayload, CapsuleState, DictationSession, HotkeyCapability, HotkeyMode, HotkeyStatus, HotkeyStatusState, InsertStatus, PolishMode, @@ -104,6 +106,46 @@ struct Inner { /// end_session 在调 polish/translate 前读这个 flag + translation_target_language /// 决定走哪条管线。详见 issue #4。 translation_modifier_seen: AtomicBool, + /// 划词语音问答(issue #118):与 dictation hotkey 平行的全局快捷键 + /// 监听器(global-hotkey crate)。`None` 表示功能关闭或还没成功安装。 + qa_hotkey: Mutex>, + /// QA 单独的 session 状态,与 dictation 的 SessionPhase 不冲突。 + qa_state: Mutex, + /// QA 用的 ASR 句柄(始终是 Volcengine 流式)。 + qa_asr: Mutex>>, + /// QA 用的 Recorder 句柄。 + qa_recorder: Mutex>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum QaPhase { + Idle, + Recording, + Processing, +} + +struct QaSessionState { + phase: QaPhase, + cancelled: bool, + selection: Option, + front_app: Option, + /// 用于忽略迟到的 RMS / runtime error。 + session_id: u64, + /// QA 浮窗是否被用户钉住(pinned)。pinned=true 时不自动隐藏。 + pinned: bool, +} + +impl Default for QaSessionState { + fn default() -> Self { + Self { + phase: QaPhase::Idle, + cancelled: false, + selection: None, + front_app: None, + session_id: 0, + pinned: false, + } + } } impl Coordinator { @@ -129,6 +171,10 @@ impl Coordinator { hotkey_status: Mutex::new(HotkeyStatus::default()), hotkey_trigger_held: AtomicBool::new(false), translation_modifier_seen: AtomicBool::new(false), + qa_hotkey: Mutex::new(None), + qa_state: Mutex::new(QaSessionState::default()), + qa_asr: Mutex::new(None), + qa_recorder: Mutex::new(None), }), } } @@ -151,6 +197,66 @@ impl Coordinator { self.inner.hotkey.lock().take(); } + /// 启动 QA hotkey supervisor(issue #118)。和 `start_hotkey_listener` 平行: + /// 守护线程反复尝试注册(用户可能改了组合键),失败则 3s 后重试。 + pub fn start_qa_hotkey_listener(&self) { + let inner = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-qa-hotkey-supervisor".into()) + .spawn(move || qa_hotkey_supervisor_loop(inner)) + .ok(); + } + + pub fn stop_qa_hotkey_listener(&self) { + self.inner.qa_hotkey.lock().take(); + } + + /// 用户在设置里改了 QA 组合键时调用。先持久化(由 prefs.set 完成), + /// 然后通知活着的 monitor 重新注册;monitor 不存在时 supervisor 会自然 + /// 在下一次循环里读到新的 prefs。 + pub fn update_qa_hotkey_binding(&self) { + let prefs = self.inner.prefs.get(); + let Some(binding) = prefs.qa_hotkey.clone() else { + // 用户把功能关了 → 直接 drop monitor + self.inner.qa_hotkey.lock().take(); + log::info!("[coord] QA hotkey 已关闭"); + return; + }; + if let Some(monitor) = self.inner.qa_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(binding) { + log::warn!("[coord] update QA hotkey binding 失败: {e}"); + } + } + } + + /// 给前端 Settings 渲染当前 QA 快捷键 label(如 "Cmd+Shift+;")。 + /// `qa_hotkey == None` 时返回空串,UI 据此显示「未启用」。 + pub fn qa_hotkey_label(&self) -> String { + self.inner + .prefs + .get() + .qa_hotkey + .as_ref() + .map(|b| b.display_label()) + .unwrap_or_default() + } + + /// 用户点 ✕ / 按 Esc 关 QA 浮窗时调。会: + /// - 把 QA 浮窗 hide(保留前端状态) + /// - 取消 QA session(recorder/asr drop) + pub fn qa_window_dismiss(&self) { + cancel_qa_session(&self.inner); + if let Some(app) = self.inner.app.lock().clone() { + crate::hide_qa_window(&app); + } + } + + /// 用户点 📌 切换 pinned 状态。pinned=true 时浮窗不自动隐藏。 + pub fn qa_window_pin(&self, pinned: bool) { + self.inner.qa_state.lock().pinned = pinned; + log::info!("[coord] QA window pinned={pinned}"); + } + pub fn history(&self) -> &HistoryStore { &self.inner.history } @@ -287,6 +393,81 @@ fn hotkey_supervisor_loop(inner: Arc) { } } +// ─────────────────────────── QA hotkey supervisor ─────────────────────────── + +fn qa_hotkey_supervisor_loop(inner: Arc) { + let mut attempts: u32 = 0; + loop { + // 用户已经把 QA 关掉就睡着等 prefs 改动;改动通过 update_qa_hotkey_binding 唤醒。 + let binding = match inner.prefs.get().qa_hotkey.clone() { + Some(b) => b, + None => { + inner.qa_hotkey.lock().take(); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + }; + + if inner.qa_hotkey.lock().is_some() { + // 已注册成功 → 不重复装;睡 5s 复查( binding 变化由 update 路径手动触发 )。 + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + let (tx, rx) = mpsc::channel::(); + match QaHotkeyMonitor::start(binding, tx) { + Ok(monitor) => { + *inner.qa_hotkey.lock() = Some(monitor); + log::info!( + "[coord] QA hotkey listener installed (after {} attempt(s))", + attempts + 1 + ); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-qa-hotkey-bridge".into()) + .spawn(move || qa_hotkey_bridge_loop(inner_clone, rx)) + .ok(); + attempts = 0; + } + Err(e) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] QA hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +fn qa_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { + while let Ok(evt) = rx.recv() { + let inner_cloned = Arc::clone(&inner); + match evt { + QaHotkeyEvent::Pressed => { + async_runtime::spawn(async move { handle_qa_hotkey_pressed(&inner_cloned).await }); + } + } + } +} + +async fn handle_qa_hotkey_pressed(inner: &Arc) { + let phase = inner.qa_state.lock().phase; + log::info!("[coord] QA hotkey edge (phase={phase:?})"); + match phase { + QaPhase::Idle => { + let _ = begin_qa_session(inner).await; + } + QaPhase::Recording => { + let _ = end_qa_session(inner).await; + } + // Processing 阶段再次按键忽略(避免与正在跑的 LLM 冲突)。 + QaPhase::Processing => {} + } +} + fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { while let Ok(evt) = rx.recv() { let inner_cloned = Arc::clone(&inner); @@ -1270,6 +1451,374 @@ fn enabled_hotwords(inner: &Arc) -> Vec { .collect() } +// ─────────────────────────── QA session lifecycle ─────────────────────────── + +/// 划词语音问答会话(issue #118)。 +/// +/// 与 dictation 完全分离: +/// - 不进 SessionPhase(互不抢锁) +/// - 不写 history.json(除非 prefs.qa_save_history=true 才旁路写一条 placeholder) +/// - 用独立的 qa_recorder + qa_asr,复用现有 Volcengine ASR 通路 +async fn begin_qa_session(inner: &Arc) -> Result<(), String> { + { + let mut state = inner.qa_state.lock(); + if state.phase != QaPhase::Idle { + return Ok(()); + } + state.phase = QaPhase::Recording; + state.cancelled = false; + state.session_id = state.session_id.wrapping_add(1); + state.front_app = capture_frontmost_app(); + state.selection = None; + } + + // 1. 显示 QA 浮窗(loading 状态)+ 同步抓选区。 + // 选区抓取在 show_qa_window 之前做,避免浮窗抢前台 app 焦点导致选区丢失。 + let selection = capture_selection(); + let selection_preview_text = selection.as_ref().map(|s| s.text.clone()); + inner.qa_state.lock().selection = selection.clone(); + + if let Some(app) = inner.app.lock().clone() { + crate::show_qa_window(&app, "loading"); + let _ = app.emit_to( + "qa", + "qa:state", + serde_json::json!({ + "kind": "loading", + "selectionPreview": selection_preview_text.clone(), + }), + ); + } + + // 2. 凭据缺失走静默 fallback:与 dictation 一致的"用户的话不丢"约定。 + // 缺火山凭据 → 后续 Recorder 仍会跑,只是 ASR 拿不到结果,end_qa_session + // 会发 idle 事件关浮窗。 + if let Err(message) = ensure_asr_credentials() { + log::warn!("[coord] QA: ASR credentials missing: {message}"); + finish_qa_with_error(inner, format!("缺少 ASR 凭据:{message}")); + return Err(message); + } + + if let Err(message) = ensure_microphone_permission(inner) { + log::warn!("[coord] QA: microphone permission gate failed: {message}"); + finish_qa_with_error(inner, message.clone()); + return Err(message); + } + + // 3. 启动 Recorder + ASR(强制走 Volcengine 流式:QA 必须低延迟)。 + let hotwords = enabled_hotwords(inner); + let creds = read_volc_credentials(); + let asr = Arc::new(VolcengineStreamingASR::new(creds, hotwords)); + let bridge = Arc::new(DeferredAsrBridge::new()); + let consumer: Arc = bridge.clone(); + *inner.qa_asr.lock() = Some(Arc::clone(&asr)); + + // QA recorder 不需要 RMS 节流到胶囊;前端 QA 浮窗有自己的电平视图, + // 这里发一份事件给 "qa" label 用就够了。 + let inner_for_level = Arc::clone(inner); + let last_emit_at = Arc::new(Mutex::new(None::)); + const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33; + let level_handler: Arc = Arc::new(move |level| { + let phase = inner_for_level.qa_state.lock().phase; + if phase != QaPhase::Recording { + return; + } + let now = Instant::now(); + { + let mut last = last_emit_at.lock(); + if let Some(prev) = *last { + if now.duration_since(prev).as_millis() < LEVEL_EMIT_MIN_INTERVAL_MS as u128 { + return; + } + } + *last = Some(now); + } + if let Some(app) = inner_for_level.app.lock().clone() { + let _ = app.emit_to( + "qa", + "qa:level", + serde_json::json!({ "level": level }), + ); + } + }); + + match Recorder::start(consumer, level_handler) { + Ok((rec, _runtime_errors)) => { + *inner.qa_recorder.lock() = Some(rec); + } + Err(e) => { + log::error!("[coord] QA recorder start failed: {e}"); + inner.qa_asr.lock().take(); + finish_qa_with_error(inner, format!("录音启动失败: {e}")); + return Err(e.to_string()); + } + } + + if let Err(e) = asr.open_session().await { + log::error!("[coord] QA: open ASR session failed: {e}"); + if let Some(rec) = inner.qa_recorder.lock().take() { + rec.stop(); + } + if let Some(asr) = inner.qa_asr.lock().take() { + asr.cancel(); + } + finish_qa_with_error(inner, format!("ASR 连接失败: {e}")); + return Err(e.to_string()); + } + + // cancel race:在 await 期间用户可能 dismiss 了浮窗。 + if inner.qa_state.lock().cancelled { + log::info!("[coord] QA cancel raced during open_session — aborting begin"); + asr.cancel(); + if let Some(rec) = inner.qa_recorder.lock().take() { + rec.stop(); + } + inner.qa_state.lock().phase = QaPhase::Idle; + return Ok(()); + } + + let target: Arc = asr; + let flushed = bridge.attach(target); + log::info!("[coord] QA ASR connected; flushed {flushed} deferred audio bytes"); + + // 通知前端进入 recording 状态("loading" 已经在第 1 步发过;这里附带选区预览)。 + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit_to( + "qa", + "qa:state", + serde_json::json!({ + "kind": "recording", + "selectionPreview": selection_preview_text, + }), + ); + } + + Ok(()) +} + +async fn end_qa_session(inner: &Arc) -> Result<(), String> { + { + let mut state = inner.qa_state.lock(); + if state.phase != QaPhase::Recording { + return Ok(()); + } + state.phase = QaPhase::Processing; + } + + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit_to( + "qa", + "qa:state", + serde_json::json!({ "kind": "transcribing" }), + ); + } + + if let Some(rec) = inner.qa_recorder.lock().take() { + rec.stop(); + } + + let asr = match inner.qa_asr.lock().take() { + Some(a) => a, + None => { + inner.qa_state.lock().phase = QaPhase::Idle; + return Ok(()); + } + }; + + if let Err(e) = asr.send_last_frame().await { + log::error!("[coord] QA: send last frame failed: {e}"); + } + let raw = match asr.await_final_result().await { + Ok(r) => r, + Err(e) => { + log::error!("[coord] QA: await final failed: {e}"); + finish_qa_with_error(inner, format!("识别失败: {e}")); + return Err(e.to_string()); + } + }; + + // cancel race:用户在 transcribe 中按 Esc / dismiss → 静默退出。 + if inner.qa_state.lock().cancelled { + log::info!("[coord] QA cancel detected after ASR — discarding transcript"); + finish_qa_idle_silently(inner); + return Ok(()); + } + + let question = raw.text.trim().to_string(); + if question.is_empty() { + // 静默录音:不调 LLM,不弹错误,直接关浮窗。 + log::info!("[coord] QA: empty transcript → silent dismiss"); + finish_qa_idle_silently(inner); + return Ok(()); + } + + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit_to( + "qa", + "qa:state", + serde_json::json!({ + "kind": "thinking", + "question": question, + }), + ); + } + + let prefs = inner.prefs.get(); + let working_languages = prefs.working_languages.clone(); + let (selection_text, front_app) = { + let st = inner.qa_state.lock(); + let sel_text = st + .selection + .as_ref() + .map(|s| s.text.clone()) + .unwrap_or_default(); + (sel_text, st.front_app.clone()) + }; + + let answer = match answer_with_selection_dispatch( + &question, + &selection_text, + &working_languages, + front_app.as_deref(), + ) + .await + { + Ok(s) => s, + Err(e) => { + log::error!("[coord] QA: LLM answer failed: {e}"); + finish_qa_with_error(inner, format!("回答失败: {e}")); + return Err(e.to_string()); + } + }; + + if inner.qa_state.lock().cancelled { + log::info!("[coord] QA cancel detected before answer — discarding"); + finish_qa_idle_silently(inner); + return Ok(()); + } + + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit_to( + "qa", + "qa:state", + serde_json::json!({ + "kind": "answer", + "text": answer, + "question": question, + }), + ); + } + + // 可选:写一条 history(QA 类型)。当前 DictationSession schema 不能直接表达 + // "QuestionAnswer" 类型,因此简单做法:勾选 qa_save_history 时写一条 + // mode=Raw、error_code=Some("qaSession") 的 placeholder,避免污染 schema 同时 + // 让用户能在历史里翻到这次问答的字面值。详见 issue #118。 + if prefs.qa_save_history { + let session = DictationSession { + id: Uuid::new_v4().to_string(), + created_at: Utc::now().to_rfc3339(), + raw_transcript: question.clone(), + final_text: answer.clone(), + mode: PolishMode::Raw, + app_bundle_id: None, + app_name: front_app.clone(), + insert_status: InsertStatus::CopiedFallback, + error_code: Some("qaSession".to_string()), + duration_ms: Some(raw.duration_ms), + dictionary_entry_count: None, + }; + if let Err(e) = inner.history.append(session) { + log::error!("[coord] QA history append failed: {e}"); + } + } + + inner.qa_state.lock().phase = QaPhase::Idle; + Ok(()) +} + +/// 把出错状态送到前端浮窗 + 复位 phase;不弹胶囊错误(QA 浮窗内部自己渲染)。 +fn finish_qa_with_error(inner: &Arc, message: String) { + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit_to( + "qa", + "qa:state", + serde_json::json!({ + "kind": "error", + "message": message, + }), + ); + } + let mut state = inner.qa_state.lock(); + state.phase = QaPhase::Idle; + state.cancelled = false; +} + +/// 静默收尾:发 idle 事件给前端关浮窗(pinned 时保留),phase 复位到 Idle。 +fn finish_qa_idle_silently(inner: &Arc) { + let pinned = inner.qa_state.lock().pinned; + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit_to( + "qa", + "qa:state", + serde_json::json!({ "kind": "idle" }), + ); + if !pinned { + crate::hide_qa_window(&app); + } + } + let mut state = inner.qa_state.lock(); + state.phase = QaPhase::Idle; + state.cancelled = false; + state.selection = None; +} + +fn cancel_qa_session(inner: &Arc) { + let phase = inner.qa_state.lock().phase; + if phase == QaPhase::Idle { + return; + } + inner.qa_state.lock().cancelled = true; + if let Some(rec) = inner.qa_recorder.lock().take() { + rec.stop(); + } + if let Some(asr) = inner.qa_asr.lock().take() { + asr.cancel(); + } + // Processing 阶段保持 phase 让 end_qa_session 自然走完 cancel 检查; + // 否则直接复位。 + if phase != QaPhase::Processing { + inner.qa_state.lock().phase = QaPhase::Idle; + } + log::info!("[coord] QA session cancelled (was {phase:?})"); +} + +async fn answer_with_selection_dispatch( + question: &str, + selection: &str, + working_languages: &[String], + front_app: Option<&str>, +) -> anyhow::Result { + let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default(); + if api_key.is_empty() { + anyhow::bail!("ark api key missing"); + } + let model = CredentialsVault::get(CredentialAccount::ArkModelId)? + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "deepseek-v3-2".to_string()); + let endpoint = CredentialsVault::get(CredentialAccount::ArkEndpoint)? + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "https://ark.cn-beijing.volces.com/api/v3/chat/completions".to_string()); + let base_url = endpoint + .trim_end_matches("/chat/completions") + .trim_end_matches('/') + .to_string(); + let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model); + let provider = OpenAICompatibleLLMProvider::new(config); + Ok(provider + .answer_with_selection(question, selection, working_languages, front_app) + .await?) +} + #[cfg(test)] mod tests { use super::*; diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 4d7b9f10..ac025c71 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -18,7 +18,9 @@ mod insertion; mod permissions; mod persistence; mod polish; +mod qa_hotkey; mod recorder; +mod selection; mod types; #[cfg(target_os = "macos")] @@ -28,7 +30,7 @@ use std::sync::Arc; use std::time::Duration; use tauri::menu::{MenuBuilder, MenuItemBuilder}; use tauri::tray::{MouseButton, TrayIconBuilder, TrayIconEvent}; -use tauri::{AppHandle, LogicalPosition, Manager, RunEvent, Runtime}; +use tauri::{AppHandle, Emitter, LogicalPosition, Manager, RunEvent, Runtime}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -60,6 +62,19 @@ pub fn run() { let _ = capsule.hide(); } + // QA 浮窗(issue #118):紧贴胶囊上方 8pt、屏幕底部居中、380×280。 + // 启动时 hide(),等 coordinator 在 begin_qa_session 时再 show + 定位。 + // tauri.conf.json 里需要声明 label="qa" 的窗口(前端 agent 负责); + // 这里 get_webview_window 返回 None 时直接跳过,不影响主流程。 + if let Some(qa) = app.get_webview_window("qa") { + if let Err(e) = position_qa_window(&qa) { + log::warn!("[qa] position failed: {e}"); + } + let _ = qa.hide(); + } else { + log::info!("[qa] qa 窗口未在 tauri.conf.json 中声明,前端 agent 会补上"); + } + // 主窗口磨砂:macOS 用 NSVisualEffectView,Windows 用 Mica。 // 没这一层的话 transparent: true 让窗口透明 → 背后只是空,不是磨砂。 // @@ -137,6 +152,8 @@ pub fn run() { let app_handle = app.handle().clone(); coordinator.bind_app(app_handle); coordinator.start_hotkey_listener(); + // 同步启动 QA hotkey listener。和 dictation hotkey 平行,互不抢状态。 + coordinator.start_qa_hotkey_listener(); if std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() == Some("1") { show_main_window(app.handle()); } @@ -175,6 +192,10 @@ pub fn run() { commands::read_credential, commands::set_active_asr_provider, commands::set_active_llm_provider, + commands::get_qa_hotkey_label, + commands::set_qa_hotkey, + commands::qa_window_dismiss, + commands::qa_window_pin, restart_app, ]) .build(tauri::generate_context!()) @@ -193,6 +214,7 @@ pub fn run() { RunEvent::Exit => { let coordinator = app.state::>(); coordinator.stop_hotkey_listener(); + coordinator.stop_qa_hotkey_listener(); } _ => {} }); @@ -399,6 +421,71 @@ fn wait_for_app_activation(app: &AppHandle) { #[cfg(not(target_os = "macos"))] fn wait_for_app_activation(_app: &AppHandle) {} +/// QA 浮窗的目标尺寸(issue #118)。胶囊默认 220×96 + Dock 80pt + 8pt gap, +/// 算下来 QA 窗口顶部坐标 = h - 80 - 96 - 8 - 280。 +const QA_WINDOW_WIDTH: f64 = 380.0; +const QA_WINDOW_HEIGHT: f64 = 280.0; +/// 胶囊与 QA 窗口的间距,与设计稿一致。 +const QA_WINDOW_GAP_TO_CAPSULE: f64 = 8.0; +/// 胶囊高度(与 `position_capsule_bottom_center` 中一致)。 +const CAPSULE_HEIGHT_FOR_QA: f64 = 96.0; +/// 给 macOS Dock 留的下边距(与 capsule 同源)。 +const DOCK_BOTTOM_PADDING_FOR_QA: f64 = 80.0; + +/// 把 QA 浮窗放到屏幕底部居中、紧贴胶囊上方。tauri 启动期 + show 之前都会调一次, +/// 防止用户切换显示器后位置错乱。 +fn position_qa_window(window: &tauri::WebviewWindow) -> tauri::Result<()> { + let monitor = match window.current_monitor()? { + Some(m) => m, + None => return Ok(()), + }; + let scale = monitor.scale_factor(); + let size = monitor.size(); + let logical_w = size.width as f64 / scale; + let logical_h = size.height as f64 / scale; + let x = ((logical_w - QA_WINDOW_WIDTH) / 2.0).max(0.0); + let y = (logical_h + - DOCK_BOTTOM_PADDING_FOR_QA + - CAPSULE_HEIGHT_FOR_QA + - QA_WINDOW_GAP_TO_CAPSULE + - QA_WINDOW_HEIGHT) + .max(0.0); + window.set_size(tauri::LogicalSize::new(QA_WINDOW_WIDTH, QA_WINDOW_HEIGHT))?; + window.set_position(LogicalPosition::new(x, y))?; + Ok(()) +} + +/// 显示 QA 窗口并发一条状态事件(前端订阅 `qa:state`)。 +/// `content_kind` 是不透明字符串("loading" / "answer" / "idle" 等), +/// 让前端 React 视图自行决定渲染哪一种。**不**抢前台 app 焦点(保证 Cmd+C +/// fallback 仍能从原 app 拿到选区)。 +pub(crate) fn show_qa_window(app: &AppHandle, content_kind: &str) { + let Some(window) = app.get_webview_window("qa") else { + log::info!( + "[qa] show 跳过:qa 窗口不存在 (content_kind={content_kind})" + ); + return; + }; + if let Err(e) = position_qa_window(&window) { + log::warn!("[qa] position before show failed: {e}"); + } + if let Err(e) = window.show() { + log::warn!("[qa] show failed: {e}"); + } + let _ = app.emit_to( + "qa", + "qa:state", + serde_json::json!({ "kind": content_kind }), + ); +} + +/// 隐藏 QA 窗口。供 commands::qa_window_dismiss / coordinator session 收尾共用。 +pub(crate) fn hide_qa_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window("qa") { + let _ = window.hide(); + } +} + /// 把 capsule 窗口移到屏幕底部居中,与 Swift `CapsuleWindowController.repositionToBottomCenter` 同效。 /// 留 80pt 给 macOS Dock;Windows 任务栏一般在底部 48pt 以内,整体也合适。 fn position_capsule_bottom_center( diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index cb5a37c3..d524ff38 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -100,6 +100,28 @@ impl OpenAICompatibleLLMProvider { self.chat_completion(&system_prompt, &user_prompt).await } + /// 划词语音问答:基于用户在前台 app 选中的文本(`selection`)和口述提问(`question`), + /// 给出 Markdown 格式的简短回答。`working_languages` 与 `front_app` 通过 + /// `context_premise` 拼到 system prompt 头部。详见 issue #118。 + /// + /// `selection` 可以为空 —— 用户没选中时退化为纯语音问答;`question` 为空时仍会调 + /// LLM(让模型在 system 约束下产出"无问题可答"的简短提示),但 coordinator + /// 通常会在静默录音时直接 short-circuit 不走到这里。 + pub async fn answer_with_selection( + &self, + question: &str, + selection: &str, + working_languages: &[String], + front_app: Option<&str>, + ) -> Result { + let mut system_prompt = prompts::qa_system_prompt(); + if let Some(premise) = context_premise(working_languages, front_app) { + system_prompt = format!("{}\n\n{}", premise, system_prompt); + } + let user_prompt = prompts::qa_user_prompt(question, selection); + self.chat_completion(&system_prompt, &user_prompt).await + } + /// 把转写翻译成 `target_language`(前端从内置语言列表里选出来的原生名)。 /// `working_languages` 与 `front_app` 作为前提注入头部。详见 issue #4 与 #116。 pub async fn translate_to( @@ -606,6 +628,33 @@ pub mod prompts { ) } + /// 划词语音问答 system prompt — 用户选中一段文字后口头提问,要求基于选区给出简短答案。 + /// 详见 issue #118。 + pub fn qa_system_prompt() -> String { + "# 任务(基于选区的语音问答)\n\ + 用户选中了一段文字,并对它提了一个语音问题。请基于选中内容回答这个问题。\n\ + \n\ + ## 输入约定\n\ + - 选中文本可能很短(一个词),也可能很长(被截断时尾部有 […truncated…])。\n\ + - 提问可能很口语化(\u{201C}这是啥意思\u{201D} / \u{201C}和数据库啥区别\u{201D}),按字面理解。\n\ + - 选中文本可能为空(用户没选中),那就只回答语音问题,不编造选区。\n\ + \n\ + ## 输出约定\n\ + - 用 Markdown,但不要 H1/H2 大标题。可以用粗体、列表、行内代码。\n\ + - 控制在 3 段以内,约 200 字以内(除非用户明确要求长篇)。\n\ + - 用大白话,不要客套话(\u{201C}希望能帮到你\u{201D}等)。\n\ + - 不要重复用户的提问。\n\ + - 如果选中文本和提问无关,按提问独立回答,**不编造选区里没有的信息**。" + .to_string() + } + + /// QA user prompt — 把选中文本 + 口述提问拼成 chat user 消息。 + pub fn qa_user_prompt(question: &str, selection: &str) -> String { + format!( + "选中文本:\n\"\"\"\n{selection}\n\"\"\"\n\n我的语音提问:\n「{question}」" + ) + } + /// 翻译模式 system prompt — 用户在「翻译」页选定的目标语言(内置 15 种自然语言原生名)。 /// LLM 自己理解("繁体中文"/"English"/"美式英文"/"日本語" 都行)。 /// 此 prompt 之上还有 working_languages_premise 拼出的"# 上下文"前提。 diff --git a/openless-all/app/src-tauri/src/qa_hotkey.rs b/openless-all/app/src-tauri/src/qa_hotkey.rs new file mode 100644 index 00000000..e0baa571 --- /dev/null +++ b/openless-all/app/src-tauri/src/qa_hotkey.rs @@ -0,0 +1,333 @@ +//! 划词语音问答(QA)专用的全局快捷键监听器。 +//! +//! 与 `hotkey.rs`(modifier-only 听写热键)平行——QA 用的是组合键 +//! `Cmd+Shift+;` / `Ctrl+Shift+;`,所以走 `global-hotkey` crate(macOS 内部 +//! 用 Carbon `RegisterEventHotKey`,Windows 用 `RegisterHotKey`,Linux 用 X11)。 +//! +//! 仅产出 `QaHotkeyEvent::Pressed` 边沿事件;toggle / 录音生命周期由 +//! coordinator 解释(第一次按 → 开始问答;第二次按 → 结束)。 +//! +//! 模块依赖:仅 `types`,与 CLAUDE.md "Rust 模块依赖只通过 types.rs 跨模块" 一致。 + +use std::sync::mpsc::Sender; +use std::sync::Arc; + +use global_hotkey::hotkey::{Code, HotKey, Modifiers}; +use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; +use parking_lot::Mutex; + +use crate::types::QaHotkeyBinding; + +#[derive(Debug, Clone, Copy)] +pub enum QaHotkeyEvent { + /// 用户按下了配置的 QA 组合键(toggle 模式:第一次开始,第二次结束)。 + Pressed, +} + +#[derive(Debug, thiserror::Error)] +pub enum QaHotkeyError { + #[error("不支持的修饰键: {0}")] + UnsupportedModifier(String), + #[error("不支持的主键: {0}")] + UnsupportedKey(String), + #[error("注册全局快捷键失败: {0}")] + RegisterFailed(String), + #[error("初始化全局快捷键管理器失败: {0}")] + ManagerInitFailed(String), +} + +/// QA 全局快捷键监听器。`Drop` 时反注册。 +/// +/// 内部用 `global-hotkey` crate;事件转发线程持有一个共享的 `Sender`。 +pub struct QaHotkeyMonitor { + inner: Arc, +} + +struct Inner { + manager: GlobalHotKeyManager, + /// 当前注册的 hotkey 句柄;用于 unregister。 + registered: Mutex>, + /// 事件转发线程接收 global-hotkey crate 的全局 channel,再过滤 id 后转发到 tx。 + forward_alive: Arc, + /// 当前关心的 hotkey id(filter 用)。 + active_id: Arc, +} + +impl QaHotkeyMonitor { + /// 启动监听并注册一个 hotkey。`tx` 在每次按下边沿收到 `QaHotkeyEvent::Pressed`。 + /// + /// **注意**:`global-hotkey` crate 在 macOS 要求 manager 在主线程构造。 + /// 调用方需要确保从主线程触发(coordinator 的 supervisor 线程会通过 + /// `AppHandle::run_on_main_thread` 跳到主线程后再 spawn 这个 monitor)。 + /// 本函数不强制断言主线程——单元 / 集成测试也跑不到 manager 创建那一行。 + pub fn start( + binding: QaHotkeyBinding, + tx: Sender, + ) -> Result { + let manager = GlobalHotKeyManager::new() + .map_err(|e| QaHotkeyError::ManagerInitFailed(e.to_string()))?; + + let hotkey = parse_binding(&binding)?; + manager + .register(hotkey) + .map_err(|e| QaHotkeyError::RegisterFailed(e.to_string()))?; + + let active_id = Arc::new(std::sync::atomic::AtomicU32::new(hotkey.id())); + let forward_alive = Arc::new(std::sync::atomic::AtomicBool::new(true)); + + // 启动转发线程:消费 global-hotkey 的进程级 channel,filter id 后投递到上层 tx。 + // global-hotkey 用 crossbeam_channel,自带超时 recv,便于优雅退出。 + let alive_for_thread = Arc::clone(&forward_alive); + let id_for_thread = Arc::clone(&active_id); + std::thread::Builder::new() + .name("openless-qa-hotkey-forward".into()) + .spawn(move || forward_loop(alive_for_thread, id_for_thread, tx)) + .map_err(|e| QaHotkeyError::RegisterFailed(format!("spawn forward thread: {e}")))?; + + Ok(Self { + inner: Arc::new(Inner { + manager, + registered: Mutex::new(Some(hotkey)), + forward_alive, + active_id, + }), + }) + } + + /// 替换当前注册的 hotkey(用户在设置里改了组合键时)。 + pub fn update_binding(&self, binding: QaHotkeyBinding) -> Result<(), QaHotkeyError> { + let next = parse_binding(&binding)?; + let mut current = self.inner.registered.lock(); + if let Some(prev) = current.take() { + if prev == next { + *current = Some(prev); + return Ok(()); + } + if let Err(e) = self.inner.manager.unregister(prev) { + log::warn!("[qa-hotkey] unregister 旧绑定失败: {e}"); + } + } + self.inner + .manager + .register(next) + .map_err(|e| QaHotkeyError::RegisterFailed(e.to_string()))?; + *current = Some(next); + self.inner + .active_id + .store(next.id(), std::sync::atomic::Ordering::SeqCst); + Ok(()) + } +} + +impl Drop for QaHotkeyMonitor { + fn drop(&mut self) { + // 通知转发线程退出;超时 recv 后自然结束。 + self.inner + .forward_alive + .store(false, std::sync::atomic::Ordering::SeqCst); + if let Some(prev) = self.inner.registered.lock().take() { + if let Err(e) = self.inner.manager.unregister(prev) { + log::warn!("[qa-hotkey] drop 时 unregister 失败: {e}"); + } + } + } +} + +fn forward_loop( + alive: Arc, + active_id: Arc, + tx: Sender, +) { + // global-hotkey crate 用 crossbeam_channel;其 receiver 没暴露 RecvTimeoutError 给外部, + // 所以不区分 timeout vs disconnect,统一 250ms tick 重新 check alive 标志。 + let receiver = GlobalHotKeyEvent::receiver(); + while alive.load(std::sync::atomic::Ordering::SeqCst) { + let event = match receiver.recv_timeout(std::time::Duration::from_millis(250)) { + Ok(e) => e, + Err(_) => continue, + }; + let want = active_id.load(std::sync::atomic::Ordering::SeqCst); + if event.id() != want { + continue; + } + if !matches!(event.state(), HotKeyState::Pressed) { + continue; + } + if let Err(e) = tx.send(QaHotkeyEvent::Pressed) { + log::warn!("[qa-hotkey] 事件投递失败: {e}"); + break; + } + } + log::info!("[qa-hotkey] 转发线程退出"); +} + +fn parse_binding(binding: &QaHotkeyBinding) -> Result { + let mut mods = Modifiers::empty(); + for raw in &binding.modifiers { + let tag = raw.trim().to_ascii_lowercase(); + let bit = match tag.as_str() { + "cmd" | "command" | "super" | "meta" | "win" => Modifiers::SUPER, + "ctrl" | "control" => Modifiers::CONTROL, + "alt" | "option" | "opt" => Modifiers::ALT, + "shift" => Modifiers::SHIFT, + other => return Err(QaHotkeyError::UnsupportedModifier(other.to_string())), + }; + mods |= bit; + } + let code = parse_primary(&binding.primary)?; + Ok(HotKey::new(Some(mods), code)) +} + +/// 把用户配置的主键字符串解析成 keyboard_types::Code。 +/// 支持单字符(字母 / 数字 / 符号)+ 常见命名键(F1..F12 / Enter / Tab / Escape / Space)。 +fn parse_primary(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(QaHotkeyError::UnsupportedKey("(空)".into())); + } + // 单字符 + if trimmed.chars().count() == 1 { + let ch = trimmed.chars().next().unwrap(); + if let Some(code) = char_to_code(ch) { + return Ok(code); + } + } + // 命名键 + let upper = trimmed.to_ascii_uppercase(); + let named = match upper.as_str() { + "ENTER" | "RETURN" => Code::Enter, + "TAB" => Code::Tab, + "ESC" | "ESCAPE" => Code::Escape, + "SPACE" => Code::Space, + "BACKSPACE" => Code::Backspace, + "DELETE" | "DEL" => Code::Delete, + "HOME" => Code::Home, + "END" => Code::End, + "PAGEUP" => Code::PageUp, + "PAGEDOWN" => Code::PageDown, + "ARROWUP" | "UP" => Code::ArrowUp, + "ARROWDOWN" | "DOWN" => Code::ArrowDown, + "ARROWLEFT" | "LEFT" => Code::ArrowLeft, + "ARROWRIGHT" | "RIGHT" => Code::ArrowRight, + "F1" => Code::F1, + "F2" => Code::F2, + "F3" => Code::F3, + "F4" => Code::F4, + "F5" => Code::F5, + "F6" => Code::F6, + "F7" => Code::F7, + "F8" => Code::F8, + "F9" => Code::F9, + "F10" => Code::F10, + "F11" => Code::F11, + "F12" => Code::F12, + _ => return Err(QaHotkeyError::UnsupportedKey(trimmed.to_string())), + }; + Ok(named) +} + +fn char_to_code(ch: char) -> Option { + let c = ch.to_ascii_uppercase(); + let code = match c { + 'A' => Code::KeyA, + 'B' => Code::KeyB, + 'C' => Code::KeyC, + 'D' => Code::KeyD, + 'E' => Code::KeyE, + 'F' => Code::KeyF, + 'G' => Code::KeyG, + 'H' => Code::KeyH, + 'I' => Code::KeyI, + 'J' => Code::KeyJ, + 'K' => Code::KeyK, + 'L' => Code::KeyL, + 'M' => Code::KeyM, + 'N' => Code::KeyN, + 'O' => Code::KeyO, + 'P' => Code::KeyP, + 'Q' => Code::KeyQ, + 'R' => Code::KeyR, + 'S' => Code::KeyS, + 'T' => Code::KeyT, + 'U' => Code::KeyU, + 'V' => Code::KeyV, + 'W' => Code::KeyW, + 'X' => Code::KeyX, + 'Y' => Code::KeyY, + 'Z' => Code::KeyZ, + '0' => Code::Digit0, + '1' => Code::Digit1, + '2' => Code::Digit2, + '3' => Code::Digit3, + '4' => Code::Digit4, + '5' => Code::Digit5, + '6' => Code::Digit6, + '7' => Code::Digit7, + '8' => Code::Digit8, + '9' => Code::Digit9, + ';' => Code::Semicolon, + ':' => Code::Semicolon, + ',' => Code::Comma, + '.' => Code::Period, + '/' => Code::Slash, + '\\' => Code::Backslash, + '[' => Code::BracketLeft, + ']' => Code::BracketRight, + '\'' => Code::Quote, + '`' => Code::Backquote, + '-' => Code::Minus, + '=' => Code::Equal, + ' ' => Code::Space, + _ => return None, + }; + Some(code) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_default_binding() { + let binding = QaHotkeyBinding::default(); + let parsed = parse_binding(&binding).expect("default binding parses"); + assert!(parsed.mods.contains(Modifiers::SHIFT)); + assert_eq!(parsed.key, Code::Semicolon); + } + + #[test] + fn parse_letter_binding() { + let binding = QaHotkeyBinding { + primary: "k".into(), + modifiers: vec!["cmd".into(), "alt".into()], + }; + let parsed = parse_binding(&binding).expect("letter binding parses"); + assert_eq!(parsed.key, Code::KeyK); + assert!(parsed.mods.contains(Modifiers::SUPER)); + assert!(parsed.mods.contains(Modifiers::ALT)); + } + + #[test] + fn unsupported_modifier_rejected() { + let binding = QaHotkeyBinding { + primary: ";".into(), + modifiers: vec!["hyper".into()], + }; + assert!(matches!( + parse_binding(&binding), + Err(QaHotkeyError::UnsupportedModifier(_)) + )); + } + + #[test] + fn empty_primary_rejected() { + let binding = QaHotkeyBinding { + primary: "".into(), + modifiers: vec!["cmd".into()], + }; + assert!(matches!( + parse_binding(&binding), + Err(QaHotkeyError::UnsupportedKey(_)) + )); + } +} diff --git a/openless-all/app/src-tauri/src/selection.rs b/openless-all/app/src-tauri/src/selection.rs new file mode 100644 index 00000000..305fd061 --- /dev/null +++ b/openless-all/app/src-tauri/src/selection.rs @@ -0,0 +1,540 @@ +//! 跨平台「划词捕获」工具:在用户触发 QA 快捷键时尝试拿到当前前台 app 的选区文本。 +//! +//! 三级 fallback: +//! 1. **macOS** AX:`AXUIElementCopyAttributeValue(focused, kAXSelectedTextAttribute)` +//! 走辅助功能 API 直读焦点元素的选区,**不**触碰剪贴板。 +//! 2. **macOS / Windows** Cmd+C / Ctrl+C:snapshot 用户原剪贴板 → 模拟复制 → 80ms +//! 后读出新内容 → 还原原剪贴板。 +//! 3. **Linux**:返回 `None`(X11/Wayland AX 模式不统一,留作 best-effort 后续)。 +//! +//! 截断策略:超过 4000 字符的选区只保留首 2000 + 尾 2000 + `[…truncated…]` 标记, +//! 避免给 LLM 灌过长 context。 +//! +//! 模块依赖:仅 `arboard`(跨平台剪贴板)+ libc + 平台 native 框架;不依赖其它 +//! Rust 模块(与 CLAUDE.md 对齐)。 + +use std::time::Duration; + +const SELECTION_MAX_CHARS: usize = 4000; +const SELECTION_TRUNCATE_HEAD: usize = 2000; +const SELECTION_TRUNCATE_TAIL: usize = 2000; +const SELECTION_TRUNCATED_MARKER: &str = "\n[…truncated…]\n"; + +/// 从前台 app 读到的选区上下文。 +/// `text` 已经过截断处理;`source_app` 是前台 app 的人类可读标签(可空)。 +#[derive(Debug, Clone)] +pub struct SelectionContext { + pub text: String, + pub source_app: Option, +} + +/// 尝试捕获当前选区文本。所有 IO 都在调用线程完成(短小、阻塞但 < 200ms)。 +/// +/// 返回 `None` 表示真的没拿到东西(用户没选 / 平台不支持 / 权限缺失)。 +/// 返回 `Some(ctx)` 时 `ctx.text` **保证非空**。 +pub fn capture_selection() -> Option { + let source_app = current_front_app(); + + // 1. macOS AX 直读 + #[cfg(target_os = "macos")] + if let Some(text) = macos_ax::read_selected_text() { + let trimmed = text.trim(); + if !trimmed.is_empty() { + log::info!( + "[selection] AX read OK ({} chars){}", + trimmed.chars().count(), + source_app + .as_deref() + .map(|a| format!(" front_app={a}")) + .unwrap_or_default() + ); + return Some(SelectionContext { + text: truncate_selection(trimmed), + source_app, + }); + } + } + + // 2. 模拟复制 fallback(macOS / Windows) + #[cfg(any(target_os = "macos", target_os = "windows"))] + if let Some(text) = simulate_copy_and_read() { + let trimmed = text.trim(); + if !trimmed.is_empty() { + log::info!( + "[selection] simulate-copy fallback OK ({} chars){}", + trimmed.chars().count(), + source_app + .as_deref() + .map(|a| format!(" front_app={a}")) + .unwrap_or_default() + ); + return Some(SelectionContext { + text: truncate_selection(trimmed), + source_app, + }); + } + } + + // 3. Linux:暂不支持 + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + let _ = source_app; + log::info!("[selection] platform unsupported, returning None"); + } + + None +} + +/// 长度截断到首 + 尾 + 标记。 +fn truncate_selection(text: &str) -> String { + let total: usize = text.chars().count(); + if total <= SELECTION_MAX_CHARS { + return text.to_string(); + } + let head: String = text.chars().take(SELECTION_TRUNCATE_HEAD).collect(); + let tail_start = total.saturating_sub(SELECTION_TRUNCATE_TAIL); + let tail: String = text.chars().skip(tail_start).collect(); + format!("{head}{SELECTION_TRUNCATED_MARKER}{tail}") +} + +// ─────────────────────────── 模拟复制 fallback (mac/win) ─────────────────────────── + +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn simulate_copy_and_read() -> Option { + // a) snapshot 当前剪贴板(用作还原原状态的备份) + let mut clipboard = match arboard::Clipboard::new() { + Ok(c) => c, + Err(e) => { + log::warn!("[selection] clipboard init failed: {e}"); + return None; + } + }; + let original = match clipboard.get_text() { + Ok(t) => Some(t), + Err(e) => { + log::info!("[selection] clipboard get_text returned err (likely empty): {e}"); + None + } + }; + + // b) 写一个 sentinel 进剪贴板 — 之后用来检查模拟复制是否真的有覆盖(如果还是 + // sentinel 说明 Cmd+C 没生效或目标 app 没选区)。 + let sentinel = format!("__openless_qa_sentinel_{}__", uuid_like_token()); + if let Err(e) = clipboard.set_text(sentinel.clone()) { + log::warn!("[selection] clipboard set_text(sentinel) failed: {e}"); + // 即使设置 sentinel 失败,也尝试发 Cmd+C 看能不能直接拿到东西 + } + + // c) 模拟 Cmd+C / Ctrl+C + let post_ok = post_copy_shortcut(); + if !post_ok { + log::warn!("[selection] post_copy_shortcut failed"); + // 不立刻 return:剪贴板可能已经被某些路径污染,按下方还原流程恢复。 + } + + // d) 等剪贴板更新(macOS / Windows 都需要少量时间让目标 app 把数据 put 进去) + std::thread::sleep(Duration::from_millis(80)); + + // e) 读新值 + let captured = clipboard.get_text().ok(); + + // f) 还原原剪贴板 + if let Some(prev) = original { + if let Err(e) = clipboard.set_text(prev) { + log::warn!("[selection] clipboard restore failed: {e}"); + } + } else { + // 用户原剪贴板就是空 → 把 sentinel / 选区清掉,避免污染。 + if let Err(e) = clipboard.set_text("") { + log::warn!("[selection] clipboard clear failed: {e}"); + } + } + + let captured = captured?; + if captured == sentinel || captured.is_empty() { + return None; + } + Some(captured) +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn uuid_like_token() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{nanos:x}") +} + +#[cfg(target_os = "macos")] +fn post_copy_shortcut() -> bool { + macos_paste::post_cmd_c().is_ok() +} + +#[cfg(target_os = "windows")] +fn post_copy_shortcut() -> bool { + windows_paste::send_ctrl_c().is_ok() +} + +// ─────────────────────────── macOS AX read ─────────────────────────── + +#[cfg(target_os = "macos")] +mod macos_ax { + use std::ffi::{c_void, CStr}; + use std::os::raw::c_char; + + #[repr(C)] + struct OpaqueAxRef(c_void); + type AxUiElementRef = *mut OpaqueAxRef; + type CFStringRef = *const c_void; + type CFTypeRef = *const c_void; + type CFAllocatorRef = *const c_void; + type AxError = i32; + + const AX_ERROR_SUCCESS: AxError = 0; + + #[link(name = "ApplicationServices", kind = "framework")] + extern "C" { + fn AXUIElementCreateSystemWide() -> AxUiElementRef; + fn AXUIElementCopyAttributeValue( + element: AxUiElementRef, + attribute: CFStringRef, + value: *mut CFTypeRef, + ) -> AxError; + } + + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + fn CFRelease(cf: CFTypeRef); + fn CFStringCreateWithCString( + allocator: CFAllocatorRef, + cstr: *const c_char, + encoding: u32, + ) -> CFStringRef; + fn CFStringGetCStringPtr(s: CFStringRef, encoding: u32) -> *const c_char; + fn CFStringGetCString( + s: CFStringRef, + buffer: *mut c_char, + buffer_size: isize, + encoding: u32, + ) -> bool; + fn CFStringGetLength(s: CFStringRef) -> isize; + fn CFStringGetMaximumSizeForEncoding(length: isize, encoding: u32) -> isize; + } + + const K_CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100; + + /// 调 system-wide AX 树拿 focused element,再读它的 selected text。 + /// 失败(权限缺失 / 没焦点 / 该控件不支持选区属性)时返回 None。 + pub fn read_selected_text() -> Option { + unsafe { + let system = AXUIElementCreateSystemWide(); + if system.is_null() { + return None; + } + // 注意:这里不能直接用 CFSTR 宏(Rust 没有),改用 CFStringCreateWithCString + // 临时构造 attribute key。 + let focused_attr = + cfstring_from_static(b"AXFocusedUIElement\0").unwrap_or(std::ptr::null()); + let selected_attr = + cfstring_from_static(b"AXSelectedText\0").unwrap_or(std::ptr::null()); + if focused_attr.is_null() || selected_attr.is_null() { + if !system.is_null() { + CFRelease(system as CFTypeRef); + } + if !focused_attr.is_null() { + CFRelease(focused_attr); + } + if !selected_attr.is_null() { + CFRelease(selected_attr); + } + return None; + } + + let mut focused: CFTypeRef = std::ptr::null(); + let err = AXUIElementCopyAttributeValue(system, focused_attr, &mut focused); + CFRelease(system as CFTypeRef); + CFRelease(focused_attr); + if err != AX_ERROR_SUCCESS || focused.is_null() { + CFRelease(selected_attr); + return None; + } + + let mut selected: CFTypeRef = std::ptr::null(); + let err2 = AXUIElementCopyAttributeValue( + focused as AxUiElementRef, + selected_attr, + &mut selected, + ); + CFRelease(focused); + CFRelease(selected_attr); + if err2 != AX_ERROR_SUCCESS || selected.is_null() { + return None; + } + + let result = cfstring_to_rust(selected); + CFRelease(selected); + result + } + } + + unsafe fn cfstring_from_static(bytes_with_nul: &[u8]) -> Option { + let cstr = CStr::from_bytes_with_nul(bytes_with_nul).ok()?; + let s = CFStringCreateWithCString(std::ptr::null(), cstr.as_ptr(), K_CF_STRING_ENCODING_UTF8); + if s.is_null() { + None + } else { + Some(s) + } + } + + unsafe fn cfstring_to_rust(s: CFStringRef) -> Option { + let direct = CFStringGetCStringPtr(s, K_CF_STRING_ENCODING_UTF8); + if !direct.is_null() { + let cstr = CStr::from_ptr(direct); + return cstr.to_str().ok().map(|s| s.to_string()); + } + let length = CFStringGetLength(s); + if length <= 0 { + return Some(String::new()); + } + let max_bytes = CFStringGetMaximumSizeForEncoding(length, K_CF_STRING_ENCODING_UTF8) + 1; + let mut buf: Vec = vec![0; max_bytes as usize]; + let ok = CFStringGetCString( + s, + buf.as_mut_ptr() as *mut c_char, + max_bytes, + K_CF_STRING_ENCODING_UTF8, + ); + if !ok { + return None; + } + let cstr = CStr::from_ptr(buf.as_ptr() as *const c_char); + cstr.to_str().ok().map(|s| s.to_string()) + } +} + +// ─────────────────────────── macOS Cmd+C post ─────────────────────────── + +#[cfg(target_os = "macos")] +mod macos_paste { + use std::ffi::c_void; + + #[repr(C)] + struct OpaqueCGEvent(c_void); + type CGEventRef = *mut OpaqueCGEvent; + + #[repr(C)] + struct OpaqueCGEventSource(c_void); + type CGEventSourceRef = *mut OpaqueCGEventSource; + + type CGEventTapLocation = u32; + type CGEventSourceStateID = i32; + type CGKeyCode = u16; + type CGEventFlags = u64; + + const KCG_HID_EVENT_TAP: CGEventTapLocation = 0; + const KCG_EVENT_SOURCE_STATE_HID_SYSTEM_STATE: CGEventSourceStateID = 1; + const KCG_EVENT_FLAG_MASK_COMMAND: CGEventFlags = 0x0010_0000; + /// kVK_ANSI_C + const KEY_C: CGKeyCode = 8; + + #[link(name = "CoreGraphics", kind = "framework")] + extern "C" { + fn CGEventSourceCreate(state_id: CGEventSourceStateID) -> CGEventSourceRef; + fn CGEventCreateKeyboardEvent( + source: CGEventSourceRef, + virtual_key: CGKeyCode, + key_down: bool, + ) -> CGEventRef; + fn CGEventSetFlags(event: CGEventRef, flags: CGEventFlags); + fn CGEventPost(tap: CGEventTapLocation, event: CGEventRef); + } + + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + fn CFRelease(cf: *const c_void); + } + + pub fn post_cmd_c() -> Result<(), String> { + unsafe { + let source = CGEventSourceCreate(KCG_EVENT_SOURCE_STATE_HID_SYSTEM_STATE); + let down = CGEventCreateKeyboardEvent(source, KEY_C, true); + let up = CGEventCreateKeyboardEvent(source, KEY_C, false); + if down.is_null() || up.is_null() { + if !source.is_null() { + CFRelease(source as *const c_void); + } + if !down.is_null() { + CFRelease(down as *const c_void); + } + if !up.is_null() { + CFRelease(up as *const c_void); + } + return Err("CGEventCreateKeyboardEvent returned null".into()); + } + CGEventSetFlags(down, KCG_EVENT_FLAG_MASK_COMMAND); + CGEventSetFlags(up, KCG_EVENT_FLAG_MASK_COMMAND); + CGEventPost(KCG_HID_EVENT_TAP, down); + CGEventPost(KCG_HID_EVENT_TAP, up); + CFRelease(down as *const c_void); + CFRelease(up as *const c_void); + if !source.is_null() { + CFRelease(source as *const c_void); + } + } + Ok(()) + } +} + +// ─────────────────────────── Windows Ctrl+C send ─────────────────────────── + +#[cfg(target_os = "windows")] +mod windows_paste { + use windows::Win32::UI::Input::KeyboardAndMouse::{ + SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, + KEYEVENTF_KEYUP, VIRTUAL_KEY, VK_C, VK_CONTROL, + }; + + pub fn send_ctrl_c() -> Result<(), String> { + let mut inputs = [ + keyboard_event(VK_CONTROL, false), + keyboard_event(VK_C, false), + keyboard_event(VK_C, true), + keyboard_event(VK_CONTROL, true), + ]; + + let sent = unsafe { SendInput(&mut inputs, std::mem::size_of::() as i32) }; + if (sent as usize) != inputs.len() { + return Err(format!("SendInput sent {sent}/{}", inputs.len())); + } + Ok(()) + } + + fn keyboard_event(vk: VIRTUAL_KEY, key_up: bool) -> INPUT { + let mut flags = KEYBD_EVENT_FLAGS(0); + if key_up { + flags |= KEYEVENTF_KEYUP; + } + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: vk, + wScan: 0, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + } + } +} + +// ─────────────────────────── front-app label ─────────────────────────── + +#[cfg(target_os = "macos")] +fn current_front_app() -> Option { + use objc2::msg_send; + use objc2::runtime::{AnyClass, AnyObject}; + + unsafe { + let cls = AnyClass::get("NSWorkspace")?; + let workspace: *mut AnyObject = msg_send![cls, sharedWorkspace]; + if workspace.is_null() { + return None; + } + let app: *mut AnyObject = msg_send![workspace, frontmostApplication]; + if app.is_null() { + return None; + } + let name_obj: *mut AnyObject = msg_send![app, localizedName]; + let name = ns_string_to_rust(name_obj); + let bundle_obj: *mut AnyObject = msg_send![app, bundleIdentifier]; + let bundle = ns_string_to_rust(bundle_obj); + match (name, bundle) { + (Some(n), Some(b)) => Some(format!("{n} ({b})")), + (Some(n), None) => Some(n), + (None, Some(b)) => Some(b), + (None, None) => None, + } + } +} + +#[cfg(target_os = "macos")] +unsafe fn ns_string_to_rust(ns_string: *mut objc2::runtime::AnyObject) -> Option { + use objc2::msg_send; + if ns_string.is_null() { + return None; + } + let utf8: *const std::os::raw::c_char = unsafe { msg_send![ns_string, UTF8String] }; + if utf8.is_null() { + return None; + } + let cstr = unsafe { std::ffi::CStr::from_ptr(utf8) }; + let s = cstr.to_string_lossy().into_owned(); + if s.is_empty() { + None + } else { + Some(s) + } +} + +#[cfg(target_os = "windows")] +fn current_front_app() -> Option { + use windows::Win32::UI::WindowsAndMessaging::{ + GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW, + }; + unsafe { + let hwnd = GetForegroundWindow(); + if hwnd.0.is_null() { + return None; + } + let len = GetWindowTextLengthW(hwnd); + if len <= 0 { + return None; + } + let mut buf = vec![0u16; (len + 1) as usize]; + let copied = GetWindowTextW(hwnd, &mut buf); + if copied <= 0 { + return None; + } + let title = String::from_utf16_lossy(&buf[..copied as usize]); + if title.is_empty() { + None + } else { + Some(title) + } + } +} + +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] +fn current_front_app() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_short_passes_through() { + let text = "hello world"; + assert_eq!(truncate_selection(text), text); + } + + #[test] + fn truncate_long_keeps_head_and_tail() { + let head: String = "a".repeat(SELECTION_TRUNCATE_HEAD); + let middle: String = "b".repeat(2_000); + let tail: String = "c".repeat(SELECTION_TRUNCATE_TAIL); + let combined = format!("{head}{middle}{tail}"); + let out = truncate_selection(&combined); + assert!(out.contains("[…truncated…]")); + assert!(out.starts_with(&"a".repeat(50))); + assert!(out.ends_with(&"c".repeat(50))); + // 中段 b 应被裁掉 + assert!(!out.contains(&"b".repeat(20))); + } +} diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index e5622378..abe11334 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -98,6 +98,19 @@ pub struct UserPreferences { /// 由前端从内置语言列表中选择,后端只接收最终的原生名字符串拼进 prompt。详见 issue #4。 #[serde(default)] pub translation_target_language: String, + /// 划词语音问答(QA)的全局快捷键。`None` = 关闭功能;`Some(...)` 时 + /// coordinator 用 global-hotkey crate 注册组合键(modifier + 主键)。 + /// 默认 Cmd+Shift+; (macOS) / Ctrl+Shift+; (Windows)。详见 issue #118。 + #[serde(default = "default_qa_hotkey")] + pub qa_hotkey: Option, + /// 是否把每次 QA 会话写进 history.json。默认 false:QA 默认临时不留痕。 + /// 详见 issue #118。 + #[serde(default)] + pub qa_save_history: bool, +} + +fn default_qa_hotkey() -> Option { + Some(QaHotkeyBinding::default()) } fn default_working_languages() -> Vec { @@ -122,8 +135,95 @@ impl Default for UserPreferences { restore_clipboard_after_paste: true, working_languages: default_working_languages(), translation_target_language: String::new(), + qa_hotkey: default_qa_hotkey(), + qa_save_history: false, + } + } +} + +/// 划词语音问答的全局快捷键绑定。原生名字符串: +/// - `primary`:主键(如 `";"`、`"."`、`"A"`、`"F1"`)。 +/// - `modifiers`:修饰键集合,元素来自 `{"cmd","ctrl","alt","shift","super"}`。 +/// 小写名简单序列化即可,前端 / 后端解析时统一 lowercase。 +/// +/// 默认 `Cmd+Shift+;` (macOS) / `Ctrl+Shift+;` (Windows)。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct QaHotkeyBinding { + pub primary: String, + pub modifiers: Vec, +} + +impl Default for QaHotkeyBinding { + fn default() -> Self { + #[cfg(target_os = "macos")] + { + Self { + primary: ";".into(), + modifiers: vec!["cmd".into(), "shift".into()], + } + } + #[cfg(not(target_os = "macos"))] + { + Self { + primary: ";".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + } + } + } +} + +impl QaHotkeyBinding { + /// 渲染成给前端展示的可读标签(macOS 用 `Cmd`,其他平台用 `Ctrl`)。 + /// 顺序与人类阅读习惯一致:`Cmd+Shift+;`、`Ctrl+Alt+Shift+.`。 + pub fn display_label(&self) -> String { + let mut parts: Vec = Vec::new(); + // 固定输出顺序:Ctrl/Cmd → Alt/Option → Shift → Super + let modifier_order = ["cmd", "ctrl", "alt", "shift", "super"]; + for tag in modifier_order { + if self.modifiers.iter().any(|m| m.eq_ignore_ascii_case(tag)) { + parts.push(modifier_display(tag).to_string()); + } + } + let key_label = display_primary(&self.primary); + parts.push(key_label); + parts.join("+") + } +} + +fn modifier_display(tag: &str) -> &'static str { + match tag { + "cmd" => "Cmd", + "ctrl" => "Ctrl", + "alt" => { + #[cfg(target_os = "macos")] + { + "Option" + } + #[cfg(not(target_os = "macos"))] + { + "Alt" + } + } + "shift" => "Shift", + "super" => "Super", + _ => "", + } +} + +fn display_primary(primary: &str) -> String { + let trimmed = primary.trim(); + if trimmed.is_empty() { + return "?".to_string(); + } + // 单个字母键归一为大写显示("a" → "A");其余原样(如 ";"、"F1")。 + if trimmed.chars().count() == 1 { + let ch = trimmed.chars().next().unwrap(); + if ch.is_ascii_alphabetic() { + return ch.to_ascii_uppercase().to_string(); } } + trimmed.to_string() } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] From 1a0efe402c992a2de2ce5fac52f92a98f6e3f33e Mon Sep 17 00:00:00 2001 From: baiqing Date: Fri, 1 May 2026 13:07:51 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E5=88=92=E8=AF=8D=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E9=97=AE=E7=AD=94=E5=89=8D=E7=AB=AF=20=E2=80=94=20clo?= =?UTF-8?q?ses=20#118=20=E5=89=8D=E7=AB=AF=E9=83=A8=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 qa 浮窗(tauri.conf.json windows[2]):380x280 无装饰磨砂玻璃 - 新增 QaPanel.tsx:loading skeleton / answer markdown / error 三态 - marked@^11 渲染 markdown,注入轻量排版 CSS - Esc / 失焦 / 30s 超时自动 dismiss(Pin 时跳过) - 监听 qa:state + qa:dismiss 后端事件 - App.tsx 路由:?window=qa → QaPanel - types.ts:QaHotkeyBinding + UserPreferences.qaHotkey/qaSaveHistory 以及 QaStatePayload(snake_case 字段对齐 Rust) - ipc.ts:getQaHotkeyLabel / setQaHotkey / qaWindowDismiss / qaWindowPin 以及 mockSettings 默认 cmd+shift+; - Settings 录音区新增「提问模式快捷键」下拉(4 预设 + 不启用) 与「保存 Q&A 历史」开关 - i18n:zh-CN + en 同步新增 qa.* 与 settings.recording.qa* 文案 --- openless-all/app/package-lock.json | 17 +- openless-all/app/package.json | 1 + openless-all/app/src-tauri/tauri.conf.json | 17 + openless-all/app/src/App.tsx | 7 +- openless-all/app/src/i18n/en.ts | 14 + openless-all/app/src/i18n/zh-CN.ts | 14 + openless-all/app/src/lib/ipc.ts | 22 ++ openless-all/app/src/lib/types.ts | 26 ++ openless-all/app/src/main.tsx | 6 +- openless-all/app/src/pages/QaPanel.tsx | 404 +++++++++++++++++++++ openless-all/app/src/pages/Settings.tsx | 78 ++++ 11 files changed, 601 insertions(+), 5 deletions(-) create mode 100644 openless-all/app/src/pages/QaPanel.tsx diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index a185d183..15112f79 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,17 +1,18 @@ { "name": "openless-app", - "version": "1.2.7", + "version": "1.2.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "1.2.7", + "version": "1.2.8", "dependencies": { "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-shell": "^2.0.1", "@tauri-apps/plugin-updater": "^2.10.1", "i18next": "^26.0.8", + "marked": "^11.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^17.0.6" @@ -1732,6 +1733,18 @@ "yallist": "^3.0.2" } }, + "node_modules/marked": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-11.2.0.tgz", + "integrity": "sha512-HR0m3bvu0jAPYiIvLUUQtdg1g6D247//lvcekpHO1WMvbwDlwSkZAX9Lw4F4YHE1T0HaaNve0tuAWuV1UJ6vtw==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 855feea3..4843f17f 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -15,6 +15,7 @@ "@tauri-apps/plugin-shell": "^2.0.1", "@tauri-apps/plugin-updater": "^2.10.1", "i18next": "^26.0.8", + "marked": "^11.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^17.0.6" diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 2dc524fb..d188dadd 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -45,6 +45,23 @@ "visible": false, "center": false, "acceptFirstMouse": true + }, + { + "label": "qa", + "url": "index.html?window=qa", + "title": "OpenLess QA", + "width": 380, + "height": 280, + "decorations": false, + "transparent": true, + "shadow": true, + "alwaysOnTop": true, + "skipTaskbar": true, + "resizable": false, + "focus": false, + "visible": false, + "center": false, + "acceptFirstMouse": true } ], "security": { diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index eeb9d697..01792c0a 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -9,18 +9,23 @@ import { handleWindowHotkeyEvent, isTauri, } from './lib/ipc'; +import { QaPanel } from './pages/QaPanel'; import { HotkeySettingsProvider } from './state/HotkeySettingsContext'; interface AppProps { isCapsule: boolean; + isQa: boolean; } type Gate = 'checking' | 'onboarding' | 'ready'; -export function App({ isCapsule }: AppProps) { +export function App({ isCapsule, isQa }: AppProps) { if (isCapsule) { return ; } + if (isQa) { + return ; + } const os = detectOS(); // Windows 启动不应被权限探测阻塞首屏。 diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index fa335452..80e92eb0 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -34,6 +34,15 @@ export const en: typeof zhCN = { inserted: 'Inserted {{count}}', translating: 'Translating', }, + qa: { + thinking: 'Thinking…', + error: 'Something went wrong. Please try again.', + errorRetry: 'Retry', + pinTooltip: 'Pin (stay open)', + unpinTooltip: 'Unpin', + closeTooltip: 'Close', + selectionPreview: 'From selected text:', + }, nav: { overview: 'Overview', history: 'History', @@ -219,6 +228,11 @@ export const en: typeof zhCN = { capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording / transcribing.', restoreClipboardLabel: 'Restore clipboard after insert', restoreClipboardDesc: 'Windows / Linux only: restore your original clipboard after a successful paste (default on). Turn off to keep the dictation text in the clipboard so you can manually Ctrl+V if the simulated paste did not actually land. See issue #111.', + qaHotkeyLabel: 'Question-mode hotkey', + qaHotkeyDesc: 'Select some text, press this hotkey to open the QA panel and start recording; press again to stop and get the answer. See issue #118.', + qaHotkeyOptionDisabled: 'Disabled', + qaSaveHistoryLabel: 'Save Q&A history', + qaSaveHistoryDesc: 'Persist each question and answer to local storage. Turn off to leave no trace.', }, providers: { llmTitle: 'LLM (polishing)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index cf98f614..051066af 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -32,6 +32,15 @@ export const zhCN = { inserted: '已插入 {{count}}', translating: '正在翻译', }, + qa: { + thinking: '思考中…', + error: '出错了,请稍后再试。', + errorRetry: '重试', + pinTooltip: '固定(不自动关闭)', + unpinTooltip: '取消固定', + closeTooltip: '关闭', + selectionPreview: '基于选中文本:', + }, nav: { overview: '概览', history: '历史', @@ -217,6 +226,11 @@ export const zhCN = { capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。', restoreClipboardLabel: '插入后恢复剪贴板', restoreClipboardDesc: '仅 Windows / Linux:粘贴成功后恢复你原来的剪贴板内容(默认开)。关掉就把听写文本留在剪贴板,模拟粘贴没真正落地时可以手动 Ctrl+V 找回。详见 issue #111。', + qaHotkeyLabel: '提问模式快捷键', + qaHotkeyDesc: '选中一段文字后按下此快捷键,弹出问答浮窗并开始录音;再按一次结束录音、生成答案。详见 issue #118。', + qaHotkeyOptionDisabled: '不启用', + qaSaveHistoryLabel: '保存 Q&A 历史', + qaSaveHistoryDesc: '把每次划词问答的问题与答案写入本地存档。关闭则不保留任何痕迹。', }, providers: { llmTitle: 'LLM 模型(润色)', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index a1cabd6b..2e235fe3 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -10,6 +10,7 @@ import type { HotkeyStatus, PermissionStatus, PolishMode, + QaHotkeyBinding, UserPreferences, } from './types'; import { OL_DATA } from './mockData'; @@ -46,6 +47,8 @@ const mockSettings: UserPreferences = { restoreClipboardAfterPaste: true, workingLanguages: ['简体中文'], translationTargetLanguage: '', + qaHotkey: { primary: ';', modifiers: ['cmd', 'shift'] }, + qaSaveHistory: false, }; const mockHotkeyCapability: HotkeyCapability = { @@ -236,6 +239,25 @@ export function restartApp(): Promise { return invokeOrMock('restart_app', undefined, () => undefined); } +// ── QA (划词语音问答) ─────────────────────────────────────────────────── +// 详见 issue #118。后端会发 `qa:state` / `qa:dismiss` 事件;前端通过下面四个 +// 命令查询与控制 QA 浮窗。 +export function getQaHotkeyLabel(): Promise { + return invokeOrMock('get_qa_hotkey_label', undefined, () => 'Cmd+Shift+;'); +} + +export function setQaHotkey(binding: QaHotkeyBinding): Promise { + return invokeOrMock('set_qa_hotkey', { binding }, () => undefined); +} + +export function qaWindowDismiss(): Promise { + return invokeOrMock('qa_window_dismiss', undefined, () => undefined); +} + +export function qaWindowPin(pinned: boolean): Promise { + return invokeOrMock('qa_window_pin', { pinned }, () => undefined); +} + export async function openExternal(url: string): Promise { if (!isTauri) { window.open(url, '_blank', 'noopener,noreferrer'); diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 8f350564..a8229594 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -71,6 +71,14 @@ export interface HotkeyStatus { lastError: HotkeyInstallError | null; } +/** 划词语音问答快捷键绑定。null 表示未启用。详见 issue #118。 */ +export interface QaHotkeyBinding { + /** 主键(去掉所有修饰符的字面字符),例如 ";" / "/" / "a" */ + primary: string; + /** 修饰符列表,元素小写:"cmd" | "shift" | "option" | "ctrl"。 */ + modifiers: string[]; +} + export interface UserPreferences { hotkey: HotkeyBinding; defaultMode: PolishMode; @@ -85,6 +93,24 @@ export interface UserPreferences { workingLanguages: string[]; /** 翻译模式目标语言(单选,原生名);空串 = 不启用 Shift 翻译。详见 issue #4。 */ translationTargetLanguage: string; + /** 划词语音问答快捷键。null = 未启用。详见 issue #118。 */ + qaHotkey: QaHotkeyBinding | null; + /** 是否把 Q&A 历史写到本地存档。详见 issue #118。 */ + qaSaveHistory: boolean; +} + +/** Rust 通过 `qa:state` 事件下发的 payload。 + * 与 issue #118 的契约一致——字段名采用 snake_case,与后端 JSON 直接对齐。 */ +export type QaStateKind = 'loading' | 'answer' | 'error'; + +export interface QaStatePayload { + kind: QaStateKind; + /** loading 状态下的选区前缀(前 60 字,已截断)。 */ + selection_preview?: string; + /** answer 状态下的 markdown 字符串。 */ + answer_md?: string; + /** error 状态下的错误提示。 */ + error?: string; } /** 内置语言列表 — 前端 Settings UI 用,后端只接收原生名字符串拼 prompt。 diff --git a/openless-all/app/src/main.tsx b/openless-all/app/src/main.tsx index 3a946c9b..0c592ec5 100644 --- a/openless-all/app/src/main.tsx +++ b/openless-all/app/src/main.tsx @@ -6,14 +6,16 @@ import "./styles/tokens.css"; import "./styles/global.css"; const params = new URLSearchParams(window.location.search); -const isCapsule = params.get("window") === "capsule"; +const windowKind = params.get("window"); +const isCapsule = windowKind === "capsule"; +const isQa = windowKind === "qa"; const root = ReactDOM.createRoot(document.getElementById("root")!); const renderApp = () => { root.render( - + , ); }; diff --git a/openless-all/app/src/pages/QaPanel.tsx b/openless-all/app/src/pages/QaPanel.tsx new file mode 100644 index 00000000..e3326893 --- /dev/null +++ b/openless-all/app/src/pages/QaPanel.tsx @@ -0,0 +1,404 @@ +// QaPanel.tsx — 划词语音问答浮窗。详见 issue #118。 +// +// 触发链路: +// 1) 用户选中文本 → 按 Cmd+Shift+;(默认)→ 后端打开本窗口(label="qa") +// 并发 `qa:state { kind: "loading", selection_preview }` 事件,开始录音。 +// 2) 用户提问完毕,再次按热键 → 后端转写 + LLM 回答 → 发 +// `qa:state { kind: "answer", answer_md }`,本组件用 marked 渲染。 +// 3) 出错 → `qa:state { kind: "error", error }`,显示红色文案 + 重试按钮。 +// +// 关闭时机(任一): +// - Esc / Close 按钮 / 点击窗口外(除非 Pin)→ qa_window_dismiss() +// - 30s 超时(除非 Pin)→ qa_window_dismiss() +// - 后端发 `qa:dismiss` 事件 → 直接关窗 + +import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; +import { useTranslation } from 'react-i18next'; +import { marked } from 'marked'; +import { isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc'; +import type { QaStatePayload } from '../lib/types'; + +const AUTO_DISMISS_MS = 30_000; +const SELECTION_PREVIEW_MAX = 60; + +// marked 配置:开启换行符识别,关闭 mangle/headerIds(v11 已默认关闭)。 +marked.setOptions({ gfm: true, breaks: true }); + +export function QaPanel() { + const { t } = useTranslation(); + const [payload, setPayload] = useState({ kind: 'loading' }); + const [pinned, setPinned] = useState(false); + const pinnedRef = useRef(false); + + // ── 后端事件订阅 ──────────────────────────────────────────────────── + useEffect(() => { + if (!isTauri) return; + let unlistenState: (() => void) | undefined; + let unlistenDismiss: (() => void) | undefined; + let cancelled = false; + (async () => { + const { listen } = await import('@tauri-apps/api/event'); + const stateHandle = await listen('qa:state', event => { + setPayload(event.payload); + }); + const dismissHandle = await listen('qa:dismiss', () => { + // 后端要求关闭:直接转发到 dismiss 命令;同时 reset pin 状态 + // 让用户下次开新窗口拿到默认 unpinned。 + pinnedRef.current = false; + setPinned(false); + void qaWindowDismiss(); + }); + if (cancelled) { + stateHandle(); + dismissHandle(); + } else { + unlistenState = stateHandle; + unlistenDismiss = dismissHandle; + } + })(); + return () => { + cancelled = true; + unlistenState?.(); + unlistenDismiss?.(); + }; + }, []); + + // ── Esc 关闭 ──────────────────────────────────────────────────────── + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + void qaWindowDismiss(); + } + }; + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); + }, []); + + // ── 失焦自动关闭(除非 Pin)──────────────────────────────────────── + useEffect(() => { + const onBlur = () => { + if (pinnedRef.current) return; + void qaWindowDismiss(); + }; + window.addEventListener('blur', onBlur); + return () => window.removeEventListener('blur', onBlur); + }, []); + + // ── 30s 自动关闭(除非 Pin),payload 变化或 pin 切换时重置 ────── + useEffect(() => { + if (pinned) return; + const timer = window.setTimeout(() => { + if (!pinnedRef.current) void qaWindowDismiss(); + }, AUTO_DISMISS_MS); + return () => window.clearTimeout(timer); + }, [payload, pinned]); + + const onTogglePin = () => { + const next = !pinned; + pinnedRef.current = next; + setPinned(next); + void qaWindowPin(next); + }; + + const onClose = () => { + void qaWindowDismiss(); + }; + + return ( +
+ +
+ +
+
+ ); +} + +// ── 子组件 ──────────────────────────────────────────────────────────── + +interface ToolbarProps { + pinned: boolean; + onTogglePin: () => void; + onClose: () => void; +} + +function Toolbar({ pinned, onTogglePin, onClose }: ToolbarProps) { + const { t } = useTranslation(); + return ( +
+
+ + {/* Pin 图标 */} + + + + + + + + + +
+ ); +} + +interface IconBtnProps { + label: string; + active?: boolean; + onClick: () => void; + children: React.ReactNode; +} + +function IconBtn({ label, active, onClick, children }: IconBtnProps) { + return ( + + ); +} + +interface BodyProps { + payload: QaStatePayload; + t: ReturnType['t']; +} + +function Body({ payload, t }: BodyProps) { + if (payload.kind === 'loading') { + return ; + } + if (payload.kind === 'error') { + return ; + } + return ; +} + +function LoadingView({ preview, t }: { preview: string | undefined; t: BodyProps['t'] }) { + const truncated = useMemo(() => truncate(preview ?? '', SELECTION_PREVIEW_MAX), [preview]); + return ( +
+ {truncated && ( +
+ + {t('qa.selectionPreview')} + + {truncated} +
+ )} +
+ + + +
+
+ {t('qa.thinking')} +
+
+ ); +} + +function SkeletonLine({ width }: { width: string }) { + return ( +
+ ); +} + +function ErrorView({ message, t }: { message: string; t: BodyProps['t'] }) { + // 重试按钮:关掉浮窗,让用户重按 hotkey。详见 issue #118。 + const onRetry = () => { + void qaWindowDismiss(); + }; + return ( +
+
{message}
+ +
+ ); +} + +function AnswerView({ markdown }: { markdown: string }) { + // marked v11 同步调用返回 string;启用 GFM + breaks。 + // 注意:markdown 来自我们自己的后端 → LLM,链路可信,未额外 sanitize。 + // 如未来引入用户自由文本拼装到 prompt,需要补 DOMPurify。 + const html = useMemo(() => { + try { + return marked.parse(markdown, { async: false }) as string; + } catch (error) { + console.error('[qa] failed to render markdown', error); + return ''; + } + }, [markdown]); + return ( +
+ ); +} + +function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return `${text.slice(0, max)}…`; +} + +// ── 样式 ────────────────────────────────────────────────────────────── + +const shellStyle: CSSProperties = { + width: '100%', + height: '100vh', + display: 'flex', + flexDirection: 'column', + borderRadius: 14, + overflow: 'hidden', + background: 'rgba(255, 255, 255, 0.85)', + backdropFilter: 'blur(24px) saturate(180%)', + WebkitBackdropFilter: 'blur(24px) saturate(180%)', + border: '0.5px solid rgba(255, 255, 255, 0.7)', + boxShadow: 'var(--ol-shadow-lg)', + fontFamily: 'var(--ol-font-sans)', + color: 'var(--ol-ink)', +}; + +const toolbarStyle: CSSProperties = { + height: 32, + display: 'flex', + alignItems: 'center', + gap: 4, + padding: '0 8px', + borderBottom: '0.5px solid rgba(0, 0, 0, 0.06)', + flexShrink: 0, + // 让用户可以拖动整个浮窗(macOS / Win 通用)。 + // @ts-expect-error: vendor prefix not in CSSProperties typing + WebkitAppRegion: 'drag', +}; + +const iconBtnBaseStyle: CSSProperties = { + width: 22, + height: 22, + border: 0, + borderRadius: 6, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'default', + padding: 0, + transition: 'background 0.12s ease-out, color 0.12s ease-out', + // @ts-expect-error: vendor prefix not in CSSProperties typing + WebkitAppRegion: 'no-drag', +}; + +const contentStyle: CSSProperties = { + flex: 1, + minHeight: 0, + overflow: 'auto', + padding: '14px 18px', +}; + +const previewStyle: CSSProperties = { + fontSize: 11.5, + lineHeight: 1.5, + padding: '8px 10px', + borderRadius: 8, + background: 'rgba(0, 0, 0, 0.035)', + border: '0.5px solid rgba(0, 0, 0, 0.06)', +}; + +const retryBtnStyle: CSSProperties = { + alignSelf: 'flex-start', + padding: '5px 12px', + fontSize: 12, + fontWeight: 500, + border: '0.5px solid var(--ol-line-strong)', + borderRadius: 6, + background: 'var(--ol-surface)', + color: 'var(--ol-ink-2)', + cursor: 'default', + fontFamily: 'inherit', +}; + +const answerStyle: CSSProperties = { + fontSize: 13, + lineHeight: 1.6, + color: 'var(--ol-ink)', + wordWrap: 'break-word', +}; + +// 注入全局 keyframes + .qa-answer 内 markdown 排版样式。 +// 不放 styles/global.css 是因为只有这个窗口需要。 +const globalCss = ` +@keyframes qa-skeleton { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} +.qa-answer p { margin: 0 0 8px; } +.qa-answer p:last-child { margin-bottom: 0; } +.qa-answer h1, +.qa-answer h2, +.qa-answer h3 { margin: 12px 0 6px; font-weight: 600; line-height: 1.35; } +.qa-answer h1 { font-size: 16px; } +.qa-answer h2 { font-size: 14px; } +.qa-answer h3 { font-size: 13px; } +.qa-answer ul, +.qa-answer ol { margin: 0 0 8px; padding-left: 20px; } +.qa-answer li { margin: 2px 0; } +.qa-answer code { font-family: var(--ol-font-mono); font-size: 12px; + padding: 1px 5px; border-radius: 4px; + background: rgba(0,0,0,0.05); } +.qa-answer pre { margin: 0 0 8px; padding: 10px 12px; + border-radius: 8px; background: rgba(0,0,0,0.05); + overflow-x: auto; } +.qa-answer pre code { padding: 0; background: transparent; } +.qa-answer a { color: var(--ol-blue); text-decoration: none; } +.qa-answer a:hover { text-decoration: underline; } +.qa-answer blockquote { margin: 0 0 8px; padding: 4px 0 4px 10px; + border-left: 2px solid rgba(0,0,0,0.15); + color: var(--ol-ink-3); } +.qa-answer hr { border: 0; border-top: 0.5px solid rgba(0,0,0,0.10); + margin: 10px 0; } +`; + +// 单次注入。重复挂载(HMR)时会被同 id 替换。 +if (typeof document !== 'undefined' && !document.getElementById('qa-panel-style')) { + const tag = document.createElement('style'); + tag.id = 'qa-panel-style'; + tag.textContent = globalCss; + document.head.appendChild(tag); +} diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 08f70b28..3ece3050 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -21,6 +21,7 @@ import { setActiveAsrProvider, setActiveLlmProvider, setCredential, + setQaHotkey, } from '../lib/ipc'; import type { HotkeyCapability, @@ -28,6 +29,7 @@ import type { HotkeyStatus, HotkeyTrigger, PermissionStatus, + QaHotkeyBinding, } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import i18n, { @@ -132,6 +134,37 @@ function SettingRow({ label, desc, children }: SettingRowProps) { ); } +// QA 提问模式快捷键预设。详见 issue #118。 +// id="disabled" 是哨兵 — savePrefs({ qaHotkey: null }) 表示禁用。 +const QA_HOTKEY_DISABLED_ID = 'disabled' as const; + +interface QaHotkeyPreset { + id: string; + binding: QaHotkeyBinding; + /** UI 上展示的字面值;后端的 get_qa_hotkey_label 仅用作冗余提示,不必一致。 */ + label: string; +} + +const QA_HOTKEY_PRESETS: readonly QaHotkeyPreset[] = [ + { id: 'cmd+shift+;', label: 'Cmd+Shift+;', binding: { primary: ';', modifiers: ['cmd', 'shift'] } }, + { id: 'cmd+option+;', label: 'Cmd+Option+;', binding: { primary: ';', modifiers: ['cmd', 'option'] } }, + { id: 'cmd+shift+/', label: 'Cmd+Shift+/', binding: { primary: '/', modifiers: ['cmd', 'shift'] } }, + { id: 'cmd+option+/', label: 'Cmd+Option+/', binding: { primary: '/', modifiers: ['cmd', 'option'] } }, +] as const; + +function bindingToPresetId(binding: QaHotkeyBinding | null): string { + if (!binding) return QA_HOTKEY_DISABLED_ID; + const sortedMods = [...binding.modifiers].map(m => m.toLowerCase()).sort(); + const match = QA_HOTKEY_PRESETS.find(p => { + const pMods = [...p.binding.modifiers].sort(); + return p.binding.primary === binding.primary + && pMods.length === sortedMods.length + && pMods.every((m, i) => m === sortedMods[i]); + }); + // 后端如果当前绑定是非预设值,UI 兜底回到默认 cmd+shift+; + return match ? match.id : QA_HOTKEY_PRESETS[0].id; +} + function RecordingSection() { const { t } = useTranslation(); const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings(); @@ -152,6 +185,24 @@ function RecordingSection() { savePrefs({ ...prefs, showCapsule }); const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) => savePrefs({ ...prefs, restoreClipboardAfterPaste }); + const onQaHotkeyChange = async (id: string) => { + if (id === QA_HOTKEY_DISABLED_ID) { + await savePrefs({ ...prefs, qaHotkey: null }); + return; + } + const preset = QA_HOTKEY_PRESETS.find(p => p.id === id); + if (!preset) return; + // 同步两条路:set_qa_hotkey 让后端立刻重装 hotkey 监听; + // savePrefs 让 UserPreferences 落盘。两个 IPC 由后端决定能否合并。 + try { + await setQaHotkey(preset.binding); + } catch (error) { + console.error('[settings] failed to set qa hotkey', error); + } + await savePrefs({ ...prefs, qaHotkey: preset.binding }); + }; + const onQaSaveHistoryChange = (qaSaveHistory: boolean) => + savePrefs({ ...prefs, qaSaveHistory }); const choices: Array<[HotkeyMode, string]> = [ ['toggle', t('settings.recording.modeToggle')], @@ -229,6 +280,33 @@ function RecordingSection() { > + + + + + + {capability.statusHint && (
{capability.statusHint} From e68b29d5a37e0c5e4518b054f9f5f5670b1c77c9 Mon Sep 17 00:00:00 2001 From: baiqing Date: Fri, 1 May 2026 13:17:09 +0800 Subject: [PATCH 3/5] =?UTF-8?q?chore(qa):=20=E9=9B=86=E6=88=90=20=E2=80=94?= =?UTF-8?q?=20=E5=AF=B9=E9=BD=90=20Rust=E2=86=94TS=20=E4=BA=8B=E4=BB=B6=20?= =?UTF-8?q?payload=20=E5=AD=97=E6=AE=B5=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前后端 agent 各自实现完整路径后联调出 4 处契约不齐: 1. selectionPreview → selection_preview(snake_case 对齐 issue #118 spec) 2. answer payload `text` → `answer_md`,去掉前端用不到的 question 字段 3. error payload `message` → `error` 4. recording / transcribing / thinking 三个进度态合并为前端能识别的 `loading`,避免前端 fall-through 渲染成空 AnswerView 外加 QaPanel 收到 idle 时直接 return,不替换 pinned 用户正在读的 answer (idle 语义是"会话状态机回到 Idle",不 pin 时后端自己 hide 窗口)。 cargo check + npm run build 全绿,16 warnings 全是 pre-existing。 --- openless-all/app/src-tauri/src/coordinator.rs | 15 +++++++-------- openless-all/app/src/pages/QaPanel.tsx | 5 +++++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index f73862c8..1f7a08b0 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1485,7 +1485,7 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { "qa:state", serde_json::json!({ "kind": "loading", - "selectionPreview": selection_preview_text.clone(), + "selection_preview": selection_preview_text.clone(), }), ); } @@ -1587,8 +1587,8 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { "qa", "qa:state", serde_json::json!({ - "kind": "recording", - "selectionPreview": selection_preview_text, + "kind": "loading", + "selection_preview": selection_preview_text, }), ); } @@ -1609,7 +1609,7 @@ async fn end_qa_session(inner: &Arc) -> Result<(), String> { let _ = app.emit_to( "qa", "qa:state", - serde_json::json!({ "kind": "transcribing" }), + serde_json::json!({ "kind": "loading" }), ); } @@ -1657,7 +1657,7 @@ async fn end_qa_session(inner: &Arc) -> Result<(), String> { "qa", "qa:state", serde_json::json!({ - "kind": "thinking", + "kind": "loading", "question": question, }), ); @@ -1703,8 +1703,7 @@ async fn end_qa_session(inner: &Arc) -> Result<(), String> { "qa:state", serde_json::json!({ "kind": "answer", - "text": answer, - "question": question, + "answer_md": answer, }), ); } @@ -1744,7 +1743,7 @@ fn finish_qa_with_error(inner: &Arc, message: String) { "qa:state", serde_json::json!({ "kind": "error", - "message": message, + "error": message, }), ); } diff --git a/openless-all/app/src/pages/QaPanel.tsx b/openless-all/app/src/pages/QaPanel.tsx index e3326893..6a677a93 100644 --- a/openless-all/app/src/pages/QaPanel.tsx +++ b/openless-all/app/src/pages/QaPanel.tsx @@ -39,6 +39,11 @@ export function QaPanel() { (async () => { const { listen } = await import('@tauri-apps/api/event'); const stateHandle = await listen('qa:state', event => { + // 后端在 session 结束(含 cancel / 静默 / 完成)时会再发一条 kind:"idle"。 + // 它的语义是"会话状态机回到 Idle",**不**应替换 UI(pinned 用户希望继续看 answer)。 + // 不 pinned 时后端紧接着自己 hide 窗口,前端拿到 idle 也无妨。 + const kind = (event.payload as { kind?: string }).kind; + if (kind === 'idle') return; setPayload(event.payload); }); const dismissHandle = await listen('qa:dismiss', () => { From 78ff8113ebad28de44f29a9cbaed0200b63b426e Mon Sep 17 00:00:00 2001 From: baiqing Date: Fri, 1 May 2026 13:37:13 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat(qa):=20=E6=8B=86=E5=87=BA"=E5=88=92?= =?UTF-8?q?=E8=AF=8D=E8=BF=BD=E9=97=AE"=E7=8B=AC=E7=AB=8B=20tab=20+=20?= =?UTF-8?q?=E4=BF=AE=20Windows=20CI=20Send=20=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI 拆分(用户面) - 新一级 tab "划词追问"(NAV 在"翻译"后面),icon 是 stroke 文本+问号 - 新页 src/pages/SelectionAsk.tsx:3 个 Card——触发快捷键预设(含 Cmd+Option chord 选项 + 4 个三键预设 + 不启用),保存历史 toggle,使用方法 5 步说明 + 浮窗位置/生命周期块 + 隐私契约块 - Settings → 录音 删去 qaHotkey + qaSaveHistory 两行(已搬到新 tab) - i18n 加 nav.selectionAsk + 完整 selectionAsk.* 命名空间(zh+en), 删 settings.recording.qa{Hotkey,SaveHistory}* 旧 keys - useAppState.ts AppTab 加 'selectionAsk' Windows CI fix(PR #119 失败原因) - qa_hotkey.rs::Inner 含 GlobalHotKeyManager → Windows 上含 HHOOK 这种 *mut c_void 不实现 Send/Sync,导致 coordinator.rs:1998 的 async_runtime::spawn 捕获 Arc 时 Windows 编译失败(macOS 编译过) - 加 unsafe impl Send + Sync for Inner,与现有 hotkey.rs::CallbackContext 同款 做法(OS 句柄实际跨线程安全,crate 没标) 不动后端 hotkey 实现 Cmd+Option chord 选项 UI 已暴露但底层 global-hotkey crate 不支持 modifier-only chord(需要 CGEventTap 自己写状态机),UI 上加 ⚠️ 警告说明 "需要 v1.2.9+ 才支持"。当前默认仍为 Cmd+Shift+;。 --- openless-all/app/src-tauri/src/qa_hotkey.rs | 9 + .../app/src/components/FloatingShell.tsx | 2 + openless-all/app/src/components/Icon.tsx | 1 + openless-all/app/src/i18n/en.ts | 35 ++- openless-all/app/src/i18n/zh-CN.ts | 35 ++- openless-all/app/src/pages/SelectionAsk.tsx | 238 ++++++++++++++++++ openless-all/app/src/pages/Settings.tsx | 78 ------ openless-all/app/src/state/useAppState.ts | 2 +- 8 files changed, 311 insertions(+), 89 deletions(-) create mode 100644 openless-all/app/src/pages/SelectionAsk.tsx diff --git a/openless-all/app/src-tauri/src/qa_hotkey.rs b/openless-all/app/src-tauri/src/qa_hotkey.rs index e0baa571..d3186cb8 100644 --- a/openless-all/app/src-tauri/src/qa_hotkey.rs +++ b/openless-all/app/src-tauri/src/qa_hotkey.rs @@ -53,6 +53,15 @@ struct Inner { active_id: Arc, } +// global-hotkey 0.6 的 GlobalHotKeyManager 在 Windows 内部持有 HHOOK / window +// handle 等 `*mut c_void`,crate 没标 Send/Sync。但这些句柄实际是 OS 进程级 +// 资源,跨线程读写是 OS 自己同步的;coordinator.rs 又需要把 `Arc`(间接含 +// QaHotkeyMonitor)放进 async_runtime::spawn 里,强制要求 Send。手动标记。 +// macOS 上 GlobalHotKeyManager 内部用 Carbon EventHotKey,同理。 +// 与 hotkey.rs::CallbackContext 已有的 unsafe impl Send/Sync 同款做法。 +unsafe impl Send for Inner {} +unsafe impl Sync for Inner {} + impl QaHotkeyMonitor { /// 启动监听并注册一个 hotkey。`tx` 在每次按下边沿收到 `QaHotkeyEvent::Pressed`。 /// diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 8186fa6f..3f93aa6c 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -15,6 +15,7 @@ import { History } from '../pages/History'; import { Vocab } from '../pages/Vocab'; import { Style } from '../pages/Style'; import { Translation } from '../pages/Translation'; +import { SelectionAsk } from '../pages/SelectionAsk'; import { APP_VERSION_LABEL } from '../lib/appVersion'; import { HOTKEY_MODE_MIGRATION_ACK_KEY, @@ -46,6 +47,7 @@ const NAV_BASE: Array> = [ { id: 'vocab', icon: 'vocab', cmp: Vocab }, { id: 'style', icon: 'style', cmp: Style }, { id: 'translation', icon: 'translate', cmp: Translation }, + { id: 'selectionAsk', icon: 'selectionAsk', cmp: SelectionAsk }, ]; const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases'; diff --git a/openless-all/app/src/components/Icon.tsx b/openless-all/app/src/components/Icon.tsx index 29caa1ae..2c1c6395 100644 --- a/openless-all/app/src/components/Icon.tsx +++ b/openless-all/app/src/components/Icon.tsx @@ -9,6 +9,7 @@ export const ICONS: Record = { vocab: 'M5 4h11a2 2 0 0 1 2 2v13l-3-2-3 2-3-2-3 2V6a2 2 0 0 1 2-2zM8 9h7M8 13h5', style: 'M12 3a9 9 0 1 0 0 18 3 3 0 0 0 3-3v-1a2 2 0 0 1 2-2h1a3 3 0 0 0 3-3 9 9 0 0 0-9-9z', translate:'M3 5h7M6 4v2M5 8c0 4 3 6 5 6M9 8c0 4-3 6-5 6M13 20l4-10 4 10M14.5 17h5', + selectionAsk:'M4 6h11M4 10h11M4 14h7M17 14a3 3 0 1 1 3 3v1M20 22h.01', settings:'M12 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1A2 2 0 1 1 7 4.9l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z', help: 'M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z', mic: 'M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3zM19 11a7 7 0 0 1-14 0M12 18v3M8 21h8', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 80e92eb0..9039340c 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -49,6 +49,7 @@ export const en: typeof zhCN = { vocab: 'Vocabulary', style: 'Style', translation: 'Translation', + selectionAsk: 'Ask', }, shell: { shortcutLabel: 'Recording shortcut', @@ -200,6 +201,35 @@ export const en: typeof zhCN = { fallbackDesc: 'When the target is "Disabled", Shift is a no-op. If the LLM call fails mid-translation, the raw transcript is inserted directly so nothing gets lost. See issue #4.', }, }, + selectionAsk: { + kicker: 'SELECTION ASK', + title: 'Selection Ask', + desc: 'Select text in any app, press the hotkey, ask a question by voice, and the AI answer appears in a floating panel above the recording capsule.', + statusEnabled: 'Enabled', + statusDisabled: 'Disabled', + hotkey: { + title: 'Trigger hotkey', + desc: 'When set, selecting text in any app and pressing this combo opens a panel and starts recording. Press again to stop and generate the answer. Pick "Disabled" to turn the feature off.', + optionDisabled: 'Disabled', + chordWarning: '⚠️ Modifier-only chord (Cmd+Option / Ctrl+Alt): both modifiers held without any other key, then released. Fewer keys, but higher false-trigger rate than 3-key combos. Chord backend requires v1.2.9+; older builds, please pick a 3-key combo.', + }, + history: { + title: 'Save history', + desc: 'When on, every selection + voice question + AI answer is persisted locally (never uploaded). Off by default — closing the panel forgets the exchange entirely, more private.', + }, + howto: { + title: 'How to use', + step1: 'Select text in any app (browser, Mail, IDE, PDF reader…).', + step2: 'Press「{{hotkey}}」— the panel appears at the bottom of the screen and recording starts.', + step3: 'Ask your question into the mic — e.g. "what does this mean", "explain in plain words", "how is it different from X".', + step4: 'Press「{{hotkey}}」again to stop. The system sends your selection + voice question to the LLM.', + step5: 'The answer renders as markdown in the panel. Press Esc or click outside to dismiss; pin (📌, top-right) to keep it open until manually closed.', + windowTitle: 'Panel position + lifecycle', + windowDesc: 'The panel sits 8px above the recording capsule by default, sized 380×280. Auto-closes after 30s of inactivity (unless pinned). It scrolls if the answer is long.', + privacyTitle: 'Privacy contract', + privacyDesc: 'Selected text lives only in memory until the panel closes — it is **never** written to the history archive (Save history toggles only Q&A metadata). Selections over 4000 chars are truncated to head+tail of 2000 each before being sent. LLM calls go through your configured ARK / DeepSeek / OpenAI-compatible endpoint.', + }, + }, settings: { kicker: 'SETTINGS', title: 'Settings', @@ -228,11 +258,6 @@ export const en: typeof zhCN = { capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording / transcribing.', restoreClipboardLabel: 'Restore clipboard after insert', restoreClipboardDesc: 'Windows / Linux only: restore your original clipboard after a successful paste (default on). Turn off to keep the dictation text in the clipboard so you can manually Ctrl+V if the simulated paste did not actually land. See issue #111.', - qaHotkeyLabel: 'Question-mode hotkey', - qaHotkeyDesc: 'Select some text, press this hotkey to open the QA panel and start recording; press again to stop and get the answer. See issue #118.', - qaHotkeyOptionDisabled: 'Disabled', - qaSaveHistoryLabel: 'Save Q&A history', - qaSaveHistoryDesc: 'Persist each question and answer to local storage. Turn off to leave no trace.', }, providers: { llmTitle: 'LLM (polishing)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 051066af..fa5efc96 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -47,6 +47,7 @@ export const zhCN = { vocab: '词汇表', style: '风格', translation: '翻译', + selectionAsk: '划词追问', }, shell: { shortcutLabel: '录音快捷键', @@ -198,6 +199,35 @@ export const zhCN = { fallbackDesc: '翻译模式选「不启用」时 Shift 是没作用的;翻译过程中如果大模型调用失败,会回退到把原始中文转写直接插入,不会丢字。详见 issue #4。', }, }, + selectionAsk: { + kicker: 'SELECTION ASK', + title: '划词追问', + desc: '选中任意 app 里的一段文字,按下快捷键,对着麦克风口头提问,AI 答案会以浮窗显示在屏幕底部录音胶囊正上方。', + statusEnabled: '已启用', + statusDisabled: '未启用', + hotkey: { + title: '触发快捷键', + desc: '选了某组按键后,在任意 app 选中文字时按下,浮窗即可弹出并开始录音;再按一次停止录音并生成答案。选「不启用」则关闭整个功能。', + optionDisabled: '不启用', + chordWarning: '⚠️ 双修饰键 chord(Cmd+Option / Ctrl+Alt)触发条件:两个修饰键同时按下、不按其他键、再松开两个键。优点是按键少;缺点是误触率比 3 键组合高(你想按 Cmd+Option+某字母 但还没按字母时容易触发)。后端 chord 监听需要 v1.2.9+ 才支持,更早版本请选 3 键组合。', + }, + history: { + title: '保存历史', + desc: '勾上则把每次追问的「选中文本 + 你的语音问题 + AI 答案」写入本地存档(不上云)。默认关,关闭时浮窗一关问答即遗忘,更注重隐私。', + }, + howto: { + title: '使用方法', + step1: '在任意 app(浏览器、Mail、IDE、PDF reader…)选中一段文字。', + step2: '按下「{{hotkey}}」——浮窗在屏幕底部弹出,同时进入语音录音状态。', + step3: '对着麦克风提问——比如"这是什么意思 / 用大白话解释 / 跟 X 有什么区别 / 把它拆成几个要点"。', + step4: '再按一次「{{hotkey}}」停止录音。系统会把「你选中的文字 + 你的语音问题」一起送给大模型生成答案。', + step5: '答案以 markdown 格式显示在浮窗里。读完按 Esc 或点击外部即可关闭;右上角 📌 钉住则会一直保留直到你手动关。', + windowTitle: '浮窗位置 + 生命周期', + windowDesc: '浮窗默认在录音胶囊正上方 8px 处,宽 380×高 280。30 秒未操作自动关闭(钉住的不算)。窗口有自己的滚动条,长答案可以滚动阅读。', + privacyTitle: '隐私契约', + privacyDesc: '选中的文本只在内存里活到浮窗关闭,**绝不**写入历史存档(保存历史开关只控制问答 metadata);超过 4000 字符会截首+尾各 2000 后再上送大模型,避免泄露太多。LLM 调用走你已配的 ARK / DeepSeek 等 OpenAI 兼容 endpoint。', + }, + }, settings: { kicker: 'SETTINGS', title: '设置', @@ -226,11 +256,6 @@ export const zhCN = { capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。', restoreClipboardLabel: '插入后恢复剪贴板', restoreClipboardDesc: '仅 Windows / Linux:粘贴成功后恢复你原来的剪贴板内容(默认开)。关掉就把听写文本留在剪贴板,模拟粘贴没真正落地时可以手动 Ctrl+V 找回。详见 issue #111。', - qaHotkeyLabel: '提问模式快捷键', - qaHotkeyDesc: '选中一段文字后按下此快捷键,弹出问答浮窗并开始录音;再按一次结束录音、生成答案。详见 issue #118。', - qaHotkeyOptionDisabled: '不启用', - qaSaveHistoryLabel: '保存 Q&A 历史', - qaSaveHistoryDesc: '把每次划词问答的问题与答案写入本地存档。关闭则不保留任何痕迹。', }, providers: { llmTitle: 'LLM 模型(润色)', diff --git a/openless-all/app/src/pages/SelectionAsk.tsx b/openless-all/app/src/pages/SelectionAsk.tsx new file mode 100644 index 00000000..436d2834 --- /dev/null +++ b/openless-all/app/src/pages/SelectionAsk.tsx @@ -0,0 +1,238 @@ +// SelectionAsk.tsx — 独立的"划词追问"页(issue #118 / PR #119 配置 UI 拆分版)。 +// 功能:用户在任意 app 选中一段文字 → 按 hotkey → 浮窗弹出 + 进入语音录音 → +// 用户口述提问 → ASR + 选区 + 提问 一起送 LLM → 答案以 markdown 显示在浮窗。 +// +// 这一页把原本散在 Settings → 录音 里的两条配置(hotkey 预设 / 保存 Q&A 历史) +// 集中起来 + 加完整使用指南,跟"翻译"页平级。 + +import { useTranslation } from 'react-i18next'; +import { Card, PageHeader } from './_atoms'; +import { useHotkeySettings } from '../state/HotkeySettingsContext'; +import { setQaHotkey } from '../lib/ipc'; +import type { QaHotkeyBinding } from '../lib/types'; + +const QA_HOTKEY_DISABLED_ID = 'disabled' as const; + +interface QaHotkeyPreset { + id: string; + binding: QaHotkeyBinding; + label: string; +} + +const QA_HOTKEY_PRESETS: readonly QaHotkeyPreset[] = [ + // 双修饰键 chord(Cmd+Option / Ctrl+Alt)默认排第一——用户偏好的纯组合键。 + // 后端实现需要 CGEventTap 的"双修饰键按下后无其他键插入即释放"模式(待后端补)。 + { id: 'cmd+option', label: 'Cmd+Option', binding: { primary: '', modifiers: ['cmd', 'option'] } }, + { id: 'cmd+shift+;', label: 'Cmd+Shift+;', binding: { primary: ';', modifiers: ['cmd', 'shift'] } }, + { id: 'cmd+option+;', label: 'Cmd+Option+;', binding: { primary: ';', modifiers: ['cmd', 'option'] } }, + { id: 'cmd+shift+/', label: 'Cmd+Shift+/', binding: { primary: '/', modifiers: ['cmd', 'shift'] } }, + { id: 'cmd+option+/', label: 'Cmd+Option+/', binding: { primary: '/', modifiers: ['cmd', 'option'] } }, +] as const; + +function bindingToPresetId(binding: QaHotkeyBinding | null): string { + if (!binding) return QA_HOTKEY_DISABLED_ID; + const sortedMods = [...binding.modifiers].map(m => m.toLowerCase()).sort(); + const match = QA_HOTKEY_PRESETS.find(p => { + const pMods = [...p.binding.modifiers].sort(); + return p.binding.primary === binding.primary + && pMods.length === sortedMods.length + && pMods.every((m, i) => m === sortedMods[i]); + }); + return match ? match.id : QA_HOTKEY_PRESETS[0].id; +} + +export function SelectionAsk() { + const { t } = useTranslation(); + const { prefs, updatePrefs: savePrefs } = useHotkeySettings(); + + if (!prefs) { + return ( + <> + + +
{t('common.loading')}
+
+ + ); + } + + const onHotkeyChange = async (id: string) => { + if (id === QA_HOTKEY_DISABLED_ID) { + await savePrefs({ ...prefs, qaHotkey: null }); + return; + } + const preset = QA_HOTKEY_PRESETS.find(p => p.id === id); + if (!preset) return; + try { + await setQaHotkey(preset.binding); + } catch (error) { + console.error('[selectionAsk] failed to set qa hotkey', error); + } + await savePrefs({ ...prefs, qaHotkey: preset.binding }); + }; + + const onSaveHistoryChange = (qaSaveHistory: boolean) => + savePrefs({ ...prefs, qaSaveHistory }); + + const enabled = prefs.qaHotkey !== null; + const currentId = bindingToPresetId(prefs.qaHotkey); + const currentLabel = QA_HOTKEY_PRESETS.find(p => p.id === currentId)?.label ?? ''; + + return ( + <> + + +
+ + {/* 1. 触发快捷键 */} + +
+
{t('selectionAsk.hotkey.title')}
+ + {enabled ? t('selectionAsk.statusEnabled') : t('selectionAsk.statusDisabled')} + +
+
+ {t('selectionAsk.hotkey.desc')} +
+ + {currentId === 'cmd+option' && ( +
+ {t('selectionAsk.hotkey.chordWarning')} +
+ )} +
+ + {/* 2. 历史保存 */} + +
{t('selectionAsk.history.title')}
+
+ {t('selectionAsk.history.desc')} +
+ +
+ + {/* 3. 使用方法 */} + +
{t('selectionAsk.howto.title')}
+
    +
  1. {t('selectionAsk.howto.step1')}
  2. +
  3. {t('selectionAsk.howto.step2', { hotkey: enabled ? currentLabel : '快捷键' })}
  4. +
  5. {t('selectionAsk.howto.step3')}
  6. +
  7. {t('selectionAsk.howto.step4', { hotkey: enabled ? currentLabel : '快捷键' })}
  8. +
  9. {t('selectionAsk.howto.step5')}
  10. +
+ +
+
{t('selectionAsk.howto.windowTitle')}
+ {t('selectionAsk.howto.windowDesc')} +
+ +
+
{t('selectionAsk.howto.privacyTitle')}
+ {t('selectionAsk.howto.privacyDesc')} +
+
+
+ + ); +} diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 3ece3050..08f70b28 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -21,7 +21,6 @@ import { setActiveAsrProvider, setActiveLlmProvider, setCredential, - setQaHotkey, } from '../lib/ipc'; import type { HotkeyCapability, @@ -29,7 +28,6 @@ import type { HotkeyStatus, HotkeyTrigger, PermissionStatus, - QaHotkeyBinding, } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import i18n, { @@ -134,37 +132,6 @@ function SettingRow({ label, desc, children }: SettingRowProps) { ); } -// QA 提问模式快捷键预设。详见 issue #118。 -// id="disabled" 是哨兵 — savePrefs({ qaHotkey: null }) 表示禁用。 -const QA_HOTKEY_DISABLED_ID = 'disabled' as const; - -interface QaHotkeyPreset { - id: string; - binding: QaHotkeyBinding; - /** UI 上展示的字面值;后端的 get_qa_hotkey_label 仅用作冗余提示,不必一致。 */ - label: string; -} - -const QA_HOTKEY_PRESETS: readonly QaHotkeyPreset[] = [ - { id: 'cmd+shift+;', label: 'Cmd+Shift+;', binding: { primary: ';', modifiers: ['cmd', 'shift'] } }, - { id: 'cmd+option+;', label: 'Cmd+Option+;', binding: { primary: ';', modifiers: ['cmd', 'option'] } }, - { id: 'cmd+shift+/', label: 'Cmd+Shift+/', binding: { primary: '/', modifiers: ['cmd', 'shift'] } }, - { id: 'cmd+option+/', label: 'Cmd+Option+/', binding: { primary: '/', modifiers: ['cmd', 'option'] } }, -] as const; - -function bindingToPresetId(binding: QaHotkeyBinding | null): string { - if (!binding) return QA_HOTKEY_DISABLED_ID; - const sortedMods = [...binding.modifiers].map(m => m.toLowerCase()).sort(); - const match = QA_HOTKEY_PRESETS.find(p => { - const pMods = [...p.binding.modifiers].sort(); - return p.binding.primary === binding.primary - && pMods.length === sortedMods.length - && pMods.every((m, i) => m === sortedMods[i]); - }); - // 后端如果当前绑定是非预设值,UI 兜底回到默认 cmd+shift+; - return match ? match.id : QA_HOTKEY_PRESETS[0].id; -} - function RecordingSection() { const { t } = useTranslation(); const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings(); @@ -185,24 +152,6 @@ function RecordingSection() { savePrefs({ ...prefs, showCapsule }); const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) => savePrefs({ ...prefs, restoreClipboardAfterPaste }); - const onQaHotkeyChange = async (id: string) => { - if (id === QA_HOTKEY_DISABLED_ID) { - await savePrefs({ ...prefs, qaHotkey: null }); - return; - } - const preset = QA_HOTKEY_PRESETS.find(p => p.id === id); - if (!preset) return; - // 同步两条路:set_qa_hotkey 让后端立刻重装 hotkey 监听; - // savePrefs 让 UserPreferences 落盘。两个 IPC 由后端决定能否合并。 - try { - await setQaHotkey(preset.binding); - } catch (error) { - console.error('[settings] failed to set qa hotkey', error); - } - await savePrefs({ ...prefs, qaHotkey: preset.binding }); - }; - const onQaSaveHistoryChange = (qaSaveHistory: boolean) => - savePrefs({ ...prefs, qaSaveHistory }); const choices: Array<[HotkeyMode, string]> = [ ['toggle', t('settings.recording.modeToggle')], @@ -280,33 +229,6 @@ function RecordingSection() { > - - - - - - {capability.statusHint && (
{capability.statusHint} diff --git a/openless-all/app/src/state/useAppState.ts b/openless-all/app/src/state/useAppState.ts index f0249b24..423f6216 100644 --- a/openless-all/app/src/state/useAppState.ts +++ b/openless-all/app/src/state/useAppState.ts @@ -2,7 +2,7 @@ import { useState } from 'react'; -export type AppTab = 'overview' | 'history' | 'vocab' | 'style' | 'translation'; +export type AppTab = 'overview' | 'history' | 'vocab' | 'style' | 'translation' | 'selectionAsk'; export interface AppState { currentTab: AppTab; From 6e49281519548202b0d98f486ce9a8c3fb592ed2 Mon Sep 17 00:00:00 2001 From: baiqing Date: Fri, 1 May 2026 17:40:22 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat(qa):=20v2=20=E5=A4=9A=E8=BD=AE?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=20+=20=E6=B5=81=E5=BC=8F=E8=BE=93=E5=87=BA?= =?UTF-8?q?=20+=20macOS=20=E6=B5=AE=E7=AA=97=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue #118 v2 完整闭环。 ## 多轮对话 - QaSessionState 加 messages: Vec + panel_visible flag。 - Cmd+Shift+/ 现在 toggle 浮窗可见性(不再启动录音);浮窗可见时 rightOption 路由到 QA recording,不可见时仍是主听写。open_qa_panel / close_qa_panel 分离。 - 第一轮 user message 嵌选区原文(# 选区原文 / # 我的问题),之后只送提问。 - 前端 QaPanel 渲染气泡列表(user 蓝色靠右 / assistant 灰色 markdown 靠左)。 ## 流式输出 - chat_completion_history → chat_completion_history_streaming:开 stream:true + SSE chunk 解析,on_delta 回调把每 chunk emit 成 qa:state{kind:"answer_delta"}。 - 前端 streamingAnswer state + StreamingAssistantBubble(带蓝色闪烁 caret)。 - answer 事件落定后清空 buffer,最终 messages 接管。 ## macOS 浮窗修复(v1 一直存在的雷) - capabilities/default.json 加 "qa" 到 windows 数组。否则前端 listen("qa:state") 被 Tauri 权限拦截,浮窗永远收不到事件。 - show_qa_window 用 NSWindow.orderFrontRegardless 替代 window.show() (后者在 macOS 走 makeKeyAndOrderFront 抢 frontmost)。这样 capture_selection 的 AX read / Cmd+C fallback 能稳定从原 app 读到选区。 - 所有 ObjC msg_send 必须 wrap 进 app.run_on_main_thread —— macOS 26 对 NSWindow 主线程要求是硬断言(违反直接 SIGTRAP)。 - NSWindow.movableByWindowBackground=YES:浮窗任意空白处可拖。 - QA hotkey 注册在主线程:global-hotkey crate 在 macOS 用 Carbon RegisterEventHotKey,事件回调要靠主 run loop dispatch。 ## 胶囊状态同步 - QA 录音 / 转写 / 思考 同步推 capsule:state,让用户在底部胶囊也看到状态。 - schedule_capsule_idle 同时检查 dictation 和 QA 都 Idle 才隐藏,避免旧 dictation Done timer 把 QA 的胶囊误关。 - open_qa_panel 进入时先 emit Idle 清掉 dictation 残留 message + insertedChars。 ## 设置页 - SelectionAsk 独立成一级 tab;预设减到 4 个 cmd+shift+ 系列(Option 跟主听写 共用,不放进 QA hotkey)。 - howto / hotkey / privacy 文案重写,反映 v2 的「先开浮窗、再 Option 录音」流程。 - prefs 默认 qaHotkey = cmd+shift+;。 ## 后续路线图 - docs/qa-reasoning-roadmap.md:思考能力 v2.2 设计稿(C 路线 reasoner model 切换 + reasoning_content delta + 折叠思考块)。 --- docs/qa-reasoning-roadmap.md | 75 +++ .../app/src-tauri/capabilities/default.json | 2 +- openless-all/app/src-tauri/src/coordinator.rs | 353 +++++++++-- openless-all/app/src-tauri/src/lib.rs | 83 ++- openless-all/app/src-tauri/src/polish.rs | 167 ++++- openless-all/app/src-tauri/src/types.rs | 11 + openless-all/app/src-tauri/tauri.conf.json | 2 +- openless-all/app/src/i18n/en.ts | 30 +- openless-all/app/src/i18n/zh-CN.ts | 30 +- openless-all/app/src/lib/types.ts | 28 +- openless-all/app/src/pages/QaPanel.tsx | 568 +++++++++++++----- openless-all/app/src/pages/SelectionAsk.tsx | 34 +- 12 files changed, 1091 insertions(+), 292 deletions(-) create mode 100644 docs/qa-reasoning-roadmap.md diff --git a/docs/qa-reasoning-roadmap.md b/docs/qa-reasoning-roadmap.md new file mode 100644 index 00000000..4b1f887e --- /dev/null +++ b/docs/qa-reasoning-roadmap.md @@ -0,0 +1,75 @@ +# 划词追问:思考能力(Reasoning)路线图 + +> 创建于 2026-05-01。流式输出(v2.1)已完成,**思考能力(v2.2)暂未实施**——这份文档是后续迭代的设计稿。 +> +> 关联:issue #118 v2、PR #119、`openless-all/app/src-tauri/src/polish.rs`、`openless-all/app/src/pages/SelectionAsk.tsx`。 + +## 背景与决策 + +用户提出:"QA 应该让 LLM 进行思考后再回复,并且可以设置思考强度"。 + +讨论了 3 条路径: + +| 方案 | 实现 | 优 | 劣 | +|---|---|---|---| +| A | prompt-engineered(system prompt 加 `` 块要求) | 0 配置改动;现 model 即可 | 思考质量受小模型限制;不可控 | +| B | OpenAI 标准 `reasoning_effort: low/medium/high` 字段 | 标准化 | DeepSeek-v4-flash 不识别该字段 | +| **C** | **切换 reasoner 模型(deepseek-r1 / o1 / claude extended thinking)** | **真**思考;可视化推理过程 | 用户得多配一个 model;UI 复杂度高 | + +**结论**:选 C。A/B 在当前 provider 下等于无效。 + +## 实施分解 + +### 后端 + +1. **凭据存储**:`CredentialAccount` 加两条 + - `ArkReasonerModelId`(如 `deepseek-r1`、`doubao-seed-1.6-thinking`) + - 复用现有 `ArkApiKey` / `ArkEndpoint`(同一 provider 不同 model) + +2. **prefs**:`Preferences` 加字段 + - `qa_reasoning_effort: ReasoningEffort` 枚举 `Off | Low | Medium | High` + - 默认 `Off`(与现行为一致) + +3. **`answer_chat_streaming` 重载**:根据 effort 决定走 chat 还是 reasoner endpoint + - `Off`:走 v2.1 现路径(chat 模型 + stream) + - `Low/Medium/High`:走 reasoner 模型;强度通过 system prompt hint 调("简短思考即可" / "详细思考" / "深度推理多角度") + - SSE 解析时同时收 `delta.content` + `delta.reasoning_content`,两者通过不同事件 emit: + - `qa:state {kind:"reasoning_delta", chunk}` + - `qa:state {kind:"answer_delta", chunk}` (已存在) + +4. **answer_chat 拼装最终 message** 时,`reasoning_content` 不写入 `messages` 数组(只显示用,不进上下文)。多轮提问只把最终答案带回上下文。 + +### 前端 + +1. **SelectionAsk.tsx** 新增配置块: + - 「思考强度」下拉:关闭 / 浅 / 中 / 深 + - 「思考模型」输入框(model id;默认 `deepseek-r1`) + - i18n:zh-CN / en + +2. **QaPanel.tsx** 新增「思考过程」可折叠区块: + - 在 user 气泡下方、最终 assistant 气泡上方 + - 默认折叠,标题 `思考中…` / `思考过程(X 字)`,点击展开 + - 流式期间:实时拼接 `reasoning_delta`,气泡有打字 caret + - 答案完成:折叠收起;用户随时可点开看推理 + +3. **types.ts**:`QaStateKind` 加 `'reasoning_delta'`;payload 加 `reasoning_chunk?: string` + +### 边界与风险 + +- **Provider 兼容性**:火山 Ark 的 deepseek-r1 / doubao-thinking 都返回 `reasoning_content`;OpenAI o1 通过 thinking blocks(不是 reasoning_content),需要单独 adapter +- **Token 成本**:reasoner 模型 token 价格高 5-10x;用户开「深度」就是真烧钱,UI 应该有提示 +- **延迟**:reasoner 首 token 可能 > 5s(思考阶段无 content 输出)。要在 UI 上区分「思考中」(reasoning streaming)vs「答题中」(content streaming),避免用户以为卡了 + +## 工作量估算 + +- 后端 reasoner 通路 + SSE 双流解析:~2h +- 前端折叠思考区块 + 打字 caret + 状态切换:~1.5h +- prefs / SelectionAsk 配置 UI + i18n:~0.5h +- 端到端测试(三档强度 × 单/多轮 × 错误回退):~1h +- **总计**:~5h + +## 实施先决条件 + +1. 用户配置好一个 reasoner model(deepseek-r1 / doubao-thinking-pro 等) +2. 后端凭据 vault 写入对应 model id +3. v2.1 流式输出已稳定(已完成 ✅) diff --git a/openless-all/app/src-tauri/capabilities/default.json b/openless-all/app/src-tauri/capabilities/default.json index 49dc8e6c..a17c96af 100644 --- a/openless-all/app/src-tauri/capabilities/default.json +++ b/openless-all/app/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default capabilities for OpenLess windows", - "windows": ["main", "capsule"], + "windows": ["main", "capsule", "qa"], "permissions": [ "core:default", "core:window:default", diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 1f7a08b0..824e5365 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -26,7 +26,7 @@ use crate::persistence::{ }; use crate::polish::{OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; -use crate::qa_hotkey::{QaHotkeyEvent, QaHotkeyMonitor}; +use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor}; use crate::recorder::{Recorder, RecorderError}; use crate::selection::{capture_selection, SelectionContext}; use crate::types::{ @@ -133,6 +133,12 @@ struct QaSessionState { session_id: u64, /// QA 浮窗是否被用户钉住(pinned)。pinned=true 时不自动隐藏。 pinned: bool, + /// 浮窗是否对用户可见。Cmd+Shift+; 边沿 toggle 此 flag; + /// 主听写 hotkey(rightOption)边沿来时,看这个 flag 决定是走 QA 还是走 dictation。 + /// 详见 issue #118 v2。 + panel_visible: bool, + /// 多轮对话累积。每轮 user→assistant 加两条;关浮窗清空。 + messages: Vec, } impl Default for QaSessionState { @@ -144,6 +150,8 @@ impl Default for QaSessionState { front_app: None, session_id: 0, pinned: false, + panel_visible: false, + messages: Vec::new(), } } } @@ -217,16 +225,55 @@ impl Coordinator { pub fn update_qa_hotkey_binding(&self) { let prefs = self.inner.prefs.get(); let Some(binding) = prefs.qa_hotkey.clone() else { - // 用户把功能关了 → 直接 drop monitor - self.inner.qa_hotkey.lock().take(); + // 用户把功能关了 → 直接 drop monitor。drop 也得在主线程,否则 Carbon + // unregister 会失败/UB。 + let app = self.inner.app.lock().clone(); + if let Some(app) = app { + let inner_clone = Arc::clone(&self.inner); + let _ = app.run_on_main_thread(move || { + inner_clone.qa_hotkey.lock().take(); + }); + } else { + self.inner.qa_hotkey.lock().take(); + } log::info!("[coord] QA hotkey 已关闭"); return; }; - if let Some(monitor) = self.inner.qa_hotkey.lock().as_ref() { - if let Err(e) = monitor.update_binding(binding) { - log::warn!("[coord] update QA hotkey binding 失败: {e}"); + // global-hotkey crate 的 manager.register/unregister 必须主线程跑。 + // 没在主线程会让 Carbon 句柄注册看似成功但事件不派发。 + let app = self.inner.app.lock().clone(); + let Some(app) = app else { + log::warn!("[coord] update QA hotkey binding: AppHandle 未 bind,跳过"); + return; + }; + let inner_clone = Arc::clone(&self.inner); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + // 路径 1:当前已有 monitor → 在主线程换绑定。 + if let Some(monitor) = inner_clone.qa_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(binding_for_main.clone()) { + log::warn!("[coord] update QA hotkey binding 失败: {e}"); + } + return; } - } + // 路径 2:之前还没装上 → 主线程上重装一次(supervisor 也会重试, + // 但用户体感更快:set_qa_hotkey 命令一返回,hotkey 立即生效)。 + let (tx, rx) = mpsc::channel::(); + match QaHotkeyMonitor::start(binding_for_main, tx) { + Ok(monitor) => { + *inner_clone.qa_hotkey.lock() = Some(monitor); + log::info!("[coord] QA hotkey listener installed on main thread (via update)"); + let bridge_inner = Arc::clone(&inner_clone); + std::thread::Builder::new() + .name("openless-qa-hotkey-bridge".into()) + .spawn(move || qa_hotkey_bridge_loop(bridge_inner, rx)) + .ok(); + } + Err(e) => { + log::warn!("[coord] update QA hotkey binding 失败: {e}"); + } + } + }); } /// 给前端 Settings 渲染当前 QA 快捷键 label(如 "Cmd+Shift+;")。 @@ -241,14 +288,10 @@ impl Coordinator { .unwrap_or_default() } - /// 用户点 ✕ / 按 Esc 关 QA 浮窗时调。会: - /// - 把 QA 浮窗 hide(保留前端状态) - /// - 取消 QA session(recorder/asr drop) + /// 用户点 ✕ / 按 Esc 关 QA 浮窗时调。等价于:取消任何进行中的录音 + + /// 清空多轮对话历史 + 隐藏窗口。详见 issue #118 v2。 pub fn qa_window_dismiss(&self) { - cancel_qa_session(&self.inner); - if let Some(app) = self.inner.app.lock().clone() { - crate::hide_qa_window(&app); - } + close_qa_panel(&self.inner); } /// 用户点 📌 切换 pinned 状态。pinned=true 时浮窗不自动隐藏。 @@ -414,12 +457,49 @@ fn qa_hotkey_supervisor_loop(inner: Arc) { continue; } + // global-hotkey crate 在 macOS 走 Carbon RegisterEventHotKey,要求 manager + // 在主线程构造,否则 register() 看起来 Ok 但事件根本不会派发——这是 issue #118 + // PR #119 第一版漏掉的关键步骤,导致用户按了 hotkey 完全无反应。这里通过 + // run_on_main_thread 把 QaHotkeyMonitor::start 跳到主线程跑,结果再回 channel。 + let app = inner.app.lock().clone(); + let app = match app { + Some(a) => a, + None => { + // 启动期 AppHandle 还没 bind,再等。 + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + let (tx, rx) = mpsc::channel::(); - match QaHotkeyMonitor::start(binding, tx) { + let (init_tx, init_rx) = mpsc::sync_channel::>(1); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + let result = QaHotkeyMonitor::start(binding_for_main, tx); + let _ = init_tx.send(result); + }); + + // run_on_main_thread 是 fire-and-forget;等主线程跑完结果回来。给 5s 上限避免 + // 主线程繁忙时 supervisor 永久阻塞。 + let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(r) => r, + Err(_) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] QA hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + }; + + match init_result { Ok(monitor) => { *inner.qa_hotkey.lock() = Some(monitor); log::info!( - "[coord] QA hotkey listener installed (after {} attempt(s))", + "[coord] QA hotkey listener installed on main thread (after {} attempt(s))", attempts + 1 ); let inner_clone = Arc::clone(&inner); @@ -454,8 +534,22 @@ fn qa_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { } async fn handle_qa_hotkey_pressed(inner: &Arc) { + // QA hotkey(默认 Cmd+Shift+;)现在只 toggle 浮窗可见性。 + // 浮窗内的录音 / 提问由 Option 边沿驱动(handle_pressed_edge → handle_qa_option_edge)。 + let visible = inner.qa_state.lock().panel_visible; + log::info!("[coord] QA hotkey edge (panel_visible={visible})"); + if visible { + close_qa_panel(inner); + } else { + open_qa_panel(inner); + } +} + +/// 浮窗可见时,主听写 hotkey(rightOption)边沿改打到这里: +/// Idle → 录音 / Recording → 停录音并提问。 +async fn handle_qa_option_edge(inner: &Arc) { let phase = inner.qa_state.lock().phase; - log::info!("[coord] QA hotkey edge (phase={phase:?})"); + log::info!("[coord] QA option edge (phase={phase:?})"); match phase { QaPhase::Idle => { let _ = begin_qa_session(inner).await; @@ -468,6 +562,53 @@ async fn handle_qa_hotkey_pressed(inner: &Arc) { } } +fn open_qa_panel(inner: &Arc) { + { + let mut state = inner.qa_state.lock(); + state.panel_visible = true; + state.phase = QaPhase::Idle; + state.cancelled = false; + state.messages.clear(); + state.selection = None; + state.front_app = capture_frontmost_app(); + } + // 先把胶囊清干净,避免主听写上一次 Done 状态残留的 message/insertedChars + // 在 QA Done 阶段被 capsule UI 错误复用("已之一粘贴这个 0" 那种)。 + emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None); + if let Some(app) = inner.app.lock().clone() { + crate::show_qa_window(&app, "idle"); + let _ = app.emit_to( + "qa", + "qa:state", + serde_json::json!({ + "kind": "idle", + "messages": Vec::::new(), + }), + ); + } + log::info!("[coord] QA panel opened (awaiting Option to record)"); +} + +fn close_qa_panel(inner: &Arc) { + cancel_qa_session(inner); + { + let mut state = inner.qa_state.lock(); + state.panel_visible = false; + state.pinned = false; + state.messages.clear(); + state.selection = None; + state.front_app = None; + state.phase = QaPhase::Idle; + state.cancelled = false; + } + if let Some(app) = inner.app.lock().clone() { + crate::hide_qa_window(&app); + } + // 胶囊一同收掉,避免浮窗关了胶囊还在显示。 + emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None); + log::info!("[coord] QA panel closed, history cleared"); +} + fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { while let Ok(evt) = rx.recv() { let inner_cloned = Arc::clone(&inner); @@ -500,7 +641,13 @@ fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { async fn handle_pressed_edge(inner: &Arc) { let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst); if !was_held { - handle_pressed(inner).await; + // 路由:QA 浮窗可见时,rightOption 边沿走 QA;否则走主听写。详见 issue #118 v2。 + let panel_visible = inner.qa_state.lock().panel_visible; + if panel_visible { + handle_qa_option_edge(inner).await; + } else { + handle_pressed(inner).await; + } } } @@ -530,6 +677,11 @@ async fn handle_pressed(inner: &Arc) { async fn handle_released_edge(inner: &Arc) { let was_held = inner.hotkey_trigger_held.swap(false, Ordering::SeqCst); if was_held { + // QA 浮窗可见时,Option 行为是 press-toggle(不分 hold/release),release 边沿忽略。 + let panel_visible = inner.qa_state.lock().panel_visible; + if panel_visible { + return; + } handle_released(inner).await; } } @@ -1462,6 +1614,10 @@ fn enabled_hotwords(inner: &Arc) -> Vec { async fn begin_qa_session(inner: &Arc) -> Result<(), String> { { let mut state = inner.qa_state.lock(); + if !state.panel_visible { + // 防御:浮窗没开就被叫到这里说明路由错了,直接退出。 + return Ok(()); + } if state.phase != QaPhase::Idle { return Ok(()); } @@ -1472,20 +1628,21 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { state.selection = None; } - // 1. 显示 QA 浮窗(loading 状态)+ 同步抓选区。 - // 选区抓取在 show_qa_window 之前做,避免浮窗抢前台 app 焦点导致选区丢失。 + // 抓选区。每轮按 Option 都重新抓一次:用户多轮提问中可以重新选别处文字。 + // 浮窗 focus:false,原 app 仍是 frontmost,AX/Cmd+C fallback 都能拿到。 let selection = capture_selection(); let selection_preview_text = selection.as_ref().map(|s| s.text.clone()); inner.qa_state.lock().selection = selection.clone(); if let Some(app) = inner.app.lock().clone() { - crate::show_qa_window(&app, "loading"); + let messages = inner.qa_state.lock().messages.clone(); let _ = app.emit_to( "qa", "qa:state", serde_json::json!({ - "kind": "loading", - "selection_preview": selection_preview_text.clone(), + "kind": "recording", + "selection_preview": selection_preview_text, + "messages": messages, }), ); } @@ -1540,6 +1697,15 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { serde_json::json!({ "level": level }), ); } + // 同步把电平推给底部胶囊,让 QA 录音也有跟主听写一致的可视反馈。 + emit_capsule( + &inner_for_level, + CapsuleState::Recording, + level, + 0, + None, + None, + ); }); match Recorder::start(consumer, level_handler) { @@ -1581,17 +1747,9 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { let flushed = bridge.attach(target); log::info!("[coord] QA ASR connected; flushed {flushed} deferred audio bytes"); - // 通知前端进入 recording 状态("loading" 已经在第 1 步发过;这里附带选区预览)。 - if let Some(app) = inner.app.lock().clone() { - let _ = app.emit_to( - "qa", - "qa:state", - serde_json::json!({ - "kind": "loading", - "selection_preview": selection_preview_text, - }), - ); - } + // 显式弹胶囊到 Recording。level_handler 后续会持续推电平,胶囊里"录音中…" + // 的视觉反馈跟主听写完全一致。 + emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); Ok(()) } @@ -1605,6 +1763,9 @@ async fn end_qa_session(inner: &Arc) -> Result<(), String> { state.phase = QaPhase::Processing; } + // 胶囊进入 Transcribing:用户视觉上看到"识别中"。 + emit_capsule(inner, CapsuleState::Transcribing, 0.0, 0, None, None); + if let Some(app) = inner.app.lock().clone() { let _ = app.emit_to( "qa", @@ -1652,40 +1813,83 @@ async fn end_qa_session(inner: &Arc) -> Result<(), String> { return Ok(()); } + // 拼这一轮的 user 消息:第一轮(messages 还空)把选区原文嵌进去; + // 之后的轮次只送提问,让 LLM 顺着上下文回答。详见 issue #118 v2。 + let user_content = { + let st = inner.qa_state.lock(); + let is_first_turn = st.messages.is_empty(); + let sel_text = st + .selection + .as_ref() + .map(|s| s.text.clone()) + .unwrap_or_default(); + if is_first_turn && !sel_text.trim().is_empty() { + format!( + "# 选区原文\n{}\n\n# 我的问题\n{}", + sel_text.trim(), + question + ) + } else { + question.clone() + } + }; + + inner.qa_state.lock().messages.push(crate::types::QaChatMessage { + role: "user".to_string(), + content: user_content, + }); + if let Some(app) = inner.app.lock().clone() { + let messages = inner.qa_state.lock().messages.clone(); let _ = app.emit_to( "qa", "qa:state", serde_json::json!({ - "kind": "loading", - "question": question, + "kind": "thinking", + "messages": messages, }), ); } + // 胶囊:思考阶段(复用 dictation 的 Polishing 状态——视觉上是"润色中",QA 借用一下)。 + emit_capsule(inner, CapsuleState::Polishing, 0.0, 0, None, None); + let prefs = inner.prefs.get(); let working_languages = prefs.working_languages.clone(); - let (selection_text, front_app) = { + let (messages_for_llm, front_app) = { let st = inner.qa_state.lock(); - let sel_text = st - .selection - .as_ref() - .map(|s| s.text.clone()) - .unwrap_or_default(); - (sel_text, st.front_app.clone()) + (st.messages.clone(), st.front_app.clone()) + }; + + // 流式回调:每个 SSE delta 立刻推一帧 qa:state{kind:"answer_delta"} 给前端, + // 浮窗里气泡边收边长。最终的 messages 由 answer 事件统一下发(保证一致性)。 + let inner_for_delta = Arc::clone(inner); + let on_delta = move |chunk: &str| { + if let Some(app) = inner_for_delta.app.lock().clone() { + let _ = app.emit_to( + "qa", + "qa:state", + serde_json::json!({ + "kind": "answer_delta", + "chunk": chunk, + }), + ); + } }; - let answer = match answer_with_selection_dispatch( - &question, - &selection_text, + let answer = match answer_chat_dispatch( + &messages_for_llm, &working_languages, front_app.as_deref(), + on_delta, ) .await { Ok(s) => s, Err(e) => { log::error!("[coord] QA: LLM answer failed: {e}"); + // 把刚 push 的 user 消息回滚,避免 retry 重复 + inner.qa_state.lock().messages.pop(); finish_qa_with_error(inner, format!("回答失败: {e}")); return Err(e.to_string()); } @@ -1693,21 +1897,33 @@ async fn end_qa_session(inner: &Arc) -> Result<(), String> { if inner.qa_state.lock().cancelled { log::info!("[coord] QA cancel detected before answer — discarding"); + // 同样回滚未配对的 user 消息 + inner.qa_state.lock().messages.pop(); finish_qa_idle_silently(inner); return Ok(()); } + inner.qa_state.lock().messages.push(crate::types::QaChatMessage { + role: "assistant".to_string(), + content: answer.clone(), + }); + if let Some(app) = inner.app.lock().clone() { + let messages = inner.qa_state.lock().messages.clone(); let _ = app.emit_to( "qa", "qa:state", serde_json::json!({ "kind": "answer", - "answer_md": answer, + "messages": messages, }), ); } + // 胶囊直接收掉。QA 不走 insertion,没"已粘贴 N 字"语义;浮窗里答案就是用户的反馈。 + // (之前用 Done 状态会被 capsule UI 错误地渲染上一次 dictation 残留的 message/insertedChars。) + emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None); + // 可选:写一条 history(QA 类型)。当前 DictationSession schema 不能直接表达 // "QuestionAnswer" 类型,因此简单做法:勾选 qa_save_history 时写一条 // mode=Raw、error_code=Some("qaSession") 的 placeholder,避免污染 schema 同时 @@ -1735,36 +1951,44 @@ async fn end_qa_session(inner: &Arc) -> Result<(), String> { Ok(()) } -/// 把出错状态送到前端浮窗 + 复位 phase;不弹胶囊错误(QA 浮窗内部自己渲染)。 +/// 把出错状态送到前端浮窗 + 胶囊错误闪一下 + 复位 phase。 +/// 浮窗保持可见(v2:错误后用户可以再按 Option 重试);messages 一并送过去 +/// 让前端继续渲染历史对话。 fn finish_qa_with_error(inner: &Arc, message: String) { if let Some(app) = inner.app.lock().clone() { + let messages = inner.qa_state.lock().messages.clone(); let _ = app.emit_to( "qa", "qa:state", serde_json::json!({ "kind": "error", "error": message, + "messages": messages, }), ); } + emit_capsule(inner, CapsuleState::Error, 0.0, 0, Some(message), None); + schedule_capsule_idle(inner, 1500); let mut state = inner.qa_state.lock(); state.phase = QaPhase::Idle; state.cancelled = false; } -/// 静默收尾:发 idle 事件给前端关浮窗(pinned 时保留),phase 复位到 Idle。 +/// 静默收尾:发 idle 事件给前端,phase 复位。**不关浮窗**(v2:浮窗只在用户 +/// Esc/X 或再按 QA hotkey 时才关);多轮对话历史保留。胶囊也即刻收掉。 fn finish_qa_idle_silently(inner: &Arc) { - let pinned = inner.qa_state.lock().pinned; if let Some(app) = inner.app.lock().clone() { + let messages = inner.qa_state.lock().messages.clone(); let _ = app.emit_to( "qa", "qa:state", - serde_json::json!({ "kind": "idle" }), + serde_json::json!({ + "kind": "idle", + "messages": messages, + }), ); - if !pinned { - crate::hide_qa_window(&app); - } } + emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None); let mut state = inner.qa_state.lock(); state.phase = QaPhase::Idle; state.cancelled = false; @@ -1791,12 +2015,15 @@ fn cancel_qa_session(inner: &Arc) { log::info!("[coord] QA session cancelled (was {phase:?})"); } -async fn answer_with_selection_dispatch( - question: &str, - selection: &str, +async fn answer_chat_dispatch( + messages: &[crate::types::QaChatMessage], working_languages: &[String], front_app: Option<&str>, -) -> anyhow::Result { + on_delta: F, +) -> anyhow::Result +where + F: Fn(&str) + Send + Sync, +{ let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default(); if api_key.is_empty() { anyhow::bail!("ark api key missing"); @@ -1814,7 +2041,7 @@ async fn answer_with_selection_dispatch( let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model); let provider = OpenAICompatibleLLMProvider::new(config); Ok(provider - .answer_with_selection(question, selection, working_languages, front_app) + .answer_chat_streaming(messages, working_languages, front_app, on_delta) .await?) } @@ -1997,9 +2224,11 @@ fn schedule_capsule_idle(inner: &Arc, delay_ms: u64) { let inner_clone = Arc::clone(inner); async_runtime::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; - // 仅在仍然 Idle 时(即用户没在这 2s 内重新触发)才 hide。 - // 否则可能把新启动的 Recording 状态意外覆盖回 Idle。 - if inner_clone.state.lock().phase == SessionPhase::Idle { + // 必须 dictation **和** QA 同时空闲才能隐藏胶囊。否则旧 dictation Done timer + // 的尾巴会在新 QA 录音/思考中把胶囊意外收掉(issue #118 v2 复现)。 + let dictation_idle = inner_clone.state.lock().phase == SessionPhase::Idle; + let qa_idle = inner_clone.qa_state.lock().phase == QaPhase::Idle; + if dictation_idle && qa_idle { emit_capsule(&inner_clone, CapsuleState::Idle, 0.0, 0, None, None); } }); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index ac025c71..d894ceaa 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -25,9 +25,14 @@ mod types; #[cfg(target_os = "macos")] use std::sync::mpsc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; #[cfg(target_os = "macos")] use std::time::Duration; + +/// 第一次 show 时把 QA 浮窗摆到屏幕底部居中;之后的 show 不再 reposition, +/// 让用户拖动后的位置在 hide → show 之间得以保持。详见 issue #118 v2。 +static QA_WINDOW_POSITIONED: AtomicBool = AtomicBool::new(false); use tauri::menu::{MenuBuilder, MenuItemBuilder}; use tauri::tray::{MouseButton, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Emitter, LogicalPosition, Manager, RunEvent, Runtime}; @@ -62,14 +67,16 @@ pub fn run() { let _ = capsule.hide(); } - // QA 浮窗(issue #118):紧贴胶囊上方 8pt、屏幕底部居中、380×280。 - // 启动时 hide(),等 coordinator 在 begin_qa_session 时再 show + 定位。 + // QA 浮窗(issue #118):紧贴胶囊上方 8pt、屏幕底部居中、380×440。 + // 启动时 hide(),等 coordinator 在 open_qa_panel 时再 show + 首次定位。 // tauri.conf.json 里需要声明 label="qa" 的窗口(前端 agent 负责); // 这里 get_webview_window 返回 None 时直接跳过,不影响主流程。 if let Some(qa) = app.get_webview_window("qa") { if let Err(e) = position_qa_window(&qa) { log::warn!("[qa] position failed: {e}"); } + #[cfg(target_os = "macos")] + make_qa_window_draggable_macos(&qa); let _ = qa.hide(); } else { log::info!("[qa] qa 窗口未在 tauri.conf.json 中声明,前端 agent 会补上"); @@ -424,7 +431,7 @@ fn wait_for_app_activation(_app: &AppHandle) {} /// QA 浮窗的目标尺寸(issue #118)。胶囊默认 220×96 + Dock 80pt + 8pt gap, /// 算下来 QA 窗口顶部坐标 = h - 80 - 96 - 8 - 280。 const QA_WINDOW_WIDTH: f64 = 380.0; -const QA_WINDOW_HEIGHT: f64 = 280.0; +const QA_WINDOW_HEIGHT: f64 = 440.0; /// 胶囊与 QA 窗口的间距,与设计稿一致。 const QA_WINDOW_GAP_TO_CAPSULE: f64 = 8.0; /// 胶囊高度(与 `position_capsule_bottom_center` 中一致)。 @@ -466,9 +473,47 @@ pub(crate) fn show_qa_window(app: &AppHandle, content_kind ); return; }; - if let Err(e) = position_qa_window(&window) { - log::warn!("[qa] position before show failed: {e}"); + // 仅首次 show 时居中;之后保留用户拖动后的位置。 + if !QA_WINDOW_POSITIONED.load(Ordering::Relaxed) { + if let Err(e) = position_qa_window(&window) { + log::warn!("[qa] position before first show failed: {e}"); + } + QA_WINDOW_POSITIONED.store(true, Ordering::Relaxed); } + // macOS:不用 window.show()(它会 makeKeyAndOrderFront 把 OpenLess 推成 frontmost, + // 之后 capture_selection 的 AX read / Cmd+C fallback 都跑在 OpenLess 自己的 webview 上 + // → 抓不到原 app 选区)。改用 orderFrontRegardless 让窗口可见但**不**成为 key window, + // frontmost 仍是用户原 app,AX 还能读到选区。这是 Spotlight / Raycast 的标准做法。 + // + // ⚠️ 关键:NSWindow 任何操作必须在主线程,macOS 26 是硬断言(违反直接 SIGTRAP)。 + // show_qa_window 经常从 tokio worker 调(qa_hotkey_bridge_loop),所以裸 ObjC msg_send + // 必须用 `app.run_on_main_thread` dispatch 到主线程。详见 issue #118 v2。 + #[cfg(target_os = "macos")] + { + let window_clone = window.clone(); + let _ = app.run_on_main_thread(move || { + use objc2::msg_send; + use objc2::runtime::AnyObject; + match window_clone.ns_window() { + Ok(handle) => { + let ns = handle as *mut AnyObject; + if ns.is_null() { + log::warn!("[qa] ns_window null; falling back to window.show()"); + let _ = window_clone.show(); + } else { + unsafe { + let _: () = msg_send![ns, orderFrontRegardless]; + } + } + } + Err(e) => { + log::warn!("[qa] ns_window unavailable: {e}; falling back to window.show()"); + let _ = window_clone.show(); + } + } + }); + } + #[cfg(not(target_os = "macos"))] if let Err(e) = window.show() { log::warn!("[qa] show failed: {e}"); } @@ -479,6 +524,34 @@ pub(crate) fn show_qa_window(app: &AppHandle, content_kind ); } +/// QA 浮窗的拖动修复(macOS)。 +/// +/// 配置 `focus: false` 让 Tauri 把窗口创建为 nonactivating panel 风格(避免抢前台 app +/// 焦点)。代价是 AppKit 的 `performWindowDragWithEvent:` 在 nonactivating 窗口上无效, +/// 所以 `data-tauri-drag-region` 和 `WebviewWindow::start_dragging()` 都拖不动。 +/// +/// 解法是把 NSWindow 的 `movableByWindowBackground` 打开——这条路径不依赖窗口是否成为 +/// key window,跟 Spotlight / Raycast 的浮窗是同一手法。设一次就够,整个生命周期保持。 +#[cfg(target_os = "macos")] +fn make_qa_window_draggable_macos(window: &tauri::WebviewWindow) { + use objc2::msg_send; + use objc2::runtime::{AnyObject, Bool}; + let Ok(handle) = window.ns_window() else { + log::warn!("[qa] ns_window unavailable; drag fix skipped"); + return; + }; + let ns_window = handle as *mut AnyObject; + if ns_window.is_null() { + log::warn!("[qa] ns_window null; drag fix skipped"); + return; + } + unsafe { + let _: () = msg_send![ns_window, setMovableByWindowBackground: Bool::YES]; + let _: () = msg_send![ns_window, setMovable: Bool::YES]; + } + log::info!("[qa] NSWindow movableByWindowBackground=YES"); +} + /// 隐藏 QA 窗口。供 commands::qa_window_dismiss / coordinator session 收尾共用。 pub(crate) fn hide_qa_window(app: &AppHandle) { if let Some(window) = app.get_webview_window("qa") { diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index d524ff38..0004e1f7 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -10,7 +10,7 @@ use std::time::Duration; use serde_json::{json, Value}; use thiserror::Error; -use crate::types::PolishMode; +use crate::types::{PolishMode, QaChatMessage}; const DEFAULT_TEMPERATURE: f32 = 0.3; const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; @@ -100,26 +100,26 @@ impl OpenAICompatibleLLMProvider { self.chat_completion(&system_prompt, &user_prompt).await } - /// 划词语音问答:基于用户在前台 app 选中的文本(`selection`)和口述提问(`question`), - /// 给出 Markdown 格式的简短回答。`working_languages` 与 `front_app` 通过 - /// `context_premise` 拼到 system prompt 头部。详见 issue #118。 - /// - /// `selection` 可以为空 —— 用户没选中时退化为纯语音问答;`question` 为空时仍会调 - /// LLM(让模型在 system 约束下产出"无问题可答"的简短提示),但 coordinator - /// 通常会在静默录音时直接 short-circuit 不走到这里。 - pub async fn answer_with_selection( + /// 多轮划词追问,**流式**返回。`messages` 包含历史对话(user/assistant 交替), + /// 最后一条必须是新一轮的 user 提问。第一条 user 消息里如果有选区,调用方应在 + /// content 里就把选区原文注入。`on_delta` 在每个 SSE chunk 到达时被调;最终返回 + /// 拼好的完整字符串(用于写入 messages 历史)。详见 issue #118 v2。 + pub async fn answer_chat_streaming( &self, - question: &str, - selection: &str, + messages: &[QaChatMessage], working_languages: &[String], front_app: Option<&str>, - ) -> Result { + on_delta: F, + ) -> Result + where + F: Fn(&str) + Send + Sync, + { let mut system_prompt = prompts::qa_system_prompt(); if let Some(premise) = context_premise(working_languages, front_app) { system_prompt = format!("{}\n\n{}", premise, system_prompt); } - let user_prompt = prompts::qa_user_prompt(question, selection); - self.chat_completion(&system_prompt, &user_prompt).await + self.chat_completion_history_streaming(&system_prompt, messages, on_delta) + .await } /// 把转写翻译成 `target_language`(前端从内置语言列表里选出来的原生名)。 @@ -205,6 +205,138 @@ impl OpenAICompatibleLLMProvider { extract_assistant_content(&body_text) } + + /// 与 `chat_completion` 同条 HTTP 通路,但开 `stream: true` 并把 SSE chunk 一边 + /// 解析、一边通过 `on_delta` 推给调用方(用于实时把答案塞进浮窗气泡)。 + /// 最终返回拼好的完整字符串供调用方写入对话历史。 + async fn chat_completion_history_streaming( + &self, + system_prompt: &str, + history: &[QaChatMessage], + on_delta: F, + ) -> Result + where + F: Fn(&str) + Send + Sync, + { + if self.config.api_key.trim().is_empty() { + return Err(LLMError::MissingCredentials); + } + + let mut msgs: Vec = Vec::with_capacity(history.len() + 1); + msgs.push(json!({ "role": "system", "content": system_prompt })); + for m in history { + msgs.push(json!({ "role": m.role, "content": m.content })); + } + + let url = chat_completions_url(&self.config.base_url); + let body = json!({ + "model": self.config.model, + "stream": true, + "temperature": self.config.temperature, + "messages": msgs, + }); + + log::info!( + "[llm] POST {} provider={} model={} chat_turns={} stream=true", + url, + self.config.provider_id, + self.config.model, + history.len() + ); + + let mut request = self + .client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream") + .header("Authorization", format!("Bearer {}", self.config.api_key)); + for (k, v) in &self.config.extra_headers { + request = request.header(k.as_str(), v.as_str()); + } + let request = request.json(&body); + + let response = match request.send().await { + Ok(r) => r, + Err(e) => { + if e.is_timeout() { + return Err(LLMError::Timeout); + } + return Err(LLMError::Network(e.to_string())); + } + }; + + let status = response.status(); + if !status.is_success() { + // 失败时仍把 body 读一遍方便诊断 + let body_text = response + .text() + .await + .map_err(|e| LLMError::Network(e.to_string()))?; + let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len()); + let preview = safe_str_slice(&body_text, preview_end); + log::error!("[llm] HTTP {} body={}", status.as_u16(), preview); + return Err(LLMError::InvalidResponse { + status: status.as_u16(), + body: preview.to_string(), + }); + } + + // SSE 流:一帧 = 若干行,以 `\n\n` 分隔。每行如 `data: {...}` 或 `data: [DONE]`。 + // 一个 chunk() 可能包含半帧或多帧;用 buffer 累积后再按 `\n\n` 切。 + let mut response = response; + let mut buffer = String::new(); + let mut full_text = String::new(); + loop { + let chunk_opt = response + .chunk() + .await + .map_err(|e| LLMError::Network(e.to_string()))?; + let Some(chunk) = chunk_opt else { break }; + let s = std::str::from_utf8(&chunk) + .map_err(|e| LLMError::Network(format!("non-utf8 SSE chunk: {e}")))?; + buffer.push_str(s); + + while let Some(idx) = buffer.find("\n\n") { + let event = buffer[..idx].to_string(); + buffer.drain(..idx + 2); + for line in event.lines() { + let Some(payload) = line.strip_prefix("data: ").or_else(|| line.strip_prefix("data:")) else { + continue; + }; + let payload = payload.trim(); + if payload.is_empty() || payload == "[DONE]" { + continue; + } + let v: Value = match serde_json::from_str(payload) { + Ok(v) => v, + Err(e) => { + log::warn!("[llm] SSE parse skip: {e}; payload preview: {}", safe_str_slice(payload, 80)); + continue; + } + }; + if let Some(delta) = v["choices"][0]["delta"]["content"].as_str() { + if !delta.is_empty() { + full_text.push_str(delta); + on_delta(delta); + } + } + } + } + } + + log::info!( + "[llm] HTTP 200 stream done; total chars={}", + full_text.chars().count() + ); + + if full_text.is_empty() { + return Err(LLMError::InvalidResponse { + status: 200, + body: "empty stream".to_string(), + }); + } + Ok(full_text) + } } /// Slice up to `end` bytes off `s`, but don't split a UTF-8 codepoint. @@ -648,13 +780,6 @@ pub mod prompts { .to_string() } - /// QA user prompt — 把选中文本 + 口述提问拼成 chat user 消息。 - pub fn qa_user_prompt(question: &str, selection: &str) -> String { - format!( - "选中文本:\n\"\"\"\n{selection}\n\"\"\"\n\n我的语音提问:\n「{question}」" - ) - } - /// 翻译模式 system prompt — 用户在「翻译」页选定的目标语言(内置 15 种自然语言原生名)。 /// LLM 自己理解("繁体中文"/"English"/"美式英文"/"日本語" 都行)。 /// 此 prompt 之上还有 working_languages_premise 拼出的"# 上下文"前提。 diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index abe11334..b12e1d62 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -464,3 +464,14 @@ pub struct TodayMetrics { pub avg_latency_ms: u64, pub total_duration_ms: u64, } + + +/// 划词追问浮窗里一条对话消息。多轮提问会累积成 Vec, +/// 整段送给 LLM 维持上下文。详见 issue #118 v2。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QaChatMessage { + /// "user" | "assistant" — 直接对应 OpenAI 消息 role 字段。 + pub role: String, + pub content: String, +} diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index d188dadd..65a114b4 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -51,7 +51,7 @@ "url": "index.html?window=qa", "title": "OpenLess QA", "width": 380, - "height": 280, + "height": 440, "decorations": false, "transparent": true, "shadow": true, diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 9039340c..1ef3ab1d 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -38,10 +38,18 @@ export const en: typeof zhCN = { thinking: 'Thinking…', error: 'Something went wrong. Please try again.', errorRetry: 'Retry', + errorRetryHint: 'Press Option again to retry.', pinTooltip: 'Pin (stay open)', unpinTooltip: 'Unpin', closeTooltip: 'Close', selectionPreview: 'From selected text:', + emptyTitle: 'Press Option to ask', + emptyDesc: 'Select text in any app, press Option once to start recording, press it again to submit. Answers appear here. You can ask follow-up questions in the same panel.', + recordingHint: 'Recording… press Option again to submit', + statusIdle: 'Press Option to ask', + statusRecording: 'Recording', + statusThinking: 'Thinking', + statusError: 'Error', }, nav: { overview: 'Overview', @@ -204,14 +212,14 @@ export const en: typeof zhCN = { selectionAsk: { kicker: 'SELECTION ASK', title: 'Selection Ask', - desc: 'Select text in any app, press the hotkey, ask a question by voice, and the AI answer appears in a floating panel above the recording capsule.', + desc: 'Select text in any app, press Cmd+Shift+; to open the panel, then press Option to record a question. Multi-turn follow-ups stay in the same panel until you close it.', statusEnabled: 'Enabled', statusDisabled: 'Disabled', hotkey: { - title: 'Trigger hotkey', - desc: 'When set, selecting text in any app and pressing this combo opens a panel and starts recording. Press again to stop and generate the answer. Pick "Disabled" to turn the feature off.', + title: 'Hotkey to open the panel', + desc: 'This only opens / closes the panel. Recording inside the panel reuses your main Option dictation key. Pick "Disabled" to turn the feature off.', optionDisabled: 'Disabled', - chordWarning: '⚠️ Modifier-only chord (Cmd+Option / Ctrl+Alt): both modifiers held without any other key, then released. Fewer keys, but higher false-trigger rate than 3-key combos. Chord backend requires v1.2.9+; older builds, please pick a 3-key combo.', + chordWarning: '', }, history: { title: 'Save history', @@ -219,13 +227,13 @@ export const en: typeof zhCN = { }, howto: { title: 'How to use', - step1: 'Select text in any app (browser, Mail, IDE, PDF reader…).', - step2: 'Press「{{hotkey}}」— the panel appears at the bottom of the screen and recording starts.', - step3: 'Ask your question into the mic — e.g. "what does this mean", "explain in plain words", "how is it different from X".', - step4: 'Press「{{hotkey}}」again to stop. The system sends your selection + voice question to the LLM.', - step5: 'The answer renders as markdown in the panel. Press Esc or click outside to dismiss; pin (📌, top-right) to keep it open until manually closed.', - windowTitle: 'Panel position + lifecycle', - windowDesc: 'The panel sits 8px above the recording capsule by default, sized 380×280. Auto-closes after 30s of inactivity (unless pinned). It scrolls if the answer is long.', + step1: 'Press「{{hotkey}}」any time to open the panel (no need to select first).', + step2: 'Select text in any app (browser, Mail, IDE, PDF reader…).', + step3: 'Press **Option** (rightOption — same key you use for dictation) to start recording. Press Option again to stop and submit; the answer shows in the panel.', + step4: 'Keep asking follow-ups in the same panel: press Option again to record, press again to submit. You can re-select different text for the next turn or skip selection entirely.', + step5: 'Press Esc or the ✕ in the top-right to close — closing wipes the multi-turn history. Pressing「{{hotkey}}」again starts a fresh conversation.', + windowTitle: 'Position, drag, and pin', + windowDesc: 'The panel first opens above the recording capsule. The toolbar is draggable; once moved, it stays at the dragged position for the rest of the app session. The 📌 pin keeps the window open across follow-up turns.', privacyTitle: 'Privacy contract', privacyDesc: 'Selected text lives only in memory until the panel closes — it is **never** written to the history archive (Save history toggles only Q&A metadata). Selections over 4000 chars are truncated to head+tail of 2000 each before being sent. LLM calls go through your configured ARK / DeepSeek / OpenAI-compatible endpoint.', }, diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index fa5efc96..476a5b7d 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -36,10 +36,18 @@ export const zhCN = { thinking: '思考中…', error: '出错了,请稍后再试。', errorRetry: '重试', + errorRetryHint: '再按 Option 重新提问。', pinTooltip: '固定(不自动关闭)', unpinTooltip: '取消固定', closeTooltip: '关闭', selectionPreview: '基于选中文本:', + emptyTitle: '按 Option 开始提问', + emptyDesc: '在任意 app 选中一段文字后,按一次 Option 开始录音,再按一次结束并提交。回答会显示在这里,可以连续多轮追问。', + recordingHint: '录音中…再按一次 Option 结束并提问', + statusIdle: '按 Option 提问', + statusRecording: '录音中', + statusThinking: '思考中', + statusError: '出错了', }, nav: { overview: '概览', @@ -202,14 +210,14 @@ export const zhCN = { selectionAsk: { kicker: 'SELECTION ASK', title: '划词追问', - desc: '选中任意 app 里的一段文字,按下快捷键,对着麦克风口头提问,AI 答案会以浮窗显示在屏幕底部录音胶囊正上方。', + desc: '选中任意 app 里的一段文字,按 Cmd+Shift+; 弹出浮窗,再按 Option 录音提问。支持多轮追问,浮窗一直保留直到你手动关。', statusEnabled: '已启用', statusDisabled: '未启用', hotkey: { - title: '触发快捷键', - desc: '选了某组按键后,在任意 app 选中文字时按下,浮窗即可弹出并开始录音;再按一次停止录音并生成答案。选「不启用」则关闭整个功能。', + title: '弹出浮窗的快捷键', + desc: '只决定「打开 / 关闭」浮窗。浮窗里录音 / 提问统一用 Option(与你的主听写键复用)。选「不启用」则关闭整个功能。', optionDisabled: '不启用', - chordWarning: '⚠️ 双修饰键 chord(Cmd+Option / Ctrl+Alt)触发条件:两个修饰键同时按下、不按其他键、再松开两个键。优点是按键少;缺点是误触率比 3 键组合高(你想按 Cmd+Option+某字母 但还没按字母时容易触发)。后端 chord 监听需要 v1.2.9+ 才支持,更早版本请选 3 键组合。', + chordWarning: '', }, history: { title: '保存历史', @@ -217,13 +225,13 @@ export const zhCN = { }, howto: { title: '使用方法', - step1: '在任意 app(浏览器、Mail、IDE、PDF reader…)选中一段文字。', - step2: '按下「{{hotkey}}」——浮窗在屏幕底部弹出,同时进入语音录音状态。', - step3: '对着麦克风提问——比如"这是什么意思 / 用大白话解释 / 跟 X 有什么区别 / 把它拆成几个要点"。', - step4: '再按一次「{{hotkey}}」停止录音。系统会把「你选中的文字 + 你的语音问题」一起送给大模型生成答案。', - step5: '答案以 markdown 格式显示在浮窗里。读完按 Esc 或点击外部即可关闭;右上角 📌 钉住则会一直保留直到你手动关。', - windowTitle: '浮窗位置 + 生命周期', - windowDesc: '浮窗默认在录音胶囊正上方 8px 处,宽 380×高 280。30 秒未操作自动关闭(钉住的不算)。窗口有自己的滚动条,长答案可以滚动阅读。', + step1: '按「{{hotkey}}」在任意时刻打开浮窗(不需要先选文字)。', + step2: '在任意 app(浏览器、Mail、IDE、PDF reader…)里选中一段文字。', + step3: '按一下 **Option**(rightOption,跟你录音用的同一个键)——开始录音;再按一下 Option,停止并提交,AI 答案显示在浮窗里。', + step4: '同一个浮窗里可继续多轮追问:再按 Option 录音 → 再按 Option 提交。可以重新选文字让下一轮带新选区,也可以不选直接对话。', + step5: '按 Esc 或浮窗右上角 ✕ 关闭,关闭即清空所有多轮历史。再按「{{hotkey}}」就是一段新的对话。', + windowTitle: '浮窗位置 + 拖动 + 钉住', + windowDesc: '浮窗第一次打开在屏幕底部录音胶囊正上方;标题栏可拖动,移到任意位置后下一次打开会保留位置(同一次启动期间)。右上角 📌 钉住时即使重新提问也保留窗口;不钉住按 Esc 即关。', privacyTitle: '隐私契约', privacyDesc: '选中的文本只在内存里活到浮窗关闭,**绝不**写入历史存档(保存历史开关只控制问答 metadata);超过 4000 字符会截首+尾各 2000 后再上送大模型,避免泄露太多。LLM 调用走你已配的 ARK / DeepSeek 等 OpenAI 兼容 endpoint。', }, diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index a8229594..2ddf3cda 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -100,17 +100,31 @@ export interface UserPreferences { } /** Rust 通过 `qa:state` 事件下发的 payload。 - * 与 issue #118 的契约一致——字段名采用 snake_case,与后端 JSON 直接对齐。 */ -export type QaStateKind = 'loading' | 'answer' | 'error'; + * v2 (issue #118 v2):支持多轮对话,messages 数组每次由后端整段下发(单一可信源)。 + * v2.1:开 `stream:true`,LLM 答案逐 chunk 通过 `answer_delta` 事件推前端边渲染。 */ +export type QaStateKind = + | 'idle' + | 'recording' + | 'thinking' + | 'answer_delta' + | 'answer' + | 'error'; + +export interface QaChatMessage { + role: 'user' | 'assistant'; + content: string; +} export interface QaStatePayload { kind: QaStateKind; - /** loading 状态下的选区前缀(前 60 字,已截断)。 */ - selection_preview?: string; - /** answer 状态下的 markdown 字符串。 */ - answer_md?: string; - /** error 状态下的错误提示。 */ + /** 后端权威:当前已有的多轮对话历史(user → assistant 交替)。answer 事件带完整版。 */ + messages?: QaChatMessage[]; + /** recording 状态时附带的选区预览(前 60 字)。 */ + selection_preview?: string | null; + /** error 状态时附带的提示。 */ error?: string; + /** answer_delta 事件时附带的本帧增量字符串。 */ + chunk?: string; } /** 内置语言列表 — 前端 Settings UI 用,后端只接收原生名字符串拼 prompt。 diff --git a/openless-all/app/src/pages/QaPanel.tsx b/openless-all/app/src/pages/QaPanel.tsx index 6a677a93..d1739439 100644 --- a/openless-all/app/src/pages/QaPanel.tsx +++ b/openless-all/app/src/pages/QaPanel.tsx @@ -1,64 +1,105 @@ -// QaPanel.tsx — 划词语音问答浮窗。详见 issue #118。 +// QaPanel.tsx — 划词追问浮窗 v2(issue #118 v2)。 // -// 触发链路: -// 1) 用户选中文本 → 按 Cmd+Shift+;(默认)→ 后端打开本窗口(label="qa") -// 并发 `qa:state { kind: "loading", selection_preview }` 事件,开始录音。 -// 2) 用户提问完毕,再次按热键 → 后端转写 + LLM 回答 → 发 -// `qa:state { kind: "answer", answer_md }`,本组件用 marked 渲染。 -// 3) 出错 → `qa:state { kind: "error", error }`,显示红色文案 + 重试按钮。 +// 触发链路(v2 双 hotkey): +// 1) 用户按 Cmd+Shift+;(默认)→ 后端 toggle 浮窗可见性。 +// 显示时发 `qa:state { kind: "idle", messages: [] }`。 +// 2) 浮窗可见时,用户按 rightOption(主听写键的复用)→ 录音; +// 再按一次 → ASR + LLM;后端推 `qa:state { kind: "answer", messages: [...] }`。 +// 3) 答案后用户可继续按 Option 多轮提问,messages 累积。 // -// 关闭时机(任一): -// - Esc / Close 按钮 / 点击窗口外(除非 Pin)→ qa_window_dismiss() -// - 30s 超时(除非 Pin)→ qa_window_dismiss() -// - 后端发 `qa:dismiss` 事件 → 直接关窗 +// 关闭:Esc / Close 按钮 / 再按 Cmd+Shift+; → qa_window_dismiss → 后端清历史 + 隐藏窗口。 +// **不再自动关**(v1 的 blur / 30s 超时去掉):用户多轮思考时浮窗保持。 import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { marked } from 'marked'; import { isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc'; -import type { QaStatePayload } from '../lib/types'; +import type { QaChatMessage, QaStatePayload } from '../lib/types'; -const AUTO_DISMISS_MS = 30_000; const SELECTION_PREVIEW_MAX = 60; -// marked 配置:开启换行符识别,关闭 mangle/headerIds(v11 已默认关闭)。 marked.setOptions({ gfm: true, breaks: true }); +type Status = 'idle' | 'recording' | 'thinking' | 'error'; + export function QaPanel() { const { t } = useTranslation(); - const [payload, setPayload] = useState({ kind: 'loading' }); + const [messages, setMessages] = useState([]); + const [status, setStatus] = useState('idle'); + const [errorMsg, setErrorMsg] = useState(''); + const [selectionPreview, setSelectionPreview] = useState(''); const [pinned, setPinned] = useState(false); - const pinnedRef = useRef(false); + /** 流式 LLM 答案:answer_delta 累积、answer 事件来时清空(最终内容已落到 messages)。 */ + const [streamingAnswer, setStreamingAnswer] = useState(''); + const tRef = useRef(t); + tRef.current = t; - // ── 后端事件订阅 ──────────────────────────────────────────────────── + // ── 后端事件订阅(mount 时订阅一次,永不重订阅)────────────────── useEffect(() => { if (!isTauri) return; let unlistenState: (() => void) | undefined; let unlistenDismiss: (() => void) | undefined; let cancelled = false; (async () => { - const { listen } = await import('@tauri-apps/api/event'); - const stateHandle = await listen('qa:state', event => { - // 后端在 session 结束(含 cancel / 静默 / 完成)时会再发一条 kind:"idle"。 - // 它的语义是"会话状态机回到 Idle",**不**应替换 UI(pinned 用户希望继续看 answer)。 - // 不 pinned 时后端紧接着自己 hide 窗口,前端拿到 idle 也无妨。 - const kind = (event.payload as { kind?: string }).kind; - if (kind === 'idle') return; - setPayload(event.payload); - }); - const dismissHandle = await listen('qa:dismiss', () => { - // 后端要求关闭:直接转发到 dismiss 命令;同时 reset pin 状态 - // 让用户下次开新窗口拿到默认 unpinned。 - pinnedRef.current = false; - setPinned(false); - void qaWindowDismiss(); - }); - if (cancelled) { - stateHandle(); - dismissHandle(); - } else { - unlistenState = stateHandle; - unlistenDismiss = dismissHandle; + try { + const { listen } = await import('@tauri-apps/api/event'); + const stateHandle = await listen('qa:state', event => { + const payload = event.payload; + if (payload.messages) { + setMessages(payload.messages); + } + switch (payload.kind) { + case 'idle': + setStatus('idle'); + setSelectionPreview(''); + setErrorMsg(''); + setStreamingAnswer(''); + break; + case 'recording': + setStatus('recording'); + setSelectionPreview(payload.selection_preview ?? ''); + setErrorMsg(''); + setStreamingAnswer(''); + break; + case 'thinking': + setStatus('thinking'); + setSelectionPreview(''); + setErrorMsg(''); + setStreamingAnswer(''); + break; + case 'answer_delta': + // 流式增量。仍保持 thinking 状态——直到 answer 事件落定后才回 idle。 + if (payload.chunk) { + setStreamingAnswer(prev => prev + payload.chunk); + } + break; + case 'answer': + setStatus('idle'); + setSelectionPreview(''); + setErrorMsg(''); + // messages 已被上面的 setMessages 落定,清掉流式 buffer 避免和最终气泡重影。 + setStreamingAnswer(''); + break; + case 'error': + setStatus('error'); + setErrorMsg(payload.error ?? tRef.current('qa.error')); + setStreamingAnswer(''); + break; + } + }); + const dismissHandle = await listen('qa:dismiss', () => { + setPinned(false); + void qaWindowDismiss(); + }); + if (cancelled) { + stateHandle(); + dismissHandle(); + } else { + unlistenState = stateHandle; + unlistenDismiss = dismissHandle; + } + } catch (error) { + console.error('[QaPanel] listener setup failed', error); } })(); return () => { @@ -80,28 +121,8 @@ export function QaPanel() { return () => window.removeEventListener('keydown', onKey, true); }, []); - // ── 失焦自动关闭(除非 Pin)──────────────────────────────────────── - useEffect(() => { - const onBlur = () => { - if (pinnedRef.current) return; - void qaWindowDismiss(); - }; - window.addEventListener('blur', onBlur); - return () => window.removeEventListener('blur', onBlur); - }, []); - - // ── 30s 自动关闭(除非 Pin),payload 变化或 pin 切换时重置 ────── - useEffect(() => { - if (pinned) return; - const timer = window.setTimeout(() => { - if (!pinnedRef.current) void qaWindowDismiss(); - }, AUTO_DISMISS_MS); - return () => window.clearTimeout(timer); - }, [payload, pinned]); - const onTogglePin = () => { const next = !pinned; - pinnedRef.current = next; setPinned(next); void qaWindowPin(next); }; @@ -110,12 +131,35 @@ export function QaPanel() { void qaWindowDismiss(); }; + // ── 自动滚动到底(新消息进来时)──────────────────────────────────── + const scrollRef = useRef(null); + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }, [messages, status]); + return (
-
- +
+ {messages.length === 0 && status === 'idle' && } + {messages.length === 0 && status === 'recording' && ( + + )} + + {status === 'recording' && messages.length > 0 && ( + + )} + {streamingAnswer && ( + + )} + {status === 'thinking' && !streamingAnswer && ( + + )} + {status === 'error' && }
+
); } @@ -130,15 +174,16 @@ interface ToolbarProps { function Toolbar({ pinned, onTogglePin, onClose }: ToolbarProps) { const { t } = useTranslation(); + // 拖动靠 NSWindow.movableByWindowBackground=YES(lib.rs::make_qa_window_draggable_macos) + // 在 AppKit 层处理。前端不需要 onMouseDown / data-tauri-drag-region。 return (
-
+
- {/* Pin 图标 */} ['t']; -} - -function Body({ payload, t }: BodyProps) { - if (payload.kind === 'loading') { - return ; - } - if (payload.kind === 'error') { - return ; - } - return ; +function EmptyHint({ t }: { t: ReturnType['t'] }) { + return ( +
+
+ {t('qa.emptyTitle')} +
+
+ {t('qa.emptyDesc')} +
+
+ ); } -function LoadingView({ preview, t }: { preview: string | undefined; t: BodyProps['t'] }) { - const truncated = useMemo(() => truncate(preview ?? '', SELECTION_PREVIEW_MAX), [preview]); +function RecordingHeader({ + preview, + t, +}: { + preview: string; + t: ReturnType['t']; +}) { + const truncated = useMemo(() => truncate(preview, SELECTION_PREVIEW_MAX), [preview]); return ( -
+
{truncated && (
@@ -214,71 +263,219 @@ function LoadingView({ preview, t }: { preview: string | undefined; t: BodyProps {truncated}
)} -
- - - -
-
- {t('qa.thinking')} +
+ + {t('qa.recordingHint')}
); } -function SkeletonLine({ width }: { width: string }) { - return ( -
- ); -} - -function ErrorView({ message, t }: { message: string; t: BodyProps['t'] }) { - // 重试按钮:关掉浮窗,让用户重按 hotkey。详见 issue #118。 - const onRetry = () => { - void qaWindowDismiss(); - }; +function MessageList({ messages }: { messages: QaChatMessage[] }) { return (
-
{message}
- + {messages.map((m, i) => ( + + ))}
); } -function AnswerView({ markdown }: { markdown: string }) { - // marked v11 同步调用返回 string;启用 GFM + breaks。 - // 注意:markdown 来自我们自己的后端 → LLM,链路可信,未额外 sanitize。 - // 如未来引入用户自由文本拼装到 prompt,需要补 DOMPurify。 +function MessageRow({ message }: { message: QaChatMessage }) { + // 钩子顺序与 message.role 无关:先无条件 useMemo(user 消息时 html 不渲染但计算无害)。 const html = useMemo(() => { + if (message.role !== 'assistant') return ''; try { - return marked.parse(markdown, { async: false }) as string; + return marked.parse(message.content, { async: false }) as string; } catch (error) { console.error('[qa] failed to render markdown', error); - return ''; + return message.content; } - }, [markdown]); + }, [message.content, message.role]); + + if (message.role === 'user') { + // 第一轮可能含 "# 选区原文 ... # 我的问题 ..." → 抽出问题部分单独显示, + // 选区作为引用块淡显在上面。 + const { selection, question } = splitFirstTurnUser(message.content); + return ( +
+ {selection && ( +
+ + {truncate(selection, 120)} + +
+ )} +
{question}
+
+ ); + } return (
); } +/** 流式 LLM 答案的 in-progress 气泡。跟 assistant 最终气泡同款样式,结尾加一颗 + * 闪烁的 caret 让用户看出还在生成。markdown 边到边渲染,未闭合的代码块不会炸 — + * marked 在不完整输入上是宽容的(开 token 没找到闭 token 就当 inline)。 */ +function StreamingAssistantBubble({ markdown }: { markdown: string }) { + const html = useMemo(() => { + try { + return marked.parse(markdown, { async: false }) as string; + } catch (error) { + console.error('[qa] failed to render streaming markdown', error); + return markdown; + } + }, [markdown]); + return ( +
+
+ +
+ ); +} + +function splitFirstTurnUser(content: string): { selection: string; question: string } { + // 后端拼法:`# 选区原文\n{sel}\n\n# 我的问题\n{q}`。简单 split,对齐 coordinator.rs 的写法。 + const m = content.match(/^# 选区原文\n([\s\S]*?)\n\n# 我的问题\n([\s\S]+)$/); + if (!m) return { selection: '', question: content }; + return { selection: m[1].trim(), question: m[2].trim() }; +} + +function TurnIndicator({ + kind, + preview, + t, +}: { + kind: 'recording' | 'thinking'; + preview?: string; + t: ReturnType['t']; +}) { + if (kind === 'recording') { + const truncated = preview ? truncate(preview, SELECTION_PREVIEW_MAX) : ''; + return ( +
+ {truncated && ( +
+ + {t('qa.selectionPreview')} + + {truncated} +
+ )} +
+ + {t('qa.recordingHint')} +
+
+ ); + } + return ( +
+
+ +
+
+ {t('qa.thinking')} +
+
+ ); +} + +function ErrorRow({ + message, + t, +}: { + message: string; + t: ReturnType['t']; +}) { + return ( +
+
{message}
+
{t('qa.errorRetryHint')}
+
+ ); +} + +function StatusBar({ + status, + t, +}: { + status: Status; + t: ReturnType['t']; +}) { + let label = ''; + let dotColor = 'transparent'; + switch (status) { + case 'idle': + label = t('qa.statusIdle'); + dotColor = 'rgba(0,0,0,0.18)'; + break; + case 'recording': + label = t('qa.statusRecording'); + dotColor = 'var(--ol-err)'; + break; + case 'thinking': + label = t('qa.statusThinking'); + dotColor = 'var(--ol-blue)'; + break; + case 'error': + label = t('qa.statusError'); + dotColor = 'var(--ol-err)'; + break; + } + return ( +
+ + {label} +
+ ); +} + +function SkeletonLine({ width }: { width: string }) { + return ( +
+ ); +} + function truncate(text: string, max: number): string { if (text.length <= max) return text; return `${text.slice(0, max)}…`; @@ -293,10 +490,12 @@ const shellStyle: CSSProperties = { flexDirection: 'column', borderRadius: 14, overflow: 'hidden', - background: 'rgba(255, 255, 255, 0.85)', + // 浮窗 focus:false 在 macOS 上会让 backdrop-filter 不工作(透到桌面文字),所以 + // 改成接近不透明的实色背景。blur 仅作锦上添花,不再依赖它保证可读性。 + background: 'rgba(255, 255, 255, 0.97)', backdropFilter: 'blur(24px) saturate(180%)', WebkitBackdropFilter: 'blur(24px) saturate(180%)', - border: '0.5px solid rgba(255, 255, 255, 0.7)', + border: '0.5px solid rgba(0, 0, 0, 0.08)', boxShadow: 'var(--ol-shadow-lg)', fontFamily: 'var(--ol-font-sans)', color: 'var(--ol-ink)', @@ -310,9 +509,7 @@ const toolbarStyle: CSSProperties = { padding: '0 8px', borderBottom: '0.5px solid rgba(0, 0, 0, 0.06)', flexShrink: 0, - // 让用户可以拖动整个浮窗(macOS / Win 通用)。 - // @ts-expect-error: vendor prefix not in CSSProperties typing - WebkitAppRegion: 'drag', + cursor: 'grab', }; const iconBtnBaseStyle: CSSProperties = { @@ -326,15 +523,28 @@ const iconBtnBaseStyle: CSSProperties = { cursor: 'default', padding: 0, transition: 'background 0.12s ease-out, color 0.12s ease-out', - // @ts-expect-error: vendor prefix not in CSSProperties typing - WebkitAppRegion: 'no-drag', }; const contentStyle: CSSProperties = { flex: 1, minHeight: 0, overflow: 'auto', - padding: '14px 18px', + padding: '14px 16px', + display: 'flex', + flexDirection: 'column', + gap: 12, +}; + +const emptyHintStyle: CSSProperties = { + margin: 'auto 0', + textAlign: 'center', + padding: '0 8px', +}; + +const recordingHeaderStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 8, }; const previewStyle: CSSProperties = { @@ -346,61 +556,119 @@ const previewStyle: CSSProperties = { border: '0.5px solid rgba(0, 0, 0, 0.06)', }; -const retryBtnStyle: CSSProperties = { - alignSelf: 'flex-start', - padding: '5px 12px', - fontSize: 12, - fontWeight: 500, - border: '0.5px solid var(--ol-line-strong)', - borderRadius: 6, - background: 'var(--ol-surface)', - color: 'var(--ol-ink-2)', - cursor: 'default', - fontFamily: 'inherit', +const previewInlineStyle: CSSProperties = { + ...previewStyle, + marginBottom: 4, }; -const answerStyle: CSSProperties = { +const turnIndicatorStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 6, +}; + +const userBubbleStyle: CSSProperties = { + maxWidth: '80%', + padding: '8px 12px', + borderRadius: 14, + borderBottomRightRadius: 4, + background: 'var(--ol-blue)', + color: '#fff', + fontSize: 13, + lineHeight: 1.55, + wordBreak: 'break-word', +}; + +const selectionQuoteStyle: CSSProperties = { + maxWidth: '80%', + padding: '6px 10px', + borderRadius: 10, + background: 'rgba(0,0,0,0.04)', + border: '0.5px solid rgba(0,0,0,0.06)', + fontSize: 11.5, + color: 'var(--ol-ink-3)', + fontStyle: 'italic', + lineHeight: 1.5, +}; + +const assistantBubbleStyle: CSSProperties = { + maxWidth: '92%', + padding: '8px 12px', + borderRadius: 14, + borderBottomLeftRadius: 4, + background: 'rgba(0,0,0,0.04)', fontSize: 13, lineHeight: 1.6, color: 'var(--ol-ink)', - wordWrap: 'break-word', + wordBreak: 'break-word', + alignSelf: 'flex-start', +}; + +const errorRowStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 4, + padding: '8px 12px', + borderRadius: 10, + background: 'rgba(220,38,38,0.06)', + border: '0.5px solid rgba(220,38,38,0.18)', +}; + +const recordingDotStyle: CSSProperties = { + width: 8, + height: 8, + borderRadius: '50%', + background: 'var(--ol-err)', + animation: 'qa-pulse 1.2s ease-in-out infinite', +}; + +const statusBarStyle: CSSProperties = { + height: 28, + flexShrink: 0, + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '0 14px', + borderTop: '0.5px solid rgba(0, 0, 0, 0.06)', + background: 'rgba(255,255,255,0.4)', }; -// 注入全局 keyframes + .qa-answer 内 markdown 排版样式。 -// 不放 styles/global.css 是因为只有这个窗口需要。 const globalCss = ` @keyframes qa-skeleton { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } -.qa-answer p { margin: 0 0 8px; } +@keyframes qa-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} +.qa-answer p { margin: 0 0 6px; } .qa-answer p:last-child { margin-bottom: 0; } .qa-answer h1, .qa-answer h2, -.qa-answer h3 { margin: 12px 0 6px; font-weight: 600; line-height: 1.35; } -.qa-answer h1 { font-size: 16px; } +.qa-answer h3 { margin: 10px 0 5px; font-weight: 600; line-height: 1.35; } +.qa-answer h1 { font-size: 15px; } .qa-answer h2 { font-size: 14px; } .qa-answer h3 { font-size: 13px; } .qa-answer ul, -.qa-answer ol { margin: 0 0 8px; padding-left: 20px; } +.qa-answer ol { margin: 0 0 6px; padding-left: 18px; } .qa-answer li { margin: 2px 0; } .qa-answer code { font-family: var(--ol-font-mono); font-size: 12px; padding: 1px 5px; border-radius: 4px; background: rgba(0,0,0,0.05); } -.qa-answer pre { margin: 0 0 8px; padding: 10px 12px; +.qa-answer pre { margin: 0 0 6px; padding: 8px 10px; border-radius: 8px; background: rgba(0,0,0,0.05); overflow-x: auto; } .qa-answer pre code { padding: 0; background: transparent; } .qa-answer a { color: var(--ol-blue); text-decoration: none; } .qa-answer a:hover { text-decoration: underline; } -.qa-answer blockquote { margin: 0 0 8px; padding: 4px 0 4px 10px; +.qa-answer blockquote { margin: 0 0 6px; padding: 4px 0 4px 8px; border-left: 2px solid rgba(0,0,0,0.15); color: var(--ol-ink-3); } .qa-answer hr { border: 0; border-top: 0.5px solid rgba(0,0,0,0.10); - margin: 10px 0; } + margin: 8px 0; } `; -// 单次注入。重复挂载(HMR)时会被同 id 替换。 if (typeof document !== 'undefined' && !document.getElementById('qa-panel-style')) { const tag = document.createElement('style'); tag.id = 'qa-panel-style'; diff --git a/openless-all/app/src/pages/SelectionAsk.tsx b/openless-all/app/src/pages/SelectionAsk.tsx index 436d2834..3b066169 100644 --- a/openless-all/app/src/pages/SelectionAsk.tsx +++ b/openless-all/app/src/pages/SelectionAsk.tsx @@ -20,13 +20,13 @@ interface QaHotkeyPreset { } const QA_HOTKEY_PRESETS: readonly QaHotkeyPreset[] = [ - // 双修饰键 chord(Cmd+Option / Ctrl+Alt)默认排第一——用户偏好的纯组合键。 - // 后端实现需要 CGEventTap 的"双修饰键按下后无其他键插入即释放"模式(待后端补)。 - { id: 'cmd+option', label: 'Cmd+Option', binding: { primary: '', modifiers: ['cmd', 'option'] } }, - { id: 'cmd+shift+;', label: 'Cmd+Shift+;', binding: { primary: ';', modifiers: ['cmd', 'shift'] } }, - { id: 'cmd+option+;', label: 'Cmd+Option+;', binding: { primary: ';', modifiers: ['cmd', 'option'] } }, - { id: 'cmd+shift+/', label: 'Cmd+Shift+/', binding: { primary: '/', modifiers: ['cmd', 'shift'] } }, - { id: 'cmd+option+/', label: 'Cmd+Option+/', binding: { primary: '/', modifiers: ['cmd', 'option'] } }, + // v2 改成 panel toggle hotkey(不再触发录音)。Option 不能出现在这条 hotkey 里—— + // 浮窗一旦可见,rightOption 边沿就被 QA 路由抢走了,主听写转给 Option 也共用同一个键。 + // 只留 Cmd+Shift+... 这种零冲突组合。详见 issue #118 v2。 + { id: 'cmd+shift+;', label: 'Cmd+Shift+;', binding: { primary: ';', modifiers: ['cmd', 'shift'] } }, + { id: 'cmd+shift+/', label: 'Cmd+Shift+/', binding: { primary: '/', modifiers: ['cmd', 'shift'] } }, + { id: 'cmd+shift+.', label: 'Cmd+Shift+.', binding: { primary: '.', modifiers: ['cmd', 'shift'] } }, + { id: 'cmd+shift+,', label: 'Cmd+Shift+,', binding: { primary: ',', modifiers: ['cmd', 'shift'] } }, ] as const; function bindingToPresetId(binding: QaHotkeyBinding | null): string { @@ -67,10 +67,14 @@ export function SelectionAsk() { } const preset = QA_HOTKEY_PRESETS.find(p => p.id === id); if (!preset) return; + // 先让后端真注册成功 → 再写盘 prefs。否则 prefs 跟实际生效的快捷键脱节, + // 会让用户陷入"UI 改了但按了没反应"的迷雾(issue #118 v1 实测过)。 try { await setQaHotkey(preset.binding); } catch (error) { console.error('[selectionAsk] failed to set qa hotkey', error); + // 后端拒绝绑定(如不支持的主键)→ 不写盘,UI 下次 render 仍显示旧值。 + return; } await savePrefs({ ...prefs, qaHotkey: preset.binding }); }; @@ -136,22 +140,6 @@ export function SelectionAsk() { ))} - {currentId === 'cmd+option' && ( -
- {t('selectionAsk.hotkey.chordWarning')} -
- )} {/* 2. 历史保存 */}