Merge pull request #119 from appergb/feat/selection-voice-qa
feat: 划词语音问答(Selection + Voice Q&A)— closes #118
This commit is contained in:
23 files changed
+3329
-14
No files matched your search
@@ -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 流式输出已稳定(已完成 ✅)
|
||||
Generated
+15
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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 拼出的"# 上下文"前提。
|
||||
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '设置',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user