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:
1 parent
e5740298fa
commit
1a0efe402c
11 files changed
+601
-5
No files matched your search
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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 启动不应被权限探测阻塞首屏。
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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 模型(润色)',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user