feat: 划词语音问答前端 — closes #118 前端部分

- 新增 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* 文案
This commit is contained in:
baiqing committed 2026-05-01 13:14:11 +08:00
1 parent e5740298fa
commit 1a0efe402c
11 files changed
+601 -5

No files matched your search

+15 -2
View File
@@ -1,17 +1,18 @@
{
"name": "openless-app",
"version": "1.2.7",
"version": "1.2.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openless-app",
"version": "1.2.7",
"version": "1.2.8",
"dependencies": {
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-shell": "^2.0.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"i18next": "^26.0.8",
"marked": "^11.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.6"
@@ -1732,6 +1733,18 @@
"yallist": "^3.0.2"
}
},
"node_modules/marked": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-11.2.0.tgz",
"integrity": "sha512-HR0m3bvu0jAPYiIvLUUQtdg1g6D247//lvcekpHO1WMvbwDlwSkZAX9Lw4F4YHE1T0HaaNve0tuAWuV1UJ6vtw==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+1
View File
@@ -15,6 +15,7 @@
"@tauri-apps/plugin-shell": "^2.0.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"i18next": "^26.0.8",
"marked": "^11.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.6"
@@ -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": {
+6 -1
View File
@@ -9,18 +9,23 @@ import {
handleWindowHotkeyEvent,
isTauri,
} from './lib/ipc';
import { QaPanel } from './pages/QaPanel';
import { HotkeySettingsProvider } from './state/HotkeySettingsContext';
interface AppProps {
isCapsule: boolean;
isQa: boolean;
}
type Gate = 'checking' | 'onboarding' | 'ready';
export function App({ isCapsule }: AppProps) {
export function App({ isCapsule, isQa }: AppProps) {
if (isCapsule) {
return <Capsule />;
}
if (isQa) {
return <QaPanel />;
}
const os = detectOS();
// Windows 启动不应被权限探测阻塞首屏。
+14
View File
@@ -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)',
+14
View File
@@ -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 模型(润色)',
+22
View File
@@ -10,6 +10,7 @@ import type {
HotkeyStatus,
PermissionStatus,
PolishMode,
QaHotkeyBinding,
UserPreferences,
} from './types';
import { OL_DATA } from './mockData';
@@ -46,6 +47,8 @@ const mockSettings: UserPreferences = {
restoreClipboardAfterPaste: true,
workingLanguages: ['简体中文'],
translationTargetLanguage: '',
qaHotkey: { primary: ';', modifiers: ['cmd', 'shift'] },
qaSaveHistory: false,
};
const mockHotkeyCapability: HotkeyCapability = {
@@ -236,6 +239,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');
+26
View File
@@ -71,6 +71,14 @@ export interface HotkeyStatus {
lastError: HotkeyInstallError | null;
}
/** 划词语音问答快捷键绑定。null 表示未启用。详见 issue #118。 */
export interface QaHotkeyBinding {
/** 主键(去掉所有修饰符的字面字符),例如 ";" / "/" / "a" */
primary: string;
/** 修饰符列表,元素小写:"cmd" | "shift" | "option" | "ctrl"。 */
modifiers: string[];
}
export interface UserPreferences {
hotkey: HotkeyBinding;
defaultMode: PolishMode;
@@ -85,6 +93,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。
+4 -2
View File
@@ -6,14 +6,16 @@ import "./styles/tokens.css";
import "./styles/global.css";
const params = new URLSearchParams(window.location.search);
const isCapsule = params.get("window") === "capsule";
const windowKind = params.get("window");
const isCapsule = windowKind === "capsule";
const isQa = windowKind === "qa";
const root = ReactDOM.createRoot(document.getElementById("root")!);
const renderApp = () => {
root.render(
<React.StrictMode>
<App isCapsule={isCapsule} />
<App isCapsule={isCapsule} isQa={isQa} />
</React.StrictMode>,
);
};
+404
View File
@@ -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<QaStatePayload>({ 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<QaStatePayload>('qa:state', event => {
setPayload(event.payload);
});
const dismissHandle = await listen<unknown>('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 (
<div style={shellStyle}>
<Toolbar pinned={pinned} onTogglePin={onTogglePin} onClose={onClose} />
<div style={contentStyle}>
<Body payload={payload} t={t} />
</div>
</div>
);
}
// ── 子组件 ────────────────────────────────────────────────────────────
interface ToolbarProps {
pinned: boolean;
onTogglePin: () => void;
onClose: () => void;
}
function Toolbar({ pinned, onTogglePin, onClose }: ToolbarProps) {
const { t } = useTranslation();
return (
<div style={toolbarStyle}>
<div style={{ flex: 1 }} />
<IconBtn
label={pinned ? t('qa.unpinTooltip') : t('qa.pinTooltip')}
active={pinned}
onClick={onTogglePin}
>
{/* Pin 图标 */}
<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>
);
}
interface BodyProps {
payload: QaStatePayload;
t: ReturnType<typeof useTranslation>['t'];
}
function Body({ payload, t }: BodyProps) {
if (payload.kind === 'loading') {
return <LoadingView preview={payload.selection_preview} t={t} />;
}
if (payload.kind === 'error') {
return <ErrorView message={payload.error ?? t('qa.error')} t={t} />;
}
return <AnswerView markdown={payload.answer_md ?? ''} />;
}
function LoadingView({ preview, t }: { preview: string | undefined; t: BodyProps['t'] }) {
const truncated = useMemo(() => truncate(preview ?? '', SELECTION_PREVIEW_MAX), [preview]);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{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', flexDirection: 'column', gap: 8 }}>
<SkeletonLine width="62%" />
<SkeletonLine width="86%" />
<SkeletonLine width="44%" />
</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', fontWeight: 500 }}>
{t('qa.thinking')}
</div>
</div>
);
}
function SkeletonLine({ width }: { width: string }) {
return (
<div
style={{
height: 10,
width,
borderRadius: 6,
background:
'linear-gradient(90deg, rgba(0,0,0,0.06) 0%, rgba(0,0,0,0.10) 50%, rgba(0,0,0,0.06) 100%)',
backgroundSize: '200% 100%',
animation: 'qa-skeleton 1.4s ease-in-out infinite',
}}
/>
);
}
function ErrorView({ message, t }: { message: string; t: BodyProps['t'] }) {
// 重试按钮:关掉浮窗,让用户重按 hotkey。详见 issue #118。
const onRetry = () => {
void qaWindowDismiss();
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: 'var(--ol-err)', lineHeight: 1.55 }}>{message}</div>
<button onClick={onRetry} style={retryBtnStyle}>
{t('qa.errorRetry')}
</button>
</div>
);
}
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 (
<div
className="qa-answer"
style={answerStyle}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
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);
}
+78
View File
@@ -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() {
>
<Toggle on={prefs.restoreClipboardAfterPaste} onToggle={onRestoreClipboardChange} />
</SettingRow>
<SettingRow
label={t('settings.recording.qaHotkeyLabel')}
desc={t('settings.recording.qaHotkeyDesc')}
>
<select
value={bindingToPresetId(prefs.qaHotkey)}
onChange={e => { void onQaHotkeyChange(e.target.value); }}
style={{
...inputStyle,
maxWidth: 220,
fontFamily: 'var(--ol-font-mono)',
}}
>
{QA_HOTKEY_PRESETS.map(p => (
<option key={p.id} value={p.id}>{p.label}</option>
))}
<option value={QA_HOTKEY_DISABLED_ID}>
{t('settings.recording.qaHotkeyOptionDisabled')}
</option>
</select>
</SettingRow>
<SettingRow
label={t('settings.recording.qaSaveHistoryLabel')}
desc={t('settings.recording.qaSaveHistoryDesc')}
>
<Toggle on={prefs.qaSaveHistory} onToggle={onQaSaveHistoryChange} />
</SettingRow>
{capability.statusHint && (
<div style={{ marginTop: 6, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.5 }}>
{capability.statusHint}