feat(qa): 拆出"划词追问"独立 tab + 修 Windows CI Send 错误
UI 拆分(用户面)
- 新一级 tab "划词追问"(NAV 在"翻译"后面),icon 是 stroke 文本+问号
- 新页 src/pages/SelectionAsk.tsx:3 个 Card——触发快捷键预设(含 Cmd+Option
chord 选项 + 4 个三键预设 + 不启用),保存历史 toggle,使用方法 5 步说明 +
浮窗位置/生命周期块 + 隐私契约块
- Settings → 录音 删去 qaHotkey + qaSaveHistory 两行(已搬到新 tab)
- i18n 加 nav.selectionAsk + 完整 selectionAsk.* 命名空间(zh+en),
删 settings.recording.qa{Hotkey,SaveHistory}* 旧 keys
- useAppState.ts AppTab 加 'selectionAsk'
Windows CI fix(PR #119 失败原因)
- qa_hotkey.rs::Inner 含 GlobalHotKeyManager → Windows 上含 HHOOK 这种
*mut c_void 不实现 Send/Sync,导致 coordinator.rs:1998 的 async_runtime::spawn
捕获 Arc<Inner> 时 Windows 编译失败(macOS 编译过)
- 加 unsafe impl Send + Sync for Inner,与现有 hotkey.rs::CallbackContext 同款
做法(OS 句柄实际跨线程安全,crate 没标)
不动后端 hotkey 实现
Cmd+Option chord 选项 UI 已暴露但底层 global-hotkey crate 不支持
modifier-only chord(需要 CGEventTap 自己写状态机),UI 上加 ⚠️ 警告说明
"需要 v1.2.9+ 才支持"。当前默认仍为 Cmd+Shift+;。
This commit is contained in:
1 parent
e68b29d5a3
commit
78ff8113eb
8 files changed
+311
-89
No files matched your search
@@ -53,6 +53,15 @@ struct Inner {
|
||||
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`。
|
||||
///
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -49,6 +49,7 @@ export const en: typeof zhCN = {
|
||||
vocab: 'Vocabulary',
|
||||
style: 'Style',
|
||||
translation: 'Translation',
|
||||
selectionAsk: 'Ask',
|
||||
},
|
||||
shell: {
|
||||
shortcutLabel: 'Recording shortcut',
|
||||
@@ -200,6 +201,35 @@ export const en: typeof zhCN = {
|
||||
fallbackDesc: 'When the target is "Disabled", Shift is a no-op. If the LLM call fails mid-translation, the raw transcript is inserted directly so nothing gets lost. See issue #4.',
|
||||
},
|
||||
},
|
||||
selectionAsk: {
|
||||
kicker: 'SELECTION ASK',
|
||||
title: 'Selection Ask',
|
||||
desc: 'Select text in any app, press the hotkey, ask a question by voice, and the AI answer appears in a floating panel above the recording capsule.',
|
||||
statusEnabled: 'Enabled',
|
||||
statusDisabled: 'Disabled',
|
||||
hotkey: {
|
||||
title: 'Trigger hotkey',
|
||||
desc: 'When set, selecting text in any app and pressing this combo opens a panel and starts recording. Press again to stop and generate the answer. Pick "Disabled" to turn the feature off.',
|
||||
optionDisabled: 'Disabled',
|
||||
chordWarning: '⚠️ Modifier-only chord (Cmd+Option / Ctrl+Alt): both modifiers held without any other key, then released. Fewer keys, but higher false-trigger rate than 3-key combos. Chord backend requires v1.2.9+; older builds, please pick a 3-key combo.',
|
||||
},
|
||||
history: {
|
||||
title: 'Save history',
|
||||
desc: 'When on, every selection + voice question + AI answer is persisted locally (never uploaded). Off by default — closing the panel forgets the exchange entirely, more private.',
|
||||
},
|
||||
howto: {
|
||||
title: 'How to use',
|
||||
step1: 'Select text in any app (browser, Mail, IDE, PDF reader…).',
|
||||
step2: 'Press「{{hotkey}}」— the panel appears at the bottom of the screen and recording starts.',
|
||||
step3: 'Ask your question into the mic — e.g. "what does this mean", "explain in plain words", "how is it different from X".',
|
||||
step4: 'Press「{{hotkey}}」again to stop. The system sends your selection + voice question to the LLM.',
|
||||
step5: 'The answer renders as markdown in the panel. Press Esc or click outside to dismiss; pin (📌, top-right) to keep it open until manually closed.',
|
||||
windowTitle: 'Panel position + lifecycle',
|
||||
windowDesc: 'The panel sits 8px above the recording capsule by default, sized 380×280. Auto-closes after 30s of inactivity (unless pinned). It scrolls if the answer is long.',
|
||||
privacyTitle: 'Privacy contract',
|
||||
privacyDesc: 'Selected text lives only in memory until the panel closes — it is **never** written to the history archive (Save history toggles only Q&A metadata). Selections over 4000 chars are truncated to head+tail of 2000 each before being sent. LLM calls go through your configured ARK / DeepSeek / OpenAI-compatible endpoint.',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
kicker: 'SETTINGS',
|
||||
title: 'Settings',
|
||||
@@ -228,11 +258,6 @@ export const en: typeof zhCN = {
|
||||
capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording / transcribing.',
|
||||
restoreClipboardLabel: 'Restore clipboard after insert',
|
||||
restoreClipboardDesc: 'Windows / Linux only: restore your original clipboard after a successful paste (default on). Turn off to keep the dictation text in the clipboard so you can manually Ctrl+V if the simulated paste did not actually land. See issue #111.',
|
||||
qaHotkeyLabel: 'Question-mode hotkey',
|
||||
qaHotkeyDesc: 'Select some text, press this hotkey to open the QA panel and start recording; press again to stop and get the answer. See issue #118.',
|
||||
qaHotkeyOptionDisabled: 'Disabled',
|
||||
qaSaveHistoryLabel: 'Save Q&A history',
|
||||
qaSaveHistoryDesc: 'Persist each question and answer to local storage. Turn off to leave no trace.',
|
||||
},
|
||||
providers: {
|
||||
llmTitle: 'LLM (polishing)',
|
||||
|
||||
@@ -47,6 +47,7 @@ export const zhCN = {
|
||||
vocab: '词汇表',
|
||||
style: '风格',
|
||||
translation: '翻译',
|
||||
selectionAsk: '划词追问',
|
||||
},
|
||||
shell: {
|
||||
shortcutLabel: '录音快捷键',
|
||||
@@ -198,6 +199,35 @@ export const zhCN = {
|
||||
fallbackDesc: '翻译模式选「不启用」时 Shift 是没作用的;翻译过程中如果大模型调用失败,会回退到把原始中文转写直接插入,不会丢字。详见 issue #4。',
|
||||
},
|
||||
},
|
||||
selectionAsk: {
|
||||
kicker: 'SELECTION ASK',
|
||||
title: '划词追问',
|
||||
desc: '选中任意 app 里的一段文字,按下快捷键,对着麦克风口头提问,AI 答案会以浮窗显示在屏幕底部录音胶囊正上方。',
|
||||
statusEnabled: '已启用',
|
||||
statusDisabled: '未启用',
|
||||
hotkey: {
|
||||
title: '触发快捷键',
|
||||
desc: '选了某组按键后,在任意 app 选中文字时按下,浮窗即可弹出并开始录音;再按一次停止录音并生成答案。选「不启用」则关闭整个功能。',
|
||||
optionDisabled: '不启用',
|
||||
chordWarning: '⚠️ 双修饰键 chord(Cmd+Option / Ctrl+Alt)触发条件:两个修饰键同时按下、不按其他键、再松开两个键。优点是按键少;缺点是误触率比 3 键组合高(你想按 Cmd+Option+某字母 但还没按字母时容易触发)。后端 chord 监听需要 v1.2.9+ 才支持,更早版本请选 3 键组合。',
|
||||
},
|
||||
history: {
|
||||
title: '保存历史',
|
||||
desc: '勾上则把每次追问的「选中文本 + 你的语音问题 + AI 答案」写入本地存档(不上云)。默认关,关闭时浮窗一关问答即遗忘,更注重隐私。',
|
||||
},
|
||||
howto: {
|
||||
title: '使用方法',
|
||||
step1: '在任意 app(浏览器、Mail、IDE、PDF reader…)选中一段文字。',
|
||||
step2: '按下「{{hotkey}}」——浮窗在屏幕底部弹出,同时进入语音录音状态。',
|
||||
step3: '对着麦克风提问——比如"这是什么意思 / 用大白话解释 / 跟 X 有什么区别 / 把它拆成几个要点"。',
|
||||
step4: '再按一次「{{hotkey}}」停止录音。系统会把「你选中的文字 + 你的语音问题」一起送给大模型生成答案。',
|
||||
step5: '答案以 markdown 格式显示在浮窗里。读完按 Esc 或点击外部即可关闭;右上角 📌 钉住则会一直保留直到你手动关。',
|
||||
windowTitle: '浮窗位置 + 生命周期',
|
||||
windowDesc: '浮窗默认在录音胶囊正上方 8px 处,宽 380×高 280。30 秒未操作自动关闭(钉住的不算)。窗口有自己的滚动条,长答案可以滚动阅读。',
|
||||
privacyTitle: '隐私契约',
|
||||
privacyDesc: '选中的文本只在内存里活到浮窗关闭,**绝不**写入历史存档(保存历史开关只控制问答 metadata);超过 4000 字符会截首+尾各 2000 后再上送大模型,避免泄露太多。LLM 调用走你已配的 ARK / DeepSeek 等 OpenAI 兼容 endpoint。',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
kicker: 'SETTINGS',
|
||||
title: '设置',
|
||||
@@ -226,11 +256,6 @@ export const zhCN = {
|
||||
capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。',
|
||||
restoreClipboardLabel: '插入后恢复剪贴板',
|
||||
restoreClipboardDesc: '仅 Windows / Linux:粘贴成功后恢复你原来的剪贴板内容(默认开)。关掉就把听写文本留在剪贴板,模拟粘贴没真正落地时可以手动 Ctrl+V 找回。详见 issue #111。',
|
||||
qaHotkeyLabel: '提问模式快捷键',
|
||||
qaHotkeyDesc: '选中一段文字后按下此快捷键,弹出问答浮窗并开始录音;再按一次结束录音、生成答案。详见 issue #118。',
|
||||
qaHotkeyOptionDisabled: '不启用',
|
||||
qaSaveHistoryLabel: '保存 Q&A 历史',
|
||||
qaSaveHistoryDesc: '把每次划词问答的问题与答案写入本地存档。关闭则不保留任何痕迹。',
|
||||
},
|
||||
providers: {
|
||||
llmTitle: 'LLM 模型(润色)',
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
// SelectionAsk.tsx — 独立的"划词追问"页(issue #118 / PR #119 配置 UI 拆分版)。
|
||||
// 功能:用户在任意 app 选中一段文字 → 按 hotkey → 浮窗弹出 + 进入语音录音 →
|
||||
// 用户口述提问 → ASR + 选区 + 提问 一起送 LLM → 答案以 markdown 显示在浮窗。
|
||||
//
|
||||
// 这一页把原本散在 Settings → 录音 里的两条配置(hotkey 预设 / 保存 Q&A 历史)
|
||||
// 集中起来 + 加完整使用指南,跟"翻译"页平级。
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, PageHeader } from './_atoms';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import { setQaHotkey } from '../lib/ipc';
|
||||
import type { QaHotkeyBinding } from '../lib/types';
|
||||
|
||||
const QA_HOTKEY_DISABLED_ID = 'disabled' as const;
|
||||
|
||||
interface QaHotkeyPreset {
|
||||
id: string;
|
||||
binding: QaHotkeyBinding;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const QA_HOTKEY_PRESETS: readonly QaHotkeyPreset[] = [
|
||||
// 双修饰键 chord(Cmd+Option / Ctrl+Alt)默认排第一——用户偏好的纯组合键。
|
||||
// 后端实现需要 CGEventTap 的"双修饰键按下后无其他键插入即释放"模式(待后端补)。
|
||||
{ id: 'cmd+option', label: 'Cmd+Option', binding: { primary: '', modifiers: ['cmd', 'option'] } },
|
||||
{ id: 'cmd+shift+;', label: 'Cmd+Shift+;', binding: { primary: ';', modifiers: ['cmd', 'shift'] } },
|
||||
{ id: 'cmd+option+;', label: 'Cmd+Option+;', binding: { primary: ';', modifiers: ['cmd', 'option'] } },
|
||||
{ id: 'cmd+shift+/', label: 'Cmd+Shift+/', binding: { primary: '/', modifiers: ['cmd', 'shift'] } },
|
||||
{ id: 'cmd+option+/', label: 'Cmd+Option+/', binding: { primary: '/', modifiers: ['cmd', 'option'] } },
|
||||
] as const;
|
||||
|
||||
function bindingToPresetId(binding: QaHotkeyBinding | null): string {
|
||||
if (!binding) return QA_HOTKEY_DISABLED_ID;
|
||||
const sortedMods = [...binding.modifiers].map(m => m.toLowerCase()).sort();
|
||||
const match = QA_HOTKEY_PRESETS.find(p => {
|
||||
const pMods = [...p.binding.modifiers].sort();
|
||||
return p.binding.primary === binding.primary
|
||||
&& pMods.length === sortedMods.length
|
||||
&& pMods.every((m, i) => m === sortedMods[i]);
|
||||
});
|
||||
return match ? match.id : QA_HOTKEY_PRESETS[0].id;
|
||||
}
|
||||
|
||||
export function SelectionAsk() {
|
||||
const { t } = useTranslation();
|
||||
const { prefs, updatePrefs: savePrefs } = useHotkeySettings();
|
||||
|
||||
if (!prefs) {
|
||||
return (
|
||||
<>
|
||||
<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;
|
||||
try {
|
||||
await setQaHotkey(preset.binding);
|
||||
} catch (error) {
|
||||
console.error('[selectionAsk] failed to set qa hotkey', error);
|
||||
}
|
||||
await savePrefs({ ...prefs, qaHotkey: preset.binding });
|
||||
};
|
||||
|
||||
const onSaveHistoryChange = (qaSaveHistory: boolean) =>
|
||||
savePrefs({ ...prefs, qaSaveHistory });
|
||||
|
||||
const enabled = prefs.qaHotkey !== null;
|
||||
const currentId = bindingToPresetId(prefs.qaHotkey);
|
||||
const currentLabel = QA_HOTKEY_PRESETS.find(p => p.id === currentId)?.label ?? '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
{currentId === 'cmd+option' && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 8,
|
||||
background: 'rgba(217, 119, 6, 0.08)',
|
||||
border: '0.5px solid rgba(217, 119, 6, 0.2)',
|
||||
fontSize: 11.5,
|
||||
color: 'var(--ol-warn)',
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{t('selectionAsk.hotkey.chordWarning')}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
setActiveAsrProvider,
|
||||
setActiveLlmProvider,
|
||||
setCredential,
|
||||
setQaHotkey,
|
||||
} from '../lib/ipc';
|
||||
import type {
|
||||
HotkeyCapability,
|
||||
@@ -29,7 +28,6 @@ import type {
|
||||
HotkeyStatus,
|
||||
HotkeyTrigger,
|
||||
PermissionStatus,
|
||||
QaHotkeyBinding,
|
||||
} from '../lib/types';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import i18n, {
|
||||
@@ -134,37 +132,6 @@ function SettingRow({ label, desc, children }: SettingRowProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// QA 提问模式快捷键预设。详见 issue #118。
|
||||
// id="disabled" 是哨兵 — savePrefs({ qaHotkey: null }) 表示禁用。
|
||||
const QA_HOTKEY_DISABLED_ID = 'disabled' as const;
|
||||
|
||||
interface QaHotkeyPreset {
|
||||
id: string;
|
||||
binding: QaHotkeyBinding;
|
||||
/** UI 上展示的字面值;后端的 get_qa_hotkey_label 仅用作冗余提示,不必一致。 */
|
||||
label: string;
|
||||
}
|
||||
|
||||
const QA_HOTKEY_PRESETS: readonly QaHotkeyPreset[] = [
|
||||
{ id: 'cmd+shift+;', label: 'Cmd+Shift+;', binding: { primary: ';', modifiers: ['cmd', 'shift'] } },
|
||||
{ id: 'cmd+option+;', label: 'Cmd+Option+;', binding: { primary: ';', modifiers: ['cmd', 'option'] } },
|
||||
{ id: 'cmd+shift+/', label: 'Cmd+Shift+/', binding: { primary: '/', modifiers: ['cmd', 'shift'] } },
|
||||
{ id: 'cmd+option+/', label: 'Cmd+Option+/', binding: { primary: '/', modifiers: ['cmd', 'option'] } },
|
||||
] as const;
|
||||
|
||||
function bindingToPresetId(binding: QaHotkeyBinding | null): string {
|
||||
if (!binding) return QA_HOTKEY_DISABLED_ID;
|
||||
const sortedMods = [...binding.modifiers].map(m => m.toLowerCase()).sort();
|
||||
const match = QA_HOTKEY_PRESETS.find(p => {
|
||||
const pMods = [...p.binding.modifiers].sort();
|
||||
return p.binding.primary === binding.primary
|
||||
&& pMods.length === sortedMods.length
|
||||
&& pMods.every((m, i) => m === sortedMods[i]);
|
||||
});
|
||||
// 后端如果当前绑定是非预设值,UI 兜底回到默认 cmd+shift+;
|
||||
return match ? match.id : QA_HOTKEY_PRESETS[0].id;
|
||||
}
|
||||
|
||||
function RecordingSection() {
|
||||
const { t } = useTranslation();
|
||||
const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings();
|
||||
@@ -185,24 +152,6 @@ function RecordingSection() {
|
||||
savePrefs({ ...prefs, showCapsule });
|
||||
const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) =>
|
||||
savePrefs({ ...prefs, restoreClipboardAfterPaste });
|
||||
const onQaHotkeyChange = async (id: string) => {
|
||||
if (id === QA_HOTKEY_DISABLED_ID) {
|
||||
await savePrefs({ ...prefs, qaHotkey: null });
|
||||
return;
|
||||
}
|
||||
const preset = QA_HOTKEY_PRESETS.find(p => p.id === id);
|
||||
if (!preset) return;
|
||||
// 同步两条路:set_qa_hotkey 让后端立刻重装 hotkey 监听;
|
||||
// savePrefs 让 UserPreferences 落盘。两个 IPC 由后端决定能否合并。
|
||||
try {
|
||||
await setQaHotkey(preset.binding);
|
||||
} catch (error) {
|
||||
console.error('[settings] failed to set qa hotkey', error);
|
||||
}
|
||||
await savePrefs({ ...prefs, qaHotkey: preset.binding });
|
||||
};
|
||||
const onQaSaveHistoryChange = (qaSaveHistory: boolean) =>
|
||||
savePrefs({ ...prefs, qaSaveHistory });
|
||||
|
||||
const choices: Array<[HotkeyMode, string]> = [
|
||||
['toggle', t('settings.recording.modeToggle')],
|
||||
@@ -280,33 +229,6 @@ function RecordingSection() {
|
||||
>
|
||||
<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}
|
||||
|
||||
@@ -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