diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 0d37f1e7..3ef48d5b 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -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(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] diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 157a63e5..c946099c 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -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', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index c98358bb..a473492b 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -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: '出错了', diff --git a/openless-all/app/src/pages/QaPanel.tsx b/openless-all/app/src/pages/QaPanel.tsx index 50ca1f15..5703ac82 100644 --- a/openless-all/app/src/pages/QaPanel.tsx +++ b/openless-all/app/src/pages/QaPanel.tsx @@ -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([]); const [status, setStatus] = useState('idle'); const [errorMsg, setErrorMsg] = useState(''); @@ -33,6 +34,12 @@ export function QaPanel() { const [streamingAnswer, setStreamingAnswer] = useState(''); /** 录音电平:0..1。后端每帧 33ms 通过 qa:level emit。详见 issue #162。 */ const [level, setLevel] = useState(0); + /** 用户当前的录音热键 label(如 "右 Option" / "Right Alt")。issue #205: + * 原版硬编码 "Option",Windows 用户没这个键,文案失真。读 prefs 后由 i18n + * 插值动态显示,平台与用户配置都能跟上。 */ + const [recordHotkeyLabel, setRecordHotkeyLabel] = useState(() => + 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('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() {
- {messages.length === 0 && status === 'idle' && } + {messages.length === 0 && status === 'idle' && ( + + )} {messages.length === 0 && status === 'recording' && ( - + )} {status === 'recording' && messages.length > 0 && ( - + )} {streamingAnswer && ( @@ -180,9 +233,11 @@ export function QaPanel() { {status === 'thinking' && !streamingAnswer && ( )} - {status === 'error' && } + {status === 'error' && ( + + )}
- +
); } @@ -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 (
-
+
['t'] }) { +function EmptyHint({ + t, + recordHotkey, +}: { + t: ReturnType['t']; + recordHotkey: string; +}) { return (
- {t('qa.emptyTitle')} + {t('qa.emptyTitle', { recordHotkey })}
- {t('qa.emptyDesc')} + {t('qa.emptyDesc', { recordHotkey })}
); @@ -272,10 +338,12 @@ function RecordingHeader({ preview, t, level, + recordHotkey, }: { preview: string; t: ReturnType['t']; level: number; + recordHotkey: string; }) { const truncated = useMemo(() => truncate(preview, SELECTION_PREVIEW_MAX), [preview]); return ( @@ -290,7 +358,7 @@ function RecordingHeader({ )}
- {t('qa.recordingHint')} + {t('qa.recordingHint', { recordHotkey })}
@@ -418,11 +486,13 @@ function TurnIndicator({ preview, t, level, + recordHotkey, }: { kind: 'recording' | 'thinking'; preview?: string; t: ReturnType['t']; level?: number; + recordHotkey?: string; }) { if (kind === 'recording') { const truncated = preview ? truncate(preview, SELECTION_PREVIEW_MAX) : ''; @@ -438,7 +508,7 @@ function TurnIndicator({ )}
- {t('qa.recordingHint')} + {t('qa.recordingHint', { recordHotkey: recordHotkey ?? '' })}
@@ -459,14 +529,18 @@ function TurnIndicator({ function ErrorRow({ message, t, + recordHotkey, }: { message: string; t: ReturnType['t']; + recordHotkey: string; }) { return (
{message}
-
{t('qa.errorRetryHint')}
+
+ {t('qa.errorRetryHint', { recordHotkey })} +
); } @@ -474,15 +548,17 @@ function ErrorRow({ function StatusBar({ status, t, + recordHotkey, }: { status: Status; t: ReturnType['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':