Merge pull request #119 from appergb/feat/selection-voice-qa

feat: 划词语音问答(Selection + Voice Q&A)— closes #118
This commit is contained in:
TRIP authored and GitHub committed 2026-05-01 19:32:18 +08:00
commit b79a1e76b4
23 files changed
+3329 -14

No files matched your search

+75
View File
@@ -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 加 `<thinking>` 块要求) | 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 流式输出已稳定(已完成 ✅)
+15 -2
View File
@@ -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",
+1
View File
@@ -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"
@@ -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",
+37 -1
View File
@@ -12,7 +12,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<Coordinator>>;
@@ -441,6 +441,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)]
+781 -4
View File
@@ -26,7 +26,9 @@ use crate::persistence::{
};
use crate::polish::{OpenAICompatibleConfig, OpenAICompatibleLLMProvider};
use crate::qa_hotkey::{QaHotkeyError, 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,54 @@ 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<Option<QaHotkeyMonitor>>,
/// QA 单独的 session 状态,与 dictation 的 SessionPhase 不冲突。
qa_state: Mutex<QaSessionState>,
/// QA 用的 ASR 句柄(始终是 Volcengine 流式)。
qa_asr: Mutex<Option<Arc<VolcengineStreamingASR>>>,
/// QA 用的 Recorder 句柄。
qa_recorder: Mutex<Option<Recorder>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum QaPhase {
Idle,
Recording,
Processing,
}
struct QaSessionState {
phase: QaPhase,
cancelled: bool,
selection: Option<SelectionContext>,
front_app: Option<String>,
/// 用于忽略迟到的 RMS / runtime error。
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<crate::types::QaChatMessage>,
}
impl Default for QaSessionState {
fn default() -> Self {
Self {
phase: QaPhase::Idle,
cancelled: false,
selection: None,
front_app: None,
session_id: 0,
pinned: false,
panel_visible: false,
messages: Vec::new(),
}
}
}
impl Coordinator {
@@ -129,6 +179,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 +205,101 @@ 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。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;
};
// 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::<QaHotkeyEvent>();
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+;")。
/// `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 浮窗时调。等价于:取消任何进行中的录音 +
/// 清空多轮对话历史 + 隐藏窗口。详见 issue #118 v2。
pub fn qa_window_dismiss(&self) {
close_qa_panel(&self.inner);
}
/// 用户点 📌 切换 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 +436,179 @@ fn hotkey_supervisor_loop(inner: Arc<Inner>) {
}
}
// ─────────────────────────── QA hotkey supervisor ───────────────────────────
fn qa_hotkey_supervisor_loop(inner: Arc<Inner>) {
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;
}
// 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::<QaHotkeyEvent>();
let (init_tx, init_rx) = mpsc::sync_channel::<Result<QaHotkeyMonitor, QaHotkeyError>>(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 on main thread (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<Inner>, rx: mpsc::Receiver<QaHotkeyEvent>) {
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<Inner>) {
// 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<Inner>) {
let phase = inner.qa_state.lock().phase;
log::info!("[coord] QA option 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 open_qa_panel(inner: &Arc<Inner>) {
{
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::<crate::types::QaChatMessage>::new(),
}),
);
}
log::info!("[coord] QA panel opened (awaiting Option to record)");
}
fn close_qa_panel(inner: &Arc<Inner>) {
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<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
while let Ok(evt) = rx.recv() {
let inner_cloned = Arc::clone(&inner);
@@ -319,7 +641,13 @@ fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
async fn handle_pressed_edge(inner: &Arc<Inner>) {
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;
}
}
}
@@ -349,6 +677,11 @@ async fn handle_pressed(inner: &Arc<Inner>) {
async fn handle_released_edge(inner: &Arc<Inner>) {
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;
}
}
@@ -1270,6 +1603,448 @@ fn enabled_hotwords(inner: &Arc<Inner>) -> Vec<DictionaryHotword> {
.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<Inner>) -> Result<(), String> {
{
let mut state = inner.qa_state.lock();
if !state.panel_visible {
// 防御:浮窗没开就被叫到这里说明路由错了,直接退出。
return Ok(());
}
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;
}
// 抓选区。每轮按 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() {
let messages = inner.qa_state.lock().messages.clone();
let _ = app.emit_to(
"qa",
"qa:state",
serde_json::json!({
"kind": "recording",
"selection_preview": selection_preview_text,
"messages": messages,
}),
);
}
// 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<dyn crate::recorder::AudioConsumer> = 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::<Instant>));
const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33;
let level_handler: Arc<dyn Fn(f32) + Send + Sync> = 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 }),
);
}
// 同步把电平推给底部胶囊,让 QA 录音也有跟主听写一致的可视反馈。
emit_capsule(
&inner_for_level,
CapsuleState::Recording,
level,
0,
None,
None,
);
});
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<dyn crate::asr::AudioConsumer> = asr;
let flushed = bridge.attach(target);
log::info!("[coord] QA ASR connected; flushed {flushed} deferred audio bytes");
// 显式弹胶囊到 Recording。level_handler 后续会持续推电平,胶囊里"录音中…"
// 的视觉反馈跟主听写完全一致。
emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None);
Ok(())
}
async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
{
let mut state = inner.qa_state.lock();
if state.phase != QaPhase::Recording {
return Ok(());
}
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",
"qa:state",
serde_json::json!({ "kind": "loading" }),
);
}
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(());
}
// 拼这一轮的 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": "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 (messages_for_llm, front_app) = {
let st = inner.qa_state.lock();
(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_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());
}
};
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",
"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 同时
// 让用户能在历史里翻到这次问答的字面值。详见 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。
/// 浮窗保持可见(v2:错误后用户可以再按 Option 重试);messages 一并送过去
/// 让前端继续渲染历史对话。
fn finish_qa_with_error(inner: &Arc<Inner>, 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 事件给前端,phase 复位。**不关浮窗**(v2:浮窗只在用户
/// Esc/X 或再按 QA hotkey 时才关);多轮对话历史保留。胶囊也即刻收掉。
fn finish_qa_idle_silently(inner: &Arc<Inner>) {
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",
"messages": messages,
}),
);
}
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
let mut state = inner.qa_state.lock();
state.phase = QaPhase::Idle;
state.cancelled = false;
state.selection = None;
}
fn cancel_qa_session(inner: &Arc<Inner>) {
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_chat_dispatch<F>(
messages: &[crate::types::QaChatMessage],
working_languages: &[String],
front_app: Option<&str>,
on_delta: F,
) -> anyhow::Result<String>
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");
}
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_chat_streaming(messages, working_languages, front_app, on_delta)
.await?)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1449,9 +2224,11 @@ fn schedule_capsule_idle(inner: &Arc<Inner>, 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);
}
});
+161 -1
View File
@@ -18,17 +18,24 @@ mod insertion;
mod permissions;
mod persistence;
mod polish;
mod qa_hotkey;
mod recorder;
mod selection;
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, LogicalPosition, Manager, RunEvent, Runtime};
use tauri::{AppHandle, Emitter, LogicalPosition, Manager, RunEvent, Runtime};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@@ -60,6 +67,21 @@ pub fn run() {
let _ = capsule.hide();
}
// 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 会补上");
}
// 主窗口磨砂:macOS 用 NSVisualEffectView,Windows 用 Mica。
// 没这一层的话 transparent: true 让窗口透明 → 背后只是空,不是磨砂。
//
@@ -137,6 +159,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 +199,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,
commands::validate_provider_credentials,
commands::list_provider_models,
restart_app,
@@ -195,6 +223,7 @@ pub fn run() {
RunEvent::Exit => {
let coordinator = app.state::<Arc<coordinator::Coordinator>>();
coordinator.stop_hotkey_listener();
coordinator.stop_qa_hotkey_listener();
}
_ => {}
});
@@ -401,6 +430,137 @@ fn wait_for_app_activation<R: Runtime>(app: &AppHandle<R>) {
#[cfg(not(target_os = "macos"))]
fn wait_for_app_activation<R: Runtime>(_app: &AppHandle<R>) {}
/// 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 = 440.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<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) -> 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<R: tauri::Runtime>(app: &AppHandle<R>, content_kind: &str) {
let Some(window) = app.get_webview_window("qa") else {
log::info!(
"[qa] show 跳过:qa 窗口不存在 (content_kind={content_kind})"
);
return;
};
// 仅首次 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}");
}
let _ = app.emit_to(
"qa",
"qa:state",
serde_json::json!({ "kind": 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<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) {
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<R: tauri::Runtime>(app: &AppHandle<R>) {
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<R: tauri::Runtime>(
+175 -1
View File
@@ -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,6 +100,28 @@ impl OpenAICompatibleLLMProvider {
self.chat_completion(&system_prompt, &user_prompt).await
}
/// 多轮划词追问,**流式**返回。`messages` 包含历史对话(user/assistant 交替),
/// 最后一条必须是新一轮的 user 提问。第一条 user 消息里如果有选区,调用方应在
/// content 里就把选区原文注入。`on_delta` 在每个 SSE chunk 到达时被调;最终返回
/// 拼好的完整字符串(用于写入 messages 历史)。详见 issue #118 v2。
pub async fn answer_chat_streaming<F>(
&self,
messages: &[QaChatMessage],
working_languages: &[String],
front_app: Option<&str>,
on_delta: F,
) -> Result<String, LLMError>
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);
}
self.chat_completion_history_streaming(&system_prompt, messages, on_delta)
.await
}
/// 把转写翻译成 `target_language`(前端从内置语言列表里选出来的原生名)。
/// `working_languages` 与 `front_app` 作为前提注入头部。详见 issue #4 与 #116。
pub async fn translate_to(
@@ -183,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<F>(
&self,
system_prompt: &str,
history: &[QaChatMessage],
on_delta: F,
) -> Result<String, LLMError>
where
F: Fn(&str) + Send + Sync,
{
if self.config.api_key.trim().is_empty() {
return Err(LLMError::MissingCredentials);
}
let mut msgs: Vec<Value> = 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.
@@ -606,6 +760,26 @@ 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()
}
/// 翻译模式 system prompt — 用户在「翻译」页选定的目标语言(内置 15 种自然语言原生名)。
/// LLM 自己理解("繁体中文"/"English"/"美式英文"/"日本語" 都行)。
/// 此 prompt 之上还有 working_languages_premise 拼出的"# 上下文"前提。
+342
View File
@@ -0,0 +1,342 @@
//! 划词语音问答(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<Inner>,
}
struct Inner {
manager: GlobalHotKeyManager,
/// 当前注册的 hotkey 句柄;用于 unregister。
registered: Mutex<Option<HotKey>>,
/// 事件转发线程接收 global-hotkey crate 的全局 channel,再过滤 id 后转发到 tx。
forward_alive: Arc<std::sync::atomic::AtomicBool>,
/// 当前关心的 hotkey id(filter 用)。
active_id: Arc<std::sync::atomic::AtomicU32>,
}
// global-hotkey 0.6 的 GlobalHotKeyManager 在 Windows 内部持有 HHOOK / window
// handle 等 `*mut c_void`,crate 没标 Send/Sync。但这些句柄实际是 OS 进程级
// 资源,跨线程读写是 OS 自己同步的;coordinator.rs 又需要把 `Arc<Inner>`(间接含
// 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`。
///
/// **注意**:`global-hotkey` crate 在 macOS 要求 manager 在主线程构造。
/// 调用方需要确保从主线程触发(coordinator 的 supervisor 线程会通过
/// `AppHandle::run_on_main_thread` 跳到主线程后再 spawn 这个 monitor)。
/// 本函数不强制断言主线程——单元 / 集成测试也跑不到 manager 创建那一行。
pub fn start(
binding: QaHotkeyBinding,
tx: Sender<QaHotkeyEvent>,
) -> Result<Self, QaHotkeyError> {
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<std::sync::atomic::AtomicBool>,
active_id: Arc<std::sync::atomic::AtomicU32>,
tx: Sender<QaHotkeyEvent>,
) {
// 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<HotKey, QaHotkeyError> {
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<Code, QaHotkeyError> {
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<Code> {
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(_))
));
}
}
+540
View File
@@ -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<String>,
}
/// 尝试捕获当前选区文本。所有 IO 都在调用线程完成(短小、阻塞但 < 200ms)。
///
/// 返回 `None` 表示真的没拿到东西(用户没选 / 平台不支持 / 权限缺失)。
/// 返回 `Some(ctx)` 时 `ctx.text` **保证非空**。
pub fn capture_selection() -> Option<SelectionContext> {
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<String> {
// 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<String> {
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<CFStringRef> {
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<String> {
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<u8> = 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::<INPUT>() 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<String> {
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<String> {
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<String> {
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<String> {
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)));
}
}
+111
View File
@@ -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<QaHotkeyBinding>,
/// 是否把每次 QA 会话写进 history.json。默认 false:QA 默认临时不留痕。
/// 详见 issue #118。
#[serde(default)]
pub qa_save_history: bool,
}
fn default_qa_hotkey() -> Option<QaHotkeyBinding> {
Some(QaHotkeyBinding::default())
}
fn default_working_languages() -> Vec<String> {
@@ -122,10 +135,97 @@ 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<String>,
}
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<String> = 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)]
#[serde(rename_all = "camelCase")]
pub enum HotkeyTrigger {
@@ -364,3 +464,14 @@ pub struct TodayMetrics {
pub avg_latency_ms: u64,
pub total_duration_ms: u64,
}
/// 划词追问浮窗里一条对话消息。多轮提问会累积成 Vec<QaChatMessage>,
/// 整段送给 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,
}
@@ -45,6 +45,23 @@
"visible": false,
"center": false,
"acceptFirstMouse": true
},
{
"label": "qa",
"url": "index.html?window=qa",
"title": "OpenLess QA",
"width": 380,
"height": 440,
"decorations": false,
"transparent": true,
"shadow": true,
"alwaysOnTop": true,
"skipTaskbar": true,
"resizable": false,
"focus": false,
"visible": false,
"center": false,
"acceptFirstMouse": true
}
],
"security": {
+6 -1
View File
@@ -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 <Capsule />;
}
if (isQa) {
return <QaPanel />;
}
const os = detectOS();
// Windows 启动不应被权限探测阻塞首屏。
@@ -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<Omit<NavItem, 'name'>> = [
{ 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';
+1
View File
@@ -9,6 +9,7 @@ export const ICONS: Record<string, string> = {
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',
+47
View File
@@ -34,12 +34,30 @@ export const en: typeof zhCN = {
inserted: 'Inserted {{count}}',
translating: 'Translating',
},
qa: {
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',
history: 'History',
vocab: 'Vocabulary',
style: 'Style',
translation: 'Translation',
selectionAsk: 'Ask',
},
shell: {
shortcutLabel: 'Recording shortcut',
@@ -191,6 +209,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 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: '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: '',
},
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: '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.',
},
},
settings: {
kicker: 'SETTINGS',
title: 'Settings',
+47
View File
@@ -32,12 +32,30 @@ export const zhCN = {
inserted: '已插入 {{count}}',
translating: '正在翻译',
},
qa: {
thinking: '思考中…',
error: '出错了,请稍后再试。',
errorRetry: '重试',
errorRetryHint: '再按 Option 重新提问。',
pinTooltip: '固定(不自动关闭)',
unpinTooltip: '取消固定',
closeTooltip: '关闭',
selectionPreview: '基于选中文本:',
emptyTitle: '按 Option 开始提问',
emptyDesc: '在任意 app 选中一段文字后,按一次 Option 开始录音,再按一次结束并提交。回答会显示在这里,可以连续多轮追问。',
recordingHint: '录音中…再按一次 Option 结束并提问',
statusIdle: '按 Option 提问',
statusRecording: '录音中',
statusThinking: '思考中',
statusError: '出错了',
},
nav: {
overview: '概览',
history: '历史',
vocab: '词汇表',
style: '风格',
translation: '翻译',
selectionAsk: '划词追问',
},
shell: {
shortcutLabel: '录音快捷键',
@@ -189,6 +207,35 @@ export const zhCN = {
fallbackDesc: '翻译模式选「不启用」时 Shift 是没作用的;翻译过程中如果大模型调用失败,会回退到把原始中文转写直接插入,不会丢字。详见 issue #4。',
},
},
selectionAsk: {
kicker: 'SELECTION ASK',
title: '划词追问',
desc: '选中任意 app 里的一段文字,按 Cmd+Shift+; 弹出浮窗,再按 Option 录音提问。支持多轮追问,浮窗一直保留直到你手动关。',
statusEnabled: '已启用',
statusDisabled: '未启用',
hotkey: {
title: '弹出浮窗的快捷键',
desc: '只决定「打开 / 关闭」浮窗。浮窗里录音 / 提问统一用 Option(与你的主听写键复用)。选「不启用」则关闭整个功能。',
optionDisabled: '不启用',
chordWarning: '',
},
history: {
title: '保存历史',
desc: '勾上则把每次追问的「选中文本 + 你的语音问题 + AI 答案」写入本地存档(不上云)。默认关,关闭时浮窗一关问答即遗忘,更注重隐私。',
},
howto: {
title: '使用方法',
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。',
},
},
settings: {
kicker: 'SETTINGS',
title: '设置',
+22
View File
@@ -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 = {
@@ -253,6 +256,25 @@ export function restartApp(): Promise<void> {
return invokeOrMock('restart_app', undefined, () => undefined);
}
// ── QA (划词语音问答) ───────────────────────────────────────────────────
// 详见 issue #118。后端会发 `qa:state` / `qa:dismiss` 事件;前端通过下面四个
// 命令查询与控制 QA 浮窗。
export function getQaHotkeyLabel(): Promise<string> {
return invokeOrMock('get_qa_hotkey_label', undefined, () => 'Cmd+Shift+;');
}
export function setQaHotkey(binding: QaHotkeyBinding): Promise<void> {
return invokeOrMock('set_qa_hotkey', { binding }, () => undefined);
}
export function qaWindowDismiss(): Promise<void> {
return invokeOrMock('qa_window_dismiss', undefined, () => undefined);
}
export function qaWindowPin(pinned: boolean): Promise<void> {
return invokeOrMock('qa_window_pin', { pinned }, () => undefined);
}
export async function openExternal(url: string): Promise<void> {
if (!isTauri) {
window.open(url, '_blank', 'noopener,noreferrer');
+40
View File
@@ -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,38 @@ 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。
* 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;
/** 后端权威:当前已有的多轮对话历史(user → assistant 交替)。answer 事件带完整版。 */
messages?: QaChatMessage[];
/** recording 状态时附带的选区预览(前 60 字)。 */
selection_preview?: string | null;
/** error 状态时附带的提示。 */
error?: string;
/** answer_delta 事件时附带的本帧增量字符串。 */
chunk?: string;
}
/** 内置语言列表 — 前端 Settings UI 用,后端只接收原生名字符串拼 prompt。
+4 -2
View File
@@ -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(
<React.StrictMode>
<App isCapsule={isCapsule} />
<App isCapsule={isCapsule} isQa={isQa} />
</React.StrictMode>,
);
};
+677
View File
@@ -0,0 +1,677 @@
// QaPanel.tsx — 划词追问浮窗 v2(issue #118 v2)。
//
// 触发链路(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 按钮 / 再按 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 { QaChatMessage, QaStatePayload } from '../lib/types';
const SELECTION_PREVIEW_MAX = 60;
marked.setOptions({ gfm: true, breaks: true });
type Status = 'idle' | 'recording' | 'thinking' | 'error';
export function QaPanel() {
const { t } = useTranslation();
const [messages, setMessages] = useState<QaChatMessage[]>([]);
const [status, setStatus] = useState<Status>('idle');
const [errorMsg, setErrorMsg] = useState<string>('');
const [selectionPreview, setSelectionPreview] = useState<string>('');
const [pinned, setPinned] = useState(false);
/** 流式 LLM 答案:answer_delta 累积、answer 事件来时清空(最终内容已落到 messages)。 */
const [streamingAnswer, setStreamingAnswer] = useState<string>('');
const tRef = useRef(t);
tRef.current = t;
// ── 后端事件订阅(mount 时订阅一次,永不重订阅)──────────────────
useEffect(() => {
if (!isTauri) return;
let unlistenState: (() => void) | undefined;
let unlistenDismiss: (() => void) | undefined;
let cancelled = false;
(async () => {
try {
const { listen } = await import('@tauri-apps/api/event');
const stateHandle = await listen<QaStatePayload>('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<unknown>('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 () => {
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);
}, []);
const onTogglePin = () => {
const next = !pinned;
setPinned(next);
void qaWindowPin(next);
};
const onClose = () => {
void qaWindowDismiss();
};
// ── 自动滚动到底(新消息进来时)────────────────────────────────────
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [messages, status]);
return (
<div style={shellStyle}>
<Toolbar pinned={pinned} onTogglePin={onTogglePin} onClose={onClose} />
<div ref={scrollRef} style={contentStyle}>
{messages.length === 0 && status === 'idle' && <EmptyHint t={t} />}
{messages.length === 0 && status === 'recording' && (
<RecordingHeader preview={selectionPreview} t={t} />
)}
<MessageList messages={messages} />
{status === 'recording' && messages.length > 0 && (
<TurnIndicator kind="recording" t={t} preview={selectionPreview} />
)}
{streamingAnswer && (
<StreamingAssistantBubble markdown={streamingAnswer} />
)}
{status === 'thinking' && !streamingAnswer && (
<TurnIndicator kind="thinking" t={t} />
)}
{status === 'error' && <ErrorRow message={errorMsg} t={t} />}
</div>
<StatusBar status={status} t={t} />
</div>
);
}
// ── 子组件 ────────────────────────────────────────────────────────────
interface ToolbarProps {
pinned: boolean;
onTogglePin: () => void;
onClose: () => void;
}
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 (
<div style={toolbarStyle}>
<div style={{ flex: 1, height: '100%' }} />
<IconBtn
label={pinned ? t('qa.unpinTooltip') : t('qa.pinTooltip')}
active={pinned}
onClick={onTogglePin}
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none">
<path
d="M10.5 2L14 5.5L11.5 8L9.5 7L7 9.5L6.5 9L4 11.5L3 13L4.5 11.5L7 9L6.5 8.5L9 6L8 4L10.5 2Z"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
fill={pinned ? 'currentColor' : 'none'}
/>
</svg>
</IconBtn>
<IconBtn label={t('qa.closeTooltip')} onClick={onClose}>
<svg width="11" height="11" viewBox="0 0 11 11">
<path
d="M1.5 1.5l8 8M9.5 1.5l-8 8"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
</IconBtn>
</div>
);
}
interface IconBtnProps {
label: string;
active?: boolean;
onClick: () => void;
children: React.ReactNode;
}
function IconBtn({ label, active, onClick, children }: IconBtnProps) {
return (
<button
onClick={onClick}
title={label}
aria-label={label}
style={{
...iconBtnBaseStyle,
color: active ? 'var(--ol-blue)' : 'var(--ol-ink-3)',
background: active ? 'rgba(37,99,235,0.12)' : 'transparent',
}}
>
{children}
</button>
);
}
function EmptyHint({ t }: { t: ReturnType<typeof useTranslation>['t'] }) {
return (
<div style={emptyHintStyle}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, color: 'var(--ol-ink)' }}>
{t('qa.emptyTitle')}
</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.6 }}>
{t('qa.emptyDesc')}
</div>
</div>
);
}
function RecordingHeader({
preview,
t,
}: {
preview: string;
t: ReturnType<typeof useTranslation>['t'];
}) {
const truncated = useMemo(() => truncate(preview, SELECTION_PREVIEW_MAX), [preview]);
return (
<div style={recordingHeaderStyle}>
{truncated && (
<div style={previewStyle}>
<span style={{ color: 'var(--ol-ink-4)', marginRight: 4 }}>
{t('qa.selectionPreview')}
</span>
<span style={{ color: 'var(--ol-ink-2)' }}>{truncated}</span>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--ol-ink-2)' }}>
<span style={recordingDotStyle} />
{t('qa.recordingHint')}
</div>
</div>
);
}
function MessageList({ messages }: { messages: QaChatMessage[] }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{messages.map((m, i) => (
<MessageRow key={i} message={m} />
))}
</div>
);
}
function MessageRow({ message }: { message: QaChatMessage }) {
// 钩子顺序与 message.role 无关:先无条件 useMemo(user 消息时 html 不渲染但计算无害)。
const html = useMemo(() => {
if (message.role !== 'assistant') return '';
try {
return marked.parse(message.content, { async: false }) as string;
} catch (error) {
console.error('[qa] failed to render markdown', error);
return message.content;
}
}, [message.content, message.role]);
if (message.role === 'user') {
// 第一轮可能含 "# 选区原文 ... # 我的问题 ..." → 抽出问题部分单独显示,
// 选区作为引用块淡显在上面。
const { selection, question } = splitFirstTurnUser(message.content);
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 }}>
{selection && (
<div style={selectionQuoteStyle}>
<span style={{ color: 'var(--ol-ink-4)', marginRight: 4 }}>“</span>
{truncate(selection, 120)}
<span style={{ color: 'var(--ol-ink-4)', marginLeft: 4 }}>”</span>
</div>
)}
<div style={userBubbleStyle}>{question}</div>
</div>
);
}
return (
<div
className="qa-answer"
style={assistantBubbleStyle}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
/** 流式 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 (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 4 }}>
<div
className="qa-answer"
style={assistantBubbleStyle}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: html }}
/>
<span
style={{
display: 'inline-block',
width: 6,
height: 12,
background: 'var(--ol-blue)',
marginLeft: 12,
animation: 'qa-pulse 0.9s ease-in-out infinite',
borderRadius: 1,
}}
/>
</div>
);
}
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<typeof useTranslation>['t'];
}) {
if (kind === 'recording') {
const truncated = preview ? truncate(preview, SELECTION_PREVIEW_MAX) : '';
return (
<div style={turnIndicatorStyle}>
{truncated && (
<div style={previewInlineStyle}>
<span style={{ color: 'var(--ol-ink-4)', marginRight: 4 }}>
{t('qa.selectionPreview')}
</span>
<span style={{ color: 'var(--ol-ink-2)' }}>{truncated}</span>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--ol-ink-2)' }}>
<span style={recordingDotStyle} />
{t('qa.recordingHint')}
</div>
</div>
);
}
return (
<div style={turnIndicatorStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--ol-ink-3)' }}>
<SkeletonLine width="60%" />
</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', fontWeight: 500 }}>
{t('qa.thinking')}
</div>
</div>
);
}
function ErrorRow({
message,
t,
}: {
message: string;
t: ReturnType<typeof useTranslation>['t'];
}) {
return (
<div style={errorRowStyle}>
<div style={{ fontSize: 12.5, color: 'var(--ol-err)', lineHeight: 1.55 }}>{message}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)' }}>{t('qa.errorRetryHint')}</div>
</div>
);
}
function StatusBar({
status,
t,
}: {
status: Status;
t: ReturnType<typeof useTranslation>['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 (
<div style={statusBarStyle}>
<span
style={{
width: 7,
height: 7,
borderRadius: '50%',
background: dotColor,
flexShrink: 0,
}}
/>
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-3)', fontWeight: 500 }}>{label}</span>
</div>
);
}
function SkeletonLine({ width }: { width: string }) {
return (
<div
style={{
height: 8,
width,
borderRadius: 4,
background:
'linear-gradient(90deg, rgba(0,0,0,0.06) 0%, rgba(0,0,0,0.12) 50%, rgba(0,0,0,0.06) 100%)',
backgroundSize: '200% 100%',
animation: 'qa-skeleton 1.4s ease-in-out infinite',
}}
/>
);
}
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',
// 浮窗 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(0, 0, 0, 0.08)',
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,
cursor: 'grab',
};
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',
};
const contentStyle: CSSProperties = {
flex: 1,
minHeight: 0,
overflow: 'auto',
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 = {
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 previewInlineStyle: CSSProperties = {
...previewStyle,
marginBottom: 4,
};
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)',
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)',
};
const globalCss = `
@keyframes qa-skeleton {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@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: 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 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 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 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: 8px 0; }
`;
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);
}
+226
View File
@@ -0,0 +1,226 @@
// 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[] = [
// 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 {
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 (
<>
<PageHeader
kicker={t('selectionAsk.kicker')}
title={t('selectionAsk.title')}
desc={t('selectionAsk.desc')}
/>
<Card>
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
</Card>
</>
);
}
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;
// 先让后端真注册成功 → 再写盘 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 });
};
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 (
<>
<PageHeader
kicker={t('selectionAsk.kicker')}
title={t('selectionAsk.title')}
desc={t('selectionAsk.desc')}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* 1. 触发快捷键 */}
<Card>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{t('selectionAsk.hotkey.title')}</div>
<span
style={{
padding: '2px 8px',
fontSize: 10.5,
fontWeight: 600,
letterSpacing: '0.04em',
borderRadius: 999,
background: enabled ? 'rgba(37,99,235,0.10)' : 'rgba(0,0,0,0.05)',
color: enabled ? 'var(--ol-blue)' : 'var(--ol-ink-4)',
textTransform: 'uppercase',
}}
>
{enabled ? t('selectionAsk.statusEnabled') : t('selectionAsk.statusDisabled')}
</span>
</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 12, lineHeight: 1.55 }}>
{t('selectionAsk.hotkey.desc')}
</div>
<select
value={currentId}
onChange={e => onHotkeyChange(e.target.value)}
style={{
width: '100%',
maxWidth: 360,
height: 32,
padding: '0 10px',
fontSize: 13,
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8,
background: '#fff',
color: 'var(--ol-ink)',
fontFamily: 'inherit',
cursor: 'default',
}}
>
<option value={QA_HOTKEY_DISABLED_ID}>{t('selectionAsk.hotkey.optionDisabled')}</option>
{QA_HOTKEY_PRESETS.map(p => (
<option key={p.id} value={p.id}>{p.label}</option>
))}
</select>
</Card>
{/* 2. 历史保存 */}
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('selectionAsk.history.title')}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 12, lineHeight: 1.55 }}>
{t('selectionAsk.history.desc')}
</div>
<button
onClick={() => onSaveHistoryChange(!prefs.qaSaveHistory)}
style={{
position: 'relative',
width: 44,
height: 24,
borderRadius: 999,
border: 0,
background: prefs.qaSaveHistory ? 'var(--ol-blue)' : 'rgba(0,0,0,0.18)',
cursor: 'default',
transition: 'background 0.15s ease-out',
padding: 0,
}}
>
<span
style={{
position: 'absolute',
top: 2,
left: prefs.qaSaveHistory ? 22 : 2,
width: 20,
height: 20,
borderRadius: 999,
background: '#fff',
boxShadow: '0 1px 2px rgba(0,0,0,.18)',
transition: 'left .15s',
}}
/>
</button>
</Card>
{/* 3. 使用方法 */}
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>{t('selectionAsk.howto.title')}</div>
<ol style={{ margin: 0, paddingLeft: 18, fontSize: 12.5, color: 'var(--ol-ink-2)', lineHeight: 1.7 }}>
<li>{t('selectionAsk.howto.step1')}</li>
<li>{t('selectionAsk.howto.step2', { hotkey: enabled ? currentLabel : '快捷键' })}</li>
<li>{t('selectionAsk.howto.step3')}</li>
<li>{t('selectionAsk.howto.step4', { hotkey: enabled ? currentLabel : '快捷键' })}</li>
<li>{t('selectionAsk.howto.step5')}</li>
</ol>
<div
style={{
marginTop: 14,
padding: '10px 12px',
borderRadius: 10,
background: 'rgba(37,99,235,0.06)',
border: '0.5px solid rgba(37,99,235,0.15)',
fontSize: 11.5,
color: 'var(--ol-ink-2)',
lineHeight: 1.55,
}}
>
<div style={{ fontWeight: 600, color: 'var(--ol-blue)', marginBottom: 4 }}>{t('selectionAsk.howto.windowTitle')}</div>
{t('selectionAsk.howto.windowDesc')}
</div>
<div
style={{
marginTop: 10,
padding: '10px 12px',
borderRadius: 10,
background: 'rgba(0,0,0,0.04)',
fontSize: 11.5,
color: 'var(--ol-ink-3)',
lineHeight: 1.55,
}}
>
<div style={{ fontWeight: 600, color: 'var(--ol-ink-2)', marginBottom: 4 }}>{t('selectionAsk.howto.privacyTitle')}</div>
{t('selectionAsk.howto.privacyDesc')}
</div>
</Card>
</div>
</>
);
}
+1 -1
View File
@@ -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;