From 1a0efe402c992a2de2ce5fac52f92a98f6e3f33e Mon Sep 17 00:00:00 2001 From: baiqing Date: Fri, 1 May 2026 13:07:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=92=E8=AF=8D=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E9=97=AE=E7=AD=94=E5=89=8D=E7=AB=AF=20=E2=80=94=20closes=20#11?= =?UTF-8?q?8=20=E5=89=8D=E7=AB=AF=E9=83=A8=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 qa 浮窗(tauri.conf.json windows[2]):380x280 无装饰磨砂玻璃 - 新增 QaPanel.tsx:loading skeleton / answer markdown / error 三态 - marked@^11 渲染 markdown,注入轻量排版 CSS - Esc / 失焦 / 30s 超时自动 dismiss(Pin 时跳过) - 监听 qa:state + qa:dismiss 后端事件 - App.tsx 路由:?window=qa → QaPanel - types.ts:QaHotkeyBinding + UserPreferences.qaHotkey/qaSaveHistory 以及 QaStatePayload(snake_case 字段对齐 Rust) - ipc.ts:getQaHotkeyLabel / setQaHotkey / qaWindowDismiss / qaWindowPin 以及 mockSettings 默认 cmd+shift+; - Settings 录音区新增「提问模式快捷键」下拉(4 预设 + 不启用) 与「保存 Q&A 历史」开关 - i18n:zh-CN + en 同步新增 qa.* 与 settings.recording.qa* 文案 --- openless-all/app/package-lock.json | 17 +- openless-all/app/package.json | 1 + openless-all/app/src-tauri/tauri.conf.json | 17 + openless-all/app/src/App.tsx | 7 +- openless-all/app/src/i18n/en.ts | 14 + openless-all/app/src/i18n/zh-CN.ts | 14 + openless-all/app/src/lib/ipc.ts | 22 ++ openless-all/app/src/lib/types.ts | 26 ++ openless-all/app/src/main.tsx | 6 +- openless-all/app/src/pages/QaPanel.tsx | 404 +++++++++++++++++++++ openless-all/app/src/pages/Settings.tsx | 78 ++++ 11 files changed, 601 insertions(+), 5 deletions(-) create mode 100644 openless-all/app/src/pages/QaPanel.tsx diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index a185d183..15112f79 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,17 +1,18 @@ { "name": "openless-app", - "version": "1.2.7", + "version": "1.2.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "1.2.7", + "version": "1.2.8", "dependencies": { "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-shell": "^2.0.1", "@tauri-apps/plugin-updater": "^2.10.1", "i18next": "^26.0.8", + "marked": "^11.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^17.0.6" @@ -1732,6 +1733,18 @@ "yallist": "^3.0.2" } }, + "node_modules/marked": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-11.2.0.tgz", + "integrity": "sha512-HR0m3bvu0jAPYiIvLUUQtdg1g6D247//lvcekpHO1WMvbwDlwSkZAX9Lw4F4YHE1T0HaaNve0tuAWuV1UJ6vtw==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 855feea3..4843f17f 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -15,6 +15,7 @@ "@tauri-apps/plugin-shell": "^2.0.1", "@tauri-apps/plugin-updater": "^2.10.1", "i18next": "^26.0.8", + "marked": "^11.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^17.0.6" diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 2dc524fb..d188dadd 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -45,6 +45,23 @@ "visible": false, "center": false, "acceptFirstMouse": true + }, + { + "label": "qa", + "url": "index.html?window=qa", + "title": "OpenLess QA", + "width": 380, + "height": 280, + "decorations": false, + "transparent": true, + "shadow": true, + "alwaysOnTop": true, + "skipTaskbar": true, + "resizable": false, + "focus": false, + "visible": false, + "center": false, + "acceptFirstMouse": true } ], "security": { diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index eeb9d697..01792c0a 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -9,18 +9,23 @@ import { handleWindowHotkeyEvent, isTauri, } from './lib/ipc'; +import { QaPanel } from './pages/QaPanel'; import { HotkeySettingsProvider } from './state/HotkeySettingsContext'; interface AppProps { isCapsule: boolean; + isQa: boolean; } type Gate = 'checking' | 'onboarding' | 'ready'; -export function App({ isCapsule }: AppProps) { +export function App({ isCapsule, isQa }: AppProps) { if (isCapsule) { return ; } + if (isQa) { + return ; + } const os = detectOS(); // Windows 启动不应被权限探测阻塞首屏。 diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index fa335452..80e92eb0 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -34,6 +34,15 @@ export const en: typeof zhCN = { inserted: 'Inserted {{count}}', translating: 'Translating', }, + qa: { + thinking: 'Thinking…', + error: 'Something went wrong. Please try again.', + errorRetry: 'Retry', + pinTooltip: 'Pin (stay open)', + unpinTooltip: 'Unpin', + closeTooltip: 'Close', + selectionPreview: 'From selected text:', + }, nav: { overview: 'Overview', history: 'History', @@ -219,6 +228,11 @@ export const en: typeof zhCN = { capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording / transcribing.', restoreClipboardLabel: 'Restore clipboard after insert', restoreClipboardDesc: 'Windows / Linux only: restore your original clipboard after a successful paste (default on). Turn off to keep the dictation text in the clipboard so you can manually Ctrl+V if the simulated paste did not actually land. See issue #111.', + qaHotkeyLabel: 'Question-mode hotkey', + qaHotkeyDesc: 'Select some text, press this hotkey to open the QA panel and start recording; press again to stop and get the answer. See issue #118.', + qaHotkeyOptionDisabled: 'Disabled', + qaSaveHistoryLabel: 'Save Q&A history', + qaSaveHistoryDesc: 'Persist each question and answer to local storage. Turn off to leave no trace.', }, providers: { llmTitle: 'LLM (polishing)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index cf98f614..051066af 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -32,6 +32,15 @@ export const zhCN = { inserted: '已插入 {{count}}', translating: '正在翻译', }, + qa: { + thinking: '思考中…', + error: '出错了,请稍后再试。', + errorRetry: '重试', + pinTooltip: '固定(不自动关闭)', + unpinTooltip: '取消固定', + closeTooltip: '关闭', + selectionPreview: '基于选中文本:', + }, nav: { overview: '概览', history: '历史', @@ -217,6 +226,11 @@ export const zhCN = { capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。', restoreClipboardLabel: '插入后恢复剪贴板', restoreClipboardDesc: '仅 Windows / Linux:粘贴成功后恢复你原来的剪贴板内容(默认开)。关掉就把听写文本留在剪贴板,模拟粘贴没真正落地时可以手动 Ctrl+V 找回。详见 issue #111。', + qaHotkeyLabel: '提问模式快捷键', + qaHotkeyDesc: '选中一段文字后按下此快捷键,弹出问答浮窗并开始录音;再按一次结束录音、生成答案。详见 issue #118。', + qaHotkeyOptionDisabled: '不启用', + qaSaveHistoryLabel: '保存 Q&A 历史', + qaSaveHistoryDesc: '把每次划词问答的问题与答案写入本地存档。关闭则不保留任何痕迹。', }, providers: { llmTitle: 'LLM 模型(润色)', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index a1cabd6b..2e235fe3 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -10,6 +10,7 @@ import type { HotkeyStatus, PermissionStatus, PolishMode, + QaHotkeyBinding, UserPreferences, } from './types'; import { OL_DATA } from './mockData'; @@ -46,6 +47,8 @@ const mockSettings: UserPreferences = { restoreClipboardAfterPaste: true, workingLanguages: ['简体中文'], translationTargetLanguage: '', + qaHotkey: { primary: ';', modifiers: ['cmd', 'shift'] }, + qaSaveHistory: false, }; const mockHotkeyCapability: HotkeyCapability = { @@ -236,6 +239,25 @@ export function restartApp(): Promise { return invokeOrMock('restart_app', undefined, () => undefined); } +// ── QA (划词语音问答) ─────────────────────────────────────────────────── +// 详见 issue #118。后端会发 `qa:state` / `qa:dismiss` 事件;前端通过下面四个 +// 命令查询与控制 QA 浮窗。 +export function getQaHotkeyLabel(): Promise { + return invokeOrMock('get_qa_hotkey_label', undefined, () => 'Cmd+Shift+;'); +} + +export function setQaHotkey(binding: QaHotkeyBinding): Promise { + return invokeOrMock('set_qa_hotkey', { binding }, () => undefined); +} + +export function qaWindowDismiss(): Promise { + return invokeOrMock('qa_window_dismiss', undefined, () => undefined); +} + +export function qaWindowPin(pinned: boolean): Promise { + return invokeOrMock('qa_window_pin', { pinned }, () => undefined); +} + export async function openExternal(url: string): Promise { if (!isTauri) { window.open(url, '_blank', 'noopener,noreferrer'); diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 8f350564..a8229594 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -71,6 +71,14 @@ export interface HotkeyStatus { lastError: HotkeyInstallError | null; } +/** 划词语音问答快捷键绑定。null 表示未启用。详见 issue #118。 */ +export interface QaHotkeyBinding { + /** 主键(去掉所有修饰符的字面字符),例如 ";" / "/" / "a" */ + primary: string; + /** 修饰符列表,元素小写:"cmd" | "shift" | "option" | "ctrl"。 */ + modifiers: string[]; +} + export interface UserPreferences { hotkey: HotkeyBinding; defaultMode: PolishMode; @@ -85,6 +93,24 @@ export interface UserPreferences { workingLanguages: string[]; /** 翻译模式目标语言(单选,原生名);空串 = 不启用 Shift 翻译。详见 issue #4。 */ translationTargetLanguage: string; + /** 划词语音问答快捷键。null = 未启用。详见 issue #118。 */ + qaHotkey: QaHotkeyBinding | null; + /** 是否把 Q&A 历史写到本地存档。详见 issue #118。 */ + qaSaveHistory: boolean; +} + +/** Rust 通过 `qa:state` 事件下发的 payload。 + * 与 issue #118 的契约一致——字段名采用 snake_case,与后端 JSON 直接对齐。 */ +export type QaStateKind = 'loading' | 'answer' | 'error'; + +export interface QaStatePayload { + kind: QaStateKind; + /** loading 状态下的选区前缀(前 60 字,已截断)。 */ + selection_preview?: string; + /** answer 状态下的 markdown 字符串。 */ + answer_md?: string; + /** error 状态下的错误提示。 */ + error?: string; } /** 内置语言列表 — 前端 Settings UI 用,后端只接收原生名字符串拼 prompt。 diff --git a/openless-all/app/src/main.tsx b/openless-all/app/src/main.tsx index 3a946c9b..0c592ec5 100644 --- a/openless-all/app/src/main.tsx +++ b/openless-all/app/src/main.tsx @@ -6,14 +6,16 @@ import "./styles/tokens.css"; import "./styles/global.css"; const params = new URLSearchParams(window.location.search); -const isCapsule = params.get("window") === "capsule"; +const windowKind = params.get("window"); +const isCapsule = windowKind === "capsule"; +const isQa = windowKind === "qa"; const root = ReactDOM.createRoot(document.getElementById("root")!); const renderApp = () => { root.render( - + , ); }; diff --git a/openless-all/app/src/pages/QaPanel.tsx b/openless-all/app/src/pages/QaPanel.tsx new file mode 100644 index 00000000..e3326893 --- /dev/null +++ b/openless-all/app/src/pages/QaPanel.tsx @@ -0,0 +1,404 @@ +// QaPanel.tsx — 划词语音问答浮窗。详见 issue #118。 +// +// 触发链路: +// 1) 用户选中文本 → 按 Cmd+Shift+;(默认)→ 后端打开本窗口(label="qa") +// 并发 `qa:state { kind: "loading", selection_preview }` 事件,开始录音。 +// 2) 用户提问完毕,再次按热键 → 后端转写 + LLM 回答 → 发 +// `qa:state { kind: "answer", answer_md }`,本组件用 marked 渲染。 +// 3) 出错 → `qa:state { kind: "error", error }`,显示红色文案 + 重试按钮。 +// +// 关闭时机(任一): +// - Esc / Close 按钮 / 点击窗口外(除非 Pin)→ qa_window_dismiss() +// - 30s 超时(除非 Pin)→ qa_window_dismiss() +// - 后端发 `qa:dismiss` 事件 → 直接关窗 + +import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; +import { useTranslation } from 'react-i18next'; +import { marked } from 'marked'; +import { isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc'; +import type { QaStatePayload } from '../lib/types'; + +const AUTO_DISMISS_MS = 30_000; +const SELECTION_PREVIEW_MAX = 60; + +// marked 配置:开启换行符识别,关闭 mangle/headerIds(v11 已默认关闭)。 +marked.setOptions({ gfm: true, breaks: true }); + +export function QaPanel() { + const { t } = useTranslation(); + const [payload, setPayload] = useState({ kind: 'loading' }); + const [pinned, setPinned] = useState(false); + const pinnedRef = useRef(false); + + // ── 后端事件订阅 ──────────────────────────────────────────────────── + useEffect(() => { + if (!isTauri) return; + let unlistenState: (() => void) | undefined; + let unlistenDismiss: (() => void) | undefined; + let cancelled = false; + (async () => { + const { listen } = await import('@tauri-apps/api/event'); + const stateHandle = await listen('qa:state', event => { + setPayload(event.payload); + }); + const dismissHandle = await listen('qa:dismiss', () => { + // 后端要求关闭:直接转发到 dismiss 命令;同时 reset pin 状态 + // 让用户下次开新窗口拿到默认 unpinned。 + pinnedRef.current = false; + setPinned(false); + void qaWindowDismiss(); + }); + if (cancelled) { + stateHandle(); + dismissHandle(); + } else { + unlistenState = stateHandle; + unlistenDismiss = dismissHandle; + } + })(); + return () => { + cancelled = true; + unlistenState?.(); + unlistenDismiss?.(); + }; + }, []); + + // ── Esc 关闭 ──────────────────────────────────────────────────────── + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + void qaWindowDismiss(); + } + }; + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); + }, []); + + // ── 失焦自动关闭(除非 Pin)──────────────────────────────────────── + useEffect(() => { + const onBlur = () => { + if (pinnedRef.current) return; + void qaWindowDismiss(); + }; + window.addEventListener('blur', onBlur); + return () => window.removeEventListener('blur', onBlur); + }, []); + + // ── 30s 自动关闭(除非 Pin),payload 变化或 pin 切换时重置 ────── + useEffect(() => { + if (pinned) return; + const timer = window.setTimeout(() => { + if (!pinnedRef.current) void qaWindowDismiss(); + }, AUTO_DISMISS_MS); + return () => window.clearTimeout(timer); + }, [payload, pinned]); + + const onTogglePin = () => { + const next = !pinned; + pinnedRef.current = next; + setPinned(next); + void qaWindowPin(next); + }; + + const onClose = () => { + void qaWindowDismiss(); + }; + + return ( +
+ +
+ +
+
+ ); +} + +// ── 子组件 ──────────────────────────────────────────────────────────── + +interface ToolbarProps { + pinned: boolean; + onTogglePin: () => void; + onClose: () => void; +} + +function Toolbar({ pinned, onTogglePin, onClose }: ToolbarProps) { + const { t } = useTranslation(); + return ( +
+
+ + {/* Pin 图标 */} + + + + + + + + + +
+ ); +} + +interface IconBtnProps { + label: string; + active?: boolean; + onClick: () => void; + children: React.ReactNode; +} + +function IconBtn({ label, active, onClick, children }: IconBtnProps) { + return ( + + ); +} + +interface BodyProps { + payload: QaStatePayload; + t: ReturnType['t']; +} + +function Body({ payload, t }: BodyProps) { + if (payload.kind === 'loading') { + return ; + } + if (payload.kind === 'error') { + return ; + } + return ; +} + +function LoadingView({ preview, t }: { preview: string | undefined; t: BodyProps['t'] }) { + const truncated = useMemo(() => truncate(preview ?? '', SELECTION_PREVIEW_MAX), [preview]); + return ( +
+ {truncated && ( +
+ + {t('qa.selectionPreview')} + + {truncated} +
+ )} +
+ + + +
+
+ {t('qa.thinking')} +
+
+ ); +} + +function SkeletonLine({ width }: { width: string }) { + return ( +
+ ); +} + +function ErrorView({ message, t }: { message: string; t: BodyProps['t'] }) { + // 重试按钮:关掉浮窗,让用户重按 hotkey。详见 issue #118。 + const onRetry = () => { + void qaWindowDismiss(); + }; + return ( +
+
{message}
+ +
+ ); +} + +function AnswerView({ markdown }: { markdown: string }) { + // marked v11 同步调用返回 string;启用 GFM + breaks。 + // 注意:markdown 来自我们自己的后端 → LLM,链路可信,未额外 sanitize。 + // 如未来引入用户自由文本拼装到 prompt,需要补 DOMPurify。 + const html = useMemo(() => { + try { + return marked.parse(markdown, { async: false }) as string; + } catch (error) { + console.error('[qa] failed to render markdown', error); + return ''; + } + }, [markdown]); + return ( +
+ ); +} + +function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return `${text.slice(0, max)}…`; +} + +// ── 样式 ────────────────────────────────────────────────────────────── + +const shellStyle: CSSProperties = { + width: '100%', + height: '100vh', + display: 'flex', + flexDirection: 'column', + borderRadius: 14, + overflow: 'hidden', + background: 'rgba(255, 255, 255, 0.85)', + backdropFilter: 'blur(24px) saturate(180%)', + WebkitBackdropFilter: 'blur(24px) saturate(180%)', + border: '0.5px solid rgba(255, 255, 255, 0.7)', + boxShadow: 'var(--ol-shadow-lg)', + fontFamily: 'var(--ol-font-sans)', + color: 'var(--ol-ink)', +}; + +const toolbarStyle: CSSProperties = { + height: 32, + display: 'flex', + alignItems: 'center', + gap: 4, + padding: '0 8px', + borderBottom: '0.5px solid rgba(0, 0, 0, 0.06)', + flexShrink: 0, + // 让用户可以拖动整个浮窗(macOS / Win 通用)。 + // @ts-expect-error: vendor prefix not in CSSProperties typing + WebkitAppRegion: 'drag', +}; + +const iconBtnBaseStyle: CSSProperties = { + width: 22, + height: 22, + border: 0, + borderRadius: 6, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'default', + padding: 0, + transition: 'background 0.12s ease-out, color 0.12s ease-out', + // @ts-expect-error: vendor prefix not in CSSProperties typing + WebkitAppRegion: 'no-drag', +}; + +const contentStyle: CSSProperties = { + flex: 1, + minHeight: 0, + overflow: 'auto', + padding: '14px 18px', +}; + +const previewStyle: CSSProperties = { + fontSize: 11.5, + lineHeight: 1.5, + padding: '8px 10px', + borderRadius: 8, + background: 'rgba(0, 0, 0, 0.035)', + border: '0.5px solid rgba(0, 0, 0, 0.06)', +}; + +const retryBtnStyle: CSSProperties = { + alignSelf: 'flex-start', + padding: '5px 12px', + fontSize: 12, + fontWeight: 500, + border: '0.5px solid var(--ol-line-strong)', + borderRadius: 6, + background: 'var(--ol-surface)', + color: 'var(--ol-ink-2)', + cursor: 'default', + fontFamily: 'inherit', +}; + +const answerStyle: CSSProperties = { + fontSize: 13, + lineHeight: 1.6, + color: 'var(--ol-ink)', + wordWrap: 'break-word', +}; + +// 注入全局 keyframes + .qa-answer 内 markdown 排版样式。 +// 不放 styles/global.css 是因为只有这个窗口需要。 +const globalCss = ` +@keyframes qa-skeleton { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} +.qa-answer p { margin: 0 0 8px; } +.qa-answer p:last-child { margin-bottom: 0; } +.qa-answer h1, +.qa-answer h2, +.qa-answer h3 { margin: 12px 0 6px; font-weight: 600; line-height: 1.35; } +.qa-answer h1 { font-size: 16px; } +.qa-answer h2 { font-size: 14px; } +.qa-answer h3 { font-size: 13px; } +.qa-answer ul, +.qa-answer ol { margin: 0 0 8px; padding-left: 20px; } +.qa-answer li { margin: 2px 0; } +.qa-answer code { font-family: var(--ol-font-mono); font-size: 12px; + padding: 1px 5px; border-radius: 4px; + background: rgba(0,0,0,0.05); } +.qa-answer pre { margin: 0 0 8px; padding: 10px 12px; + border-radius: 8px; background: rgba(0,0,0,0.05); + overflow-x: auto; } +.qa-answer pre code { padding: 0; background: transparent; } +.qa-answer a { color: var(--ol-blue); text-decoration: none; } +.qa-answer a:hover { text-decoration: underline; } +.qa-answer blockquote { margin: 0 0 8px; padding: 4px 0 4px 10px; + border-left: 2px solid rgba(0,0,0,0.15); + color: var(--ol-ink-3); } +.qa-answer hr { border: 0; border-top: 0.5px solid rgba(0,0,0,0.10); + margin: 10px 0; } +`; + +// 单次注入。重复挂载(HMR)时会被同 id 替换。 +if (typeof document !== 'undefined' && !document.getElementById('qa-panel-style')) { + const tag = document.createElement('style'); + tag.id = 'qa-panel-style'; + tag.textContent = globalCss; + document.head.appendChild(tag); +} diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 08f70b28..3ece3050 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -21,6 +21,7 @@ import { setActiveAsrProvider, setActiveLlmProvider, setCredential, + setQaHotkey, } from '../lib/ipc'; import type { HotkeyCapability, @@ -28,6 +29,7 @@ import type { HotkeyStatus, HotkeyTrigger, PermissionStatus, + QaHotkeyBinding, } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import i18n, { @@ -132,6 +134,37 @@ function SettingRow({ label, desc, children }: SettingRowProps) { ); } +// QA 提问模式快捷键预设。详见 issue #118。 +// id="disabled" 是哨兵 — savePrefs({ qaHotkey: null }) 表示禁用。 +const QA_HOTKEY_DISABLED_ID = 'disabled' as const; + +interface QaHotkeyPreset { + id: string; + binding: QaHotkeyBinding; + /** UI 上展示的字面值;后端的 get_qa_hotkey_label 仅用作冗余提示,不必一致。 */ + label: string; +} + +const QA_HOTKEY_PRESETS: readonly QaHotkeyPreset[] = [ + { id: 'cmd+shift+;', label: 'Cmd+Shift+;', binding: { primary: ';', modifiers: ['cmd', 'shift'] } }, + { id: 'cmd+option+;', label: 'Cmd+Option+;', binding: { primary: ';', modifiers: ['cmd', 'option'] } }, + { id: 'cmd+shift+/', label: 'Cmd+Shift+/', binding: { primary: '/', modifiers: ['cmd', 'shift'] } }, + { id: 'cmd+option+/', label: 'Cmd+Option+/', binding: { primary: '/', modifiers: ['cmd', 'option'] } }, +] as const; + +function bindingToPresetId(binding: QaHotkeyBinding | null): string { + if (!binding) return QA_HOTKEY_DISABLED_ID; + const sortedMods = [...binding.modifiers].map(m => m.toLowerCase()).sort(); + const match = QA_HOTKEY_PRESETS.find(p => { + const pMods = [...p.binding.modifiers].sort(); + return p.binding.primary === binding.primary + && pMods.length === sortedMods.length + && pMods.every((m, i) => m === sortedMods[i]); + }); + // 后端如果当前绑定是非预设值,UI 兜底回到默认 cmd+shift+; + return match ? match.id : QA_HOTKEY_PRESETS[0].id; +} + function RecordingSection() { const { t } = useTranslation(); const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings(); @@ -152,6 +185,24 @@ function RecordingSection() { savePrefs({ ...prefs, showCapsule }); const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) => savePrefs({ ...prefs, restoreClipboardAfterPaste }); + const onQaHotkeyChange = async (id: string) => { + if (id === QA_HOTKEY_DISABLED_ID) { + await savePrefs({ ...prefs, qaHotkey: null }); + return; + } + const preset = QA_HOTKEY_PRESETS.find(p => p.id === id); + if (!preset) return; + // 同步两条路:set_qa_hotkey 让后端立刻重装 hotkey 监听; + // savePrefs 让 UserPreferences 落盘。两个 IPC 由后端决定能否合并。 + try { + await setQaHotkey(preset.binding); + } catch (error) { + console.error('[settings] failed to set qa hotkey', error); + } + await savePrefs({ ...prefs, qaHotkey: preset.binding }); + }; + const onQaSaveHistoryChange = (qaSaveHistory: boolean) => + savePrefs({ ...prefs, qaSaveHistory }); const choices: Array<[HotkeyMode, string]> = [ ['toggle', t('settings.recording.modeToggle')], @@ -229,6 +280,33 @@ function RecordingSection() { > + + + + + + {capability.statusHint && (
{capability.statusHint}