Merge pull request #206 from appergb/fix/windows-qa-panel-205

fix(qa,windows): 划词追问浮窗读用户当前录音键 + Windows 加 toolbar 拖拽 (#205)
This commit is contained in:
TRIP authored and GitHub committed 2026-05-04 07:33:28 +08:00
commit e4f6f2eb43
4 files changed
+116 -31

No files matched your search

+12 -3
View File
@@ -5,7 +5,7 @@ use std::time::Duration;
use serde::Serialize;
use serde_json::Value;
use tauri::{AppHandle, State};
use tauri::{AppHandle, Emitter, State};
use crate::coordinator::Coordinator;
use crate::permissions::{self, PermissionStatus};
@@ -67,8 +67,17 @@ fn persist_settings<T: SettingsWriter>(coord: &T, prefs: UserPreferences) -> Res
}
#[tauri::command]
pub fn set_settings(coord: CoordinatorState<'_>, prefs: UserPreferences) -> Result<(), String> {
persist_settings(&*coord, prefs)
pub fn set_settings(
coord: CoordinatorState<'_>,
app: AppHandle,
prefs: UserPreferences,
) -> Result<(), String> {
// 广播给所有 webview。issue #205:QaPanel 跑在独立 webview,
// 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时
// 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。
persist_settings(&*coord, prefs.clone())?;
let _ = app.emit("prefs:changed", &prefs);
Ok(())
}
#[tauri::command]
+5 -5
View File
@@ -38,15 +38,15 @@ export const en: typeof zhCN = {
thinking: 'Thinking…',
error: 'Something went wrong. Please try again.',
errorRetry: 'Retry',
errorRetryHint: 'Press Option again to retry.',
errorRetryHint: 'Press {{recordHotkey}} again to retry.',
pinTooltip: 'Pin (stay open)',
unpinTooltip: 'Unpin',
closeTooltip: 'Close',
selectionPreview: 'From selected text:',
emptyTitle: 'Press Option to ask',
emptyDesc: 'Select text in any app, press Option once to start recording, press it again to submit. Answers appear here. You can ask follow-up questions in the same panel.',
recordingHint: 'Recording… press Option again to submit',
statusIdle: 'Press Option to ask',
emptyTitle: 'Press {{recordHotkey}} to ask',
emptyDesc: 'Select text in any app, press {{recordHotkey}} once to start recording, press it again to submit. Answers appear here. You can ask follow-up questions in the same panel.',
recordingHint: 'Recording… press {{recordHotkey}} again to submit',
statusIdle: 'Press {{recordHotkey}} to ask',
statusRecording: 'Recording',
statusThinking: 'Thinking',
statusError: 'Error',
+5 -5
View File
@@ -36,15 +36,15 @@ export const zhCN = {
thinking: '思考中…',
error: '出错了,请稍后再试。',
errorRetry: '重试',
errorRetryHint: '再按 Option 重新提问。',
errorRetryHint: '再按 {{recordHotkey}} 重新提问。',
pinTooltip: '固定(不自动关闭)',
unpinTooltip: '取消固定',
closeTooltip: '关闭',
selectionPreview: '基于选中文本:',
emptyTitle: '按 Option 开始提问',
emptyDesc: '在任意 app 选中一段文字后,按一次 Option 开始录音,再按一次结束并提交。回答会显示在这里,可以连续多轮追问。',
recordingHint: '录音中…再按一次 Option 结束并提问',
statusIdle: '按 Option 提问',
emptyTitle: '按 {{recordHotkey}} 开始提问',
emptyDesc: '在任意 app 选中一段文字后,按一次 {{recordHotkey}} 开始录音,再按一次结束并提交。回答会显示在这里,可以连续多轮追问。',
recordingHint: '录音中…再按一次 {{recordHotkey}} 结束并提问',
statusIdle: '按 {{recordHotkey}} 提问',
statusRecording: '录音中',
statusThinking: '思考中',
statusError: '出错了',
+94 -18
View File
@@ -13,8 +13,9 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { marked } from 'marked';
import { isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc';
import type { QaChatMessage, QaStatePayload } from '../lib/types';
import { getSettings, isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc';
import type { QaChatMessage, QaStatePayload, UserPreferences } from '../lib/types';
import { getHotkeyTriggerLabel } from '../lib/hotkey';
const SELECTION_PREVIEW_MAX = 60;
@@ -23,7 +24,7 @@ marked.setOptions({ gfm: true, breaks: true });
type Status = 'idle' | 'recording' | 'thinking' | 'error';
export function QaPanel() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [messages, setMessages] = useState<QaChatMessage[]>([]);
const [status, setStatus] = useState<Status>('idle');
const [errorMsg, setErrorMsg] = useState<string>('');
@@ -33,6 +34,12 @@ export function QaPanel() {
const [streamingAnswer, setStreamingAnswer] = useState<string>('');
/** 录音电平:0..1。后端每帧 33ms 通过 qa:level emit。详见 issue #162。 */
const [level, setLevel] = useState<number>(0);
/** 用户当前的录音热键 label(如 "右 Option" / "Right Alt")。issue #205:
* 原版硬编码 "Option",Windows 用户没这个键,文案失真。读 prefs 后由 i18n
* 插值动态显示,平台与用户配置都能跟上。 */
const [recordHotkeyLabel, setRecordHotkeyLabel] = useState<string>(() =>
i18n.t('hotkey.fallback'),
);
const tRef = useRef(t);
tRef.current = t;
@@ -42,6 +49,7 @@ export function QaPanel() {
let unlistenState: (() => void) | undefined;
let unlistenDismiss: (() => void) | undefined;
let unlistenLevel: (() => void) | undefined;
let unlistenPrefs: (() => void) | undefined;
let cancelled = false;
(async () => {
try {
@@ -111,14 +119,22 @@ export function QaPanel() {
const levelHandle = await listen<{ level: number }>('qa:level', event => {
setLevel(event.payload.level ?? 0);
});
// prefs:changed — 后端在 set_settings 后广播。issue #205:QA 浮窗在独立
// webview,没有 HotkeySettingsContext;如果用户在主窗口改了录音键,
// 浮窗里的 "{recordHotkey}" 文案必须立刻跟上,否则会一直停在旧值。
const prefsHandle = await listen<UserPreferences>('prefs:changed', event => {
setRecordHotkeyLabel(getHotkeyTriggerLabel(event.payload?.hotkey?.trigger));
});
if (cancelled) {
stateHandle();
dismissHandle();
levelHandle();
prefsHandle();
} else {
unlistenState = stateHandle;
unlistenDismiss = dismissHandle;
unlistenLevel = levelHandle;
unlistenPrefs = prefsHandle;
}
} catch (error) {
console.error('[QaPanel] listener setup failed', error);
@@ -129,6 +145,7 @@ export function QaPanel() {
unlistenState?.();
unlistenDismiss?.();
unlistenLevel?.();
unlistenPrefs?.();
};
}, []);
@@ -144,6 +161,29 @@ export function QaPanel() {
return () => window.removeEventListener('keydown', onKey, true);
}, []);
// ── 读取用户当前的录音热键 label,给 i18n 插值用(issue #205)。
// QaPanel 跑在独立 webview(label="qa"),没有 HotkeySettingsContext
// 注入,所以直接走 IPC 拿一次 prefs。语言切换时 i18n.t('hotkey.fallback')
// 也要跟着重算,所以 i18n.language 入依赖。
useEffect(() => {
if (!isTauri) {
setRecordHotkeyLabel(i18n.t('hotkey.fallback'));
return;
}
let cancelled = false;
void getSettings()
.then(prefs => {
if (cancelled) return;
setRecordHotkeyLabel(getHotkeyTriggerLabel(prefs.hotkey?.trigger));
})
.catch(err => {
console.warn('[QaPanel] load hotkey label failed', err);
});
return () => {
cancelled = true;
};
}, [i18n.language]);
const onTogglePin = () => {
const next = !pinned;
setPinned(next);
@@ -166,13 +206,26 @@ export function QaPanel() {
<div style={shellStyle}>
<Toolbar pinned={pinned} onTogglePin={onTogglePin} onClose={onClose} />
<div ref={scrollRef} style={contentStyle}>
{messages.length === 0 && status === 'idle' && <EmptyHint t={t} />}
{messages.length === 0 && status === 'idle' && (
<EmptyHint t={t} recordHotkey={recordHotkeyLabel} />
)}
{messages.length === 0 && status === 'recording' && (
<RecordingHeader preview={selectionPreview} t={t} level={level} />
<RecordingHeader
preview={selectionPreview}
t={t}
level={level}
recordHotkey={recordHotkeyLabel}
/>
)}
<MessageList messages={messages} />
{status === 'recording' && messages.length > 0 && (
<TurnIndicator kind="recording" t={t} preview={selectionPreview} level={level} />
<TurnIndicator
kind="recording"
t={t}
preview={selectionPreview}
level={level}
recordHotkey={recordHotkeyLabel}
/>
)}
{streamingAnswer && (
<StreamingAssistantBubble markdown={streamingAnswer} />
@@ -180,9 +233,11 @@ export function QaPanel() {
{status === 'thinking' && !streamingAnswer && (
<TurnIndicator kind="thinking" t={t} />
)}
{status === 'error' && <ErrorRow message={errorMsg} t={t} />}
{status === 'error' && (
<ErrorRow message={errorMsg} t={t} recordHotkey={recordHotkeyLabel} />
)}
</div>
<StatusBar status={status} t={t} />
<StatusBar status={status} t={t} recordHotkey={recordHotkeyLabel} />
</div>
);
}
@@ -197,11 +252,16 @@ interface ToolbarProps {
function Toolbar({ pinned, onTogglePin, onClose }: ToolbarProps) {
const { t } = useTranslation();
// 拖动靠 NSWindow.movableByWindowBackground=YES(lib.rs::make_qa_window_draggable_macos)
// 在 AppKit 层处理。前端不需要 onMouseDown / data-tauri-drag-region。
// 拖动 (issue #205):
// - macOS: lib.rs::make_qa_window_draggable_macos 在 NSWindow 层把整窗口设
// movableByWindowBackground=YES,所以 macOS 上整片背景都可拖。
// - Windows: NSWindow 走不了;用 Tauri 标准 data-tauri-drag-region —— mousedown
// 走 startDragging() → WM_NCLBUTTONDOWN(HTCAPTION),在 focus:false 浮窗上也能用。
// 两条路径并存不冲突;data-tauri-drag-region 放在 toolbar 的空白 spacer 上,IconBtn
// 作为 button 子元素仍然正常 click。
return (
<div style={toolbarStyle}>
<div style={{ flex: 1, height: '100%' }} />
<div data-tauri-drag-region style={{ flex: 1, height: '100%' }} />
<IconBtn
label={pinned ? t('qa.unpinTooltip') : t('qa.pinTooltip')}
active={pinned}
@@ -255,14 +315,20 @@ function IconBtn({ label, active, onClick, children }: IconBtnProps) {
);
}
function EmptyHint({ t }: { t: ReturnType<typeof useTranslation>['t'] }) {
function EmptyHint({
t,
recordHotkey,
}: {
t: ReturnType<typeof useTranslation>['t'];
recordHotkey: string;
}) {
return (
<div style={emptyHintStyle}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, color: 'var(--ol-ink)' }}>
{t('qa.emptyTitle')}
{t('qa.emptyTitle', { recordHotkey })}
</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.6 }}>
{t('qa.emptyDesc')}
{t('qa.emptyDesc', { recordHotkey })}
</div>
</div>
);
@@ -272,10 +338,12 @@ function RecordingHeader({
preview,
t,
level,
recordHotkey,
}: {
preview: string;
t: ReturnType<typeof useTranslation>['t'];
level: number;
recordHotkey: string;
}) {
const truncated = useMemo(() => truncate(preview, SELECTION_PREVIEW_MAX), [preview]);
return (
@@ -290,7 +358,7 @@ function RecordingHeader({
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--ol-ink-2)' }}>
<span style={recordingDotStyle} />
{t('qa.recordingHint')}
{t('qa.recordingHint', { recordHotkey })}
</div>
<LevelBar level={level} />
</div>
@@ -418,11 +486,13 @@ function TurnIndicator({
preview,
t,
level,
recordHotkey,
}: {
kind: 'recording' | 'thinking';
preview?: string;
t: ReturnType<typeof useTranslation>['t'];
level?: number;
recordHotkey?: string;
}) {
if (kind === 'recording') {
const truncated = preview ? truncate(preview, SELECTION_PREVIEW_MAX) : '';
@@ -438,7 +508,7 @@ function TurnIndicator({
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--ol-ink-2)' }}>
<span style={recordingDotStyle} />
{t('qa.recordingHint')}
{t('qa.recordingHint', { recordHotkey: recordHotkey ?? '' })}
</div>
<LevelBar level={level ?? 0} />
</div>
@@ -459,14 +529,18 @@ function TurnIndicator({
function ErrorRow({
message,
t,
recordHotkey,
}: {
message: string;
t: ReturnType<typeof useTranslation>['t'];
recordHotkey: string;
}) {
return (
<div style={errorRowStyle}>
<div style={{ fontSize: 12.5, color: 'var(--ol-err)', lineHeight: 1.55 }}>{message}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)' }}>{t('qa.errorRetryHint')}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)' }}>
{t('qa.errorRetryHint', { recordHotkey })}
</div>
</div>
);
}
@@ -474,15 +548,17 @@ function ErrorRow({
function StatusBar({
status,
t,
recordHotkey,
}: {
status: Status;
t: ReturnType<typeof useTranslation>['t'];
recordHotkey: string;
}) {
let label = '';
let dotColor = 'transparent';
switch (status) {
case 'idle':
label = t('qa.statusIdle');
label = t('qa.statusIdle', { recordHotkey });
dotColor = 'rgba(0,0,0,0.18)';
break;
case 'recording':