Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions openless-all/app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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]
Expand Down
10 changes: 5 additions & 5 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 5 additions & 5 deletions openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '出错了',
Expand Down
112 changes: 94 additions & 18 deletions openless-all/app/src/pages/QaPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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>('');
Expand All @@ -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;

Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -129,6 +145,7 @@ export function QaPanel() {
unlistenState?.();
unlistenDismiss?.();
unlistenLevel?.();
unlistenPrefs?.();
};
}, []);

Expand All @@ -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);
Expand All @@ -166,23 +206,38 @@ 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} />
)}
{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>
);
}
Expand All @@ -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}
Expand Down Expand Up @@ -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>
);
Expand All @@ -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 (
Expand All @@ -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>
Expand Down Expand Up @@ -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) : '';
Expand All @@ -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>
Expand All @@ -459,30 +529,36 @@ 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>
);
}

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':
Expand Down
Loading