From 5b8be1664a73104113290b901b485d797aadbcea Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 3 May 2026 16:49:54 +0800 Subject: [PATCH 01/13] =?UTF-8?q?fix(qa,windows):=20=E5=88=92=E8=AF=8D?= =?UTF-8?q?=E8=BF=BD=E9=97=AE=E6=B5=AE=E7=AA=97=E8=AF=BB=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E5=BD=95=E9=9F=B3=E9=94=AE=E3=80=81Windows?= =?UTF-8?q?=20=E5=8A=A0=20toolbar=20=E6=8B=96=E6=8B=BD=20(#205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows 端的滑词追问浮窗存在三处问题:(1) qa.* i18n 硬编码 "Option", Windows 用户没这个键;(2) 拖拽只走 macOS NSWindow.movableByWindowBackground, Windows 上无等价路径;(3) toolbar 注释明确写「不需要 data-tauri-drag-region」 导致 Tauri 标准拖拽通路也没接。 修复: - QaPanel mount 时读 prefs.hotkey.trigger,转成 label(如「右 Control」/ 「Right Alt」),通过 i18n {{recordHotkey}} 插值传给 EmptyHint / RecordingHeader / TurnIndicator / ErrorRow / StatusBar 五个子组件,平台 与用户配置都能跟上。 - Toolbar 空白 spacer 加 data-tauri-drag-region:mousedown 走 Tauri 标准 startDragging() → Windows 上是 WM_NCLBUTTONDOWN(HTCAPTION),在 focus:false 浮窗上也能拖;macOS 同时保留原 NSWindow movableByWindowBackground,两条 路径并存不冲突。 --- openless-all/app/src/i18n/en.ts | 10 +-- openless-all/app/src/i18n/zh-CN.ts | 10 +-- openless-all/app/src/pages/QaPanel.tsx | 100 ++++++++++++++++++++----- 3 files changed, 93 insertions(+), 27 deletions(-) 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..5bb43afd 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 { getSettings, isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc'; import type { QaChatMessage, QaStatePayload } 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; @@ -144,6 +151,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 +196,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 +223,11 @@ export function QaPanel() { {status === 'thinking' && !streamingAnswer && ( )} - {status === 'error' && } + {status === 'error' && ( + + )}
- +
); } @@ -197,11 +242,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 +328,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 +348,7 @@ function RecordingHeader({ )}
- {t('qa.recordingHint')} + {t('qa.recordingHint', { recordHotkey })}
@@ -418,11 +476,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 +498,7 @@ function TurnIndicator({ )}
- {t('qa.recordingHint')} + {t('qa.recordingHint', { recordHotkey: recordHotkey ?? '' })}
@@ -459,14 +519,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 +538,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': From 94d6a7199c2933ad5a59bcc8fe775ffee7196eb2 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 3 May 2026 21:54:34 +0800 Subject: [PATCH 02/13] =?UTF-8?q?feat(asr):=20=E6=8E=A5=E5=85=A5=20Qwen3-A?= =?UTF-8?q?SR=20/=20SiliconFlow=20/=20GLM-ASR=20/=20Groq=20=E4=BD=9C?= =?UTF-8?q?=E4=B8=BA=E5=8F=AF=E9=80=89=E6=9C=8D=E5=8A=A1=E5=95=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用现有 OpenAI 兼容 `/audio/transcriptions` 通道(WhisperBatchASR),无需 新增 Rust 客户端。把 `coordinator.rs` 中的 whisper 分支条件改为 `is_whisper_compatible_provider(id)`,新增厂商时一处即可扩展。 设置页 ASR 下拉新增 4 项预设,切换时自动预填 baseUrl 与 model,避免用户 忘填模型 ID 必然踩坑。 Closes #212 --- openless-all/app/src-tauri/src/coordinator.rs | 13 ++++++-- openless-all/app/src/i18n/en.ts | 3 ++ openless-all/app/src/i18n/zh-CN.ts | 3 ++ openless-all/app/src/pages/Settings.tsx | 31 +++++++++++++++---- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 2dab885a..9a3d144c 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -905,7 +905,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { let active_asr = CredentialsVault::get_active_asr(); - if active_asr == "whisper" { + if is_whisper_compatible_provider(&active_asr) { let (api_key, base_url, model) = read_whisper_credentials(); let whisper = Arc::new(WhisperBatchASR::new(api_key, base_url, model)); *inner.asr.lock() = Some(ActiveAsr::Whisper(Arc::clone(&whisper))); @@ -1540,13 +1540,13 @@ fn ensure_microphone_permission(_inner: &Arc) -> Result<(), String> { fn ensure_asr_credentials() -> Result<(), String> { let active_asr = CredentialsVault::get_active_asr(); - if active_asr == "whisper" { + if is_whisper_compatible_provider(&active_asr) { let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) .ok() .flatten() .unwrap_or_default(); if api_key.trim().is_empty() { - return Err("请先在设置中填写 Whisper ASR API Key".to_string()); + return Err("请先在设置中填写 ASR 服务商 API Key".to_string()); } return Ok(()); } @@ -1559,6 +1559,13 @@ fn ensure_asr_credentials() -> Result<(), String> { } } +/// `whisper` 是 OpenAI 原生;`qwen` / `siliconflow` / `zhipu` / `groq` 都暴露 +/// OpenAI 兼容的 `/audio/transcriptions`,统一走 `WhisperBatchASR`。 +/// 新增 OpenAI 兼容 ASR 时只需在这里加一项。 +fn is_whisper_compatible_provider(id: &str) -> bool { + matches!(id, "whisper" | "qwen" | "siliconflow" | "zhipu" | "groq") +} + /// 润色文本;失败时返回原文 + 失败原因,调用方据此弹错误胶囊 + 写历史 error_code。 /// 之前固定返回 String,调用方拿不到失败信号 → 用户感知"为什么风格设置没生效"。issue #57。 async fn polish_or_passthrough( diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 157a63e5..56240039 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -282,7 +282,10 @@ export const en: typeof zhCN = { openai: 'OpenAI', custom: 'Custom', asrVolcengine: 'Volcengine bigasr', + asrQwen: 'Qwen3-ASR (DashScope compatible mode)', asrSiliconflow: 'SiliconFlow SenseVoice', + asrZhipu: 'Zhipu GLM-ASR', + asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper (compatible)', }, volcengineAppKeyLabel: 'APP ID', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index c98358bb..892b9bfd 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -280,7 +280,10 @@ export const zhCN = { openai: 'OpenAI', custom: '自定义', asrVolcengine: '火山引擎 bigasr', + asrQwen: '通义千问 Qwen3-ASR(DashScope 兼容)', asrSiliconflow: '硅基流动 SenseVoice', + asrZhipu: '智谱 GLM-ASR', + asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper(兼容)', }, volcengineAppKeyLabel: 'APP ID', diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 2e60c839..f5e46d4e 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -274,11 +274,18 @@ type LlmPresetId = typeof LLM_PRESETS[number]['id']; const ASR_DEFAULT_RESOURCE_ID = 'volc.seedasr.sauc.duration'; -// SiliconFlow ASR 暂未在后端实现(coordinator.rs 只路由 whisper / volcengine)。 -// 在后端接入前不暴露给用户,避免选了之后必然失败。重新启用见 issue #58 的 follow-up。 +// `volcengine` 走自建流式客户端;其余走 OpenAI 兼容 `/audio/transcriptions` +// (`coordinator.rs::is_whisper_compatible_provider`)。新增兼容厂商: +// 1. 在这里加一项 `{ id, nameKey, baseUrl, model }`; +// 2. `coordinator.rs::is_whisper_compatible_provider` 加同名 id; +// 3. 在 i18n 的 `settings.providers.presets.` 加文案。 const ASR_PRESETS = [ - { id: 'volcengine', nameKey: 'asrVolcengine' }, - { id: 'whisper', nameKey: 'asrWhisper' }, + { id: 'volcengine', nameKey: 'asrVolcengine', baseUrl: '', model: '' }, + { id: 'qwen', nameKey: 'asrQwen', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen3-asr-flash' }, + { id: 'siliconflow', nameKey: 'asrSiliconflow', baseUrl: 'https://api.siliconflow.cn/v1', model: 'FunAudioLLM/SenseVoiceSmall' }, + { id: 'zhipu', nameKey: 'asrZhipu', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-asr-2512' }, + { id: 'groq', nameKey: 'asrGroq', baseUrl: 'https://api.groq.com/openai/v1', model: 'whisper-large-v3-turbo' }, + { id: 'whisper', nameKey: 'asrWhisper', baseUrl: 'https://api.openai.com/v1', model: 'whisper-1' }, ] as const; type AsrPresetId = typeof ASR_PRESETS[number]['id']; @@ -319,9 +326,20 @@ function ProvidersSection() { const next = { ...prefs, activeAsrProvider: id }; await updatePrefs(next); } + // 切换到 OpenAI 兼容厂商时同步预填 endpoint + model:每家 base URL / 模型 ID + // 都是固定的,不预填用户必然踩坑。volcengine 走另一套凭据,跳过。 + const preset = ASR_PRESETS.find(p => p.id === id); + if (preset && preset.baseUrl) { + await setCredential('asr.endpoint', preset.baseUrl); + } + if (preset && preset.model) { + await setCredential('asr.model', preset.model); + setAsrModelRevision(v => v + 1); + } }; const preset = LLM_PRESETS.find(p => p.id === llmProvider) ?? LLM_PRESETS[LLM_PRESETS.length - 1]; + const asrPreset = ASR_PRESETS.find(p => p.id === asrProvider); return ( <> @@ -397,9 +415,10 @@ function ProvidersSection() { <> + placeholder={asrPreset?.baseUrl || 'https://api.openai.com/v1'} + defaultValue={asrPreset?.baseUrl || undefined} /> + placeholder={asrPreset?.model || 'whisper-1'} /> setAsrModelRevision(v => v + 1)} /> )} From 0a4d33c75547e9eaf54247736112849677687962 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 3 May 2026 22:01:34 +0800 Subject: [PATCH 03/13] =?UTF-8?q?fix(asr):=20QA=20=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E6=94=B9=E7=94=A8=E4=B8=93=E7=94=A8=20Volcen?= =?UTF-8?q?gine=20=E6=A3=80=E6=9F=A5=EF=BC=8C=E9=81=BF=E5=85=8D=E8=A2=AB?= =?UTF-8?q?=20active=5Fasr=20=E8=AF=AF=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1 (PR #213): 主听写路径分支化 `ensure_asr_credentials` 后,QA 路径 仍然复用同一函数,导致两类用户可见 bug: 1. 用户选了 qwen/siliconflow/zhipu/groq 但没填 `asr.api_key` → QA 报 "请先填写 ASR 服务商 API Key",可火山凭据明明是齐的。 2. 反之 `asr.api_key` 已填、火山凭据为空 → QA 通过 ensure,进 open_session 再以 Volcengine 凭据错误失败,更难诊断。 修法是给 QA 加专用 `ensure_qa_volcengine_credentials()`,只看 Volcengine 字段; dictation 路径继续走 `ensure_asr_credentials` 不变。 --- openless-all/app/src-tauri/src/coordinator.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 9a3d144c..a2e6b4ea 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1566,6 +1566,18 @@ fn is_whisper_compatible_provider(id: &str) -> bool { matches!(id, "whisper" | "qwen" | "siliconflow" | "zhipu" | "groq") } +/// QA 路径专用:begin_qa_session 永远走 Volcengine 流式(低延迟要求),所以 +/// 凭据校验也只看 Volcengine 字段,不依赖 active_asr。dictation 路径请用 +/// `ensure_asr_credentials`。 +fn ensure_qa_volcengine_credentials() -> Result<(), String> { + let creds = read_volc_credentials(); + if creds.app_id.trim().is_empty() || creds.access_token.trim().is_empty() { + Err("请先在设置中填写火山引擎 ASR App Key 和 Access Key".to_string()) + } else { + Ok(()) + } +} + /// 润色文本;失败时返回原文 + 失败原因,调用方据此弹错误胶囊 + 写历史 error_code。 /// 之前固定返回 String,调用方拿不到失败信号 → 用户感知"为什么风格设置没生效"。issue #57。 async fn polish_or_passthrough( @@ -1752,7 +1764,11 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { // 2. 凭据缺失走静默 fallback:与 dictation 一致的"用户的话不丢"约定。 // 缺火山凭据 → 后续 Recorder 仍会跑,只是 ASR 拿不到结果,end_qa_session // 会发 idle 事件关浮窗。 - if let Err(message) = ensure_asr_credentials() { + // 注意:QA 强制走 Volcengine 流式(见下方注释),所以这里必须直接校验 + // Volcengine 字段,不能复用 `ensure_asr_credentials`——后者会按用户在设置 + // 里选的 active_asr 走 OpenAI 兼容分支,让 QA 把 `asr.api_key` 当成必要项, + // 或在 Volcengine 凭据其实为空时误判通过。Codex P1,PR #213。 + if let Err(message) = ensure_qa_volcengine_credentials() { log::warn!("[coord] QA: ASR credentials missing: {message}"); finish_qa_with_error(inner, format!("缺少 ASR 凭据:{message}")); return Err(message); From 3416919da668ea87abe09b6ec16195bedca94e87 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 3 May 2026 22:18:45 +0800 Subject: [PATCH 04/13] =?UTF-8?q?fix(asr):=20OpenAI=20=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=20preset=20=E4=B9=9F=E5=89=8D=E7=BD=AE=E6=A0=A1=E9=AA=8C=20end?= =?UTF-8?q?point=20=E4=B8=8E=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-Agent #213 reviewer guide: 切到新 preset 后用户若手动清空 asr.endpoint,read_whisper_credentials 会返回空字符串, WhisperBatchASR 拼出相对路径 `/audio/transcriptions`,要等用户录完 才以 reqwest 错误失败。model 同理(兜底成 "whisper-1" 对 SiliconFlow 等是无效模型名,runtime 才 400)。 在 ensure_asr_credentials 的兼容分支里加前置校验,把错误时机提前到 按下热键的瞬间,复用与 api_key 同款 hard-error 语义。 --- openless-all/app/src-tauri/src/coordinator.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index a2e6b4ea..7ac28249 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1548,6 +1548,24 @@ fn ensure_asr_credentials() -> Result<(), String> { if api_key.trim().is_empty() { return Err("请先在设置中填写 ASR 服务商 API Key".to_string()); } + // 切换 preset 时前端会自动预填 endpoint/model,但用户可能手动清空。 + // 提前在这里报错,比录完后让 reqwest 拼出 `/audio/transcriptions` 相对路径 + // 再失败友好得多。model 同理:read_whisper_credentials 兜底成 "whisper-1", + // 对 SiliconFlow / GLM 等是错的,提前提示用户去填本厂商模型 ID。 + let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint) + .ok() + .flatten() + .unwrap_or_default(); + if endpoint.trim().is_empty() { + return Err("请先在设置中填写 ASR 服务商接口地址".to_string()); + } + let model = CredentialsVault::get(CredentialAccount::AsrModel) + .ok() + .flatten() + .unwrap_or_default(); + if model.trim().is_empty() { + return Err("请先在设置中填写 ASR 模型名".to_string()); + } return Ok(()); } From fbcddc40dbb497380f9cf0e7f606cdf27df13f5c Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Mon, 4 May 2026 00:26:44 +0800 Subject: [PATCH 05/13] Add Intel macOS artifacts to Tauri release matrix The release workflow only produced Apple Silicon macOS packages, so Intel users had no official GitHub Release artifact. Extend the matrix with an x86_64 macOS lane and align artifact naming and updater-manifest paths with per-arch outputs. Constraint: Keep existing release pipeline shape and signing flow unchanged Rejected: Split Intel build into separate workflow file | higher maintenance and duplicated logic Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep macOS conditions architecture-agnostic so new mac runners are not skipped accidentally Tested: YAML syntax parse via python yaml.safe_load; workflow diff reviewed for mac gating/artifact paths Not-tested: Full GitHub Actions matrix run on tag push --- .github/workflows/release-tauri.yml | 30 ++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 6b8a2ba9..7ac2b68e 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -4,7 +4,7 @@ name: Release Tauri (cross-platform) # - 手动 dispatch(用于测试构建,不发版) # # 输出: -# macOS arm64 .dmg + Windows x64 .msi/.exe + Linux x64 .deb/.rpm/.AppImage,自动作为 GitHub Release 资产上传。 +# macOS arm64/x64 .dmg + Windows x64 .msi/.exe + Linux x64 .deb/.rpm/.AppImage,自动作为 GitHub Release 资产上传。 # # macOS 分发: # - 配好 APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / APPLE_ID / @@ -32,6 +32,10 @@ jobs: rust-target: aarch64-apple-darwin updater-target: darwin updater-arch: aarch64 + - platform: macos-13 + rust-target: x86_64-apple-darwin + updater-target: darwin + updater-arch: x86_64 - platform: windows-latest rust-target: x86_64-pc-windows-msvc updater-target: windows @@ -97,7 +101,7 @@ jobs: fi - name: Check Apple signing availability - if: matrix.platform == 'macos-latest' && startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri') + if: startsWith(matrix.platform, 'macos') && startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri') shell: bash env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -117,7 +121,7 @@ jobs: fi - name: Import Apple Developer ID certificate - if: matrix.platform == 'macos-latest' + if: startsWith(matrix.platform, 'macos') shell: bash env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -158,7 +162,7 @@ jobs: echo "Imported Apple signing identity: $CERT_ID" - name: Configure Apple notarization - if: matrix.platform == 'macos-latest' + if: startsWith(matrix.platform, 'macos') shell: bash env: APPLE_ID: ${{ secrets.APPLE_ID }} @@ -175,7 +179,7 @@ jobs: # ── macOS:用我们自己的 build-mac.sh,统一处理签名、公证和 artifact 清理 ── - name: Build (macOS) - if: matrix.platform == 'macos-latest' + if: startsWith(matrix.platform, 'macos') working-directory: 'openless-all/app' env: INSTALL: '0' # CI 不要装到 /Applications,也不要 reset TCC @@ -237,7 +241,7 @@ jobs: # 让"云端 artifact 一定干净"成为可验证的承诺。用户下载后再被本地浏览器 # 加 quarantine 时,按 release notes 的 `xattr -cr` 一行即可消除。 - name: Strip xattr / quarantine on macOS bundles - if: matrix.platform == 'macos-latest' + if: startsWith(matrix.platform, 'macos') shell: bash working-directory: 'openless-all/app/src-tauri/target/release/bundle' run: | @@ -250,24 +254,24 @@ jobs: done - name: Upload macOS artifacts - if: matrix.platform == 'macos-latest' + if: startsWith(matrix.platform, 'macos') uses: actions/upload-artifact@v4 with: - name: openless-macos-arm64 + name: openless-macos-${{ matrix.updater-arch }} path: | openless-all/app/src-tauri/target/release/bundle/dmg/*.dmg if-no-files-found: error - name: Upload macOS updater artifacts - if: matrix.platform == 'macos-latest' && env.TAURI_SIGNING_PRIVATE_KEY != '' + if: startsWith(matrix.platform, 'macos') && env.TAURI_SIGNING_PRIVATE_KEY != '' uses: actions/upload-artifact@v4 with: - name: openless-macos-arm64-updater + name: openless-macos-${{ matrix.updater-arch }}-updater path: | openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz.sig - openless-all/app/src-tauri/target/release/bundle/latest-darwin-aarch64.json - openless-all/app/src-tauri/target/release/bundle/latest-darwin-aarch64-mirror.json + openless-all/app/src-tauri/target/release/bundle/latest-darwin-${{ matrix.updater-arch }}.json + openless-all/app/src-tauri/target/release/bundle/latest-darwin-${{ matrix.updater-arch }}-mirror.json if-no-files-found: error - name: Upload Windows artifacts @@ -325,7 +329,7 @@ jobs: prerelease: false # Matrix jobs all upload assets to the same release. Generate notes once # so macOS, Windows, and Linux jobs do not duplicate the release body. - generate_release_notes: ${{ matrix.platform == 'macos-latest' }} + generate_release_notes: ${{ matrix.updater-target == 'darwin' && matrix.updater-arch == 'aarch64' }} files: | openless-all/app/src-tauri/target/release/bundle/dmg/*.dmg openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz From c8ccff0669c60b89aadf0e0c0146e39b11024967 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Mon, 4 May 2026 01:02:44 +0800 Subject: [PATCH 06/13] Avoid macOS updater asset collisions across architectures Intel and Apple Silicon macOS jobs produced the same updater bundle filename, which let one matrix job overwrite the other release asset and could point both architecture manifests to a single binary. Rename macOS updater bundles per architecture before release upload and prefer arch-specific artifact lookup when generating updater manifests. Constraint: Keep existing updater manifest format and release action usage Rejected: Split macOS uploads into separate release jobs | larger workflow churn Confidence: high Scope-risk: narrow Reversibility: clean Directive: Any new macOS updater lane must keep unique asset names to preserve manifest correctness Tested: release-tauri.yml YAML parse; diff audit for rename+manifest path alignment Not-tested: End-to-end tagged GitHub Actions release run --- .github/workflows/release-tauri.yml | 12 ++++++++++++ openless-all/app/scripts/write-updater-manifest.mjs | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 7ac2b68e..2c3588c8 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -217,6 +217,18 @@ jobs: npm run tauri -- build --bundles deb,rpm,appimage fi + - name: Disambiguate macOS updater bundle filename + if: startsWith(matrix.platform, 'macos') && env.TAURI_SIGNING_PRIVATE_KEY != '' + shell: bash + working-directory: 'openless-all/app/src-tauri/target/release/bundle/macos' + run: | + if [ -f OpenLess.app.tar.gz ]; then + mv OpenLess.app.tar.gz "OpenLess_${{ matrix.updater-arch }}.app.tar.gz" + fi + if [ -f OpenLess.app.tar.gz.sig ]; then + mv OpenLess.app.tar.gz.sig "OpenLess_${{ matrix.updater-arch }}.app.tar.gz.sig" + fi + - name: Write updater manifest if: env.TAURI_SIGNING_PRIVATE_KEY != '' shell: bash diff --git a/openless-all/app/scripts/write-updater-manifest.mjs b/openless-all/app/scripts/write-updater-manifest.mjs index eb85a906..eb0e7762 100755 --- a/openless-all/app/scripts/write-updater-manifest.mjs +++ b/openless-all/app/scripts/write-updater-manifest.mjs @@ -17,7 +17,10 @@ const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.me const bundleDir = fileURLToPath(new URL('../src-tauri/target/release/bundle/', import.meta.url)); const candidatesByTarget = { - darwin: ['macos/OpenLess.app.tar.gz'], + darwin: [ + `macos/OpenLess_${arch}.app.tar.gz`, + 'macos/OpenLess.app.tar.gz', + ], windows: ['nsis/OpenLess_*_x64-setup.exe', 'nsis/OpenLess*_x64-setup.exe'], linux: ['appimage/OpenLess_*.AppImage', 'appimage/OpenLess*.AppImage'], }; From 9809b19eab25a37d6d9daf6f4c528e5f5c5edb70 Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 4 May 2026 05:52:51 +0800 Subject: [PATCH 07/13] =?UTF-8?q?revert(asr):=20=E7=A7=BB=E9=99=A4=20endpo?= =?UTF-8?q?int/model=20=E5=89=8D=E7=BD=AE=E7=A1=AC=E9=94=99=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-Agent 在 commit 3416919 后改口反对前置校验("breaks silent-fallback contract, removes the previous backend defaulting behavior")。维护者 @H-Chris233 同样要求处理。 回滚到 0a4d33c 状态:兼容分支只校验 api_key(与 main 上既有 whisper 分支一致),endpoint/model 缺失走 read_whisper_credentials 的既有 fallback(base_url 空字符串、model 兜底 "whisper-1"),让 runtime 失败信号在 coordinator 既有错误胶囊路径里出。 api_key 校验保留,因为这段不是本 PR 引入——main 上既有,超出 V1 范围。 --- openless-all/app/src-tauri/src/coordinator.rs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 7ac28249..a2e6b4ea 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1548,24 +1548,6 @@ fn ensure_asr_credentials() -> Result<(), String> { if api_key.trim().is_empty() { return Err("请先在设置中填写 ASR 服务商 API Key".to_string()); } - // 切换 preset 时前端会自动预填 endpoint/model,但用户可能手动清空。 - // 提前在这里报错,比录完后让 reqwest 拼出 `/audio/transcriptions` 相对路径 - // 再失败友好得多。model 同理:read_whisper_credentials 兜底成 "whisper-1", - // 对 SiliconFlow / GLM 等是错的,提前提示用户去填本厂商模型 ID。 - let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint) - .ok() - .flatten() - .unwrap_or_default(); - if endpoint.trim().is_empty() { - return Err("请先在设置中填写 ASR 服务商接口地址".to_string()); - } - let model = CredentialsVault::get(CredentialAccount::AsrModel) - .ok() - .flatten() - .unwrap_or_default(); - if model.trim().is_empty() { - return Err("请先在设置中填写 ASR 模型名".to_string()); - } return Ok(()); } From d048107abb17ffec65fe1a84a743b3029175f97d Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 4 May 2026 06:53:40 +0800 Subject: [PATCH 08/13] =?UTF-8?q?fix(asr):=20=E6=8A=8A=20Qwen3-ASR-Flash?= =?UTF-8?q?=20=E4=BB=8E=20V1=20=E6=8B=BF=E6=8E=89=EF=BC=8C=E9=9C=80?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=95=99=E7=BB=99?= =?UTF-8?q?=20V2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reviewer 指出 DashScope 的 Qwen3-ASR-Flash 不走 OpenAI Whisper 的 multipart `/audio/transcriptions`,而是 MultiModalConversation 协议 (messages=[{content:[{audio:...}]}])。核实两个独立公开实现 (xinnan-tech/xiaozhi-esp32-server、jianchang512/pyvideotrans)确认 属实——把 qwen 强行塞进 WhisperBatchASR 必然 transcription 失败。 按 PR 描述里"待复核风险 → 不通则降级到 V2"的预案: - 从 ASR_PRESETS 删除 qwen 项 - 从 is_whisper_compatible_provider 拿掉 "qwen" - i18n 删掉 asrQwen 文案 - 注释里点名 Qwen 留给 V2 的原因 剩下 SiliconFlow / GLM-ASR / Groq / OpenAI Whisper 四家都有公开 OpenAI 兼容 /audio/transcriptions 端点,继续走原通道。 --- openless-all/app/src-tauri/src/coordinator.rs | 8 ++++++-- openless-all/app/src/i18n/en.ts | 1 - openless-all/app/src/i18n/zh-CN.ts | 1 - openless-all/app/src/pages/Settings.tsx | 1 - 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index a2e6b4ea..396cccc4 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1559,11 +1559,15 @@ fn ensure_asr_credentials() -> Result<(), String> { } } -/// `whisper` 是 OpenAI 原生;`qwen` / `siliconflow` / `zhipu` / `groq` 都暴露 +/// `whisper` 是 OpenAI 原生;`siliconflow` / `zhipu` / `groq` 都暴露 /// OpenAI 兼容的 `/audio/transcriptions`,统一走 `WhisperBatchASR`。 /// 新增 OpenAI 兼容 ASR 时只需在这里加一项。 +/// +/// 注:DashScope 的 Qwen3-ASR-Flash 不在此列——它用 MultiModalConversation +/// (messages=[{content:[{audio:...}]}]) 协议,不是 Whisper multipart,需要 +/// 单独 ASR 客户端,留给 V2。 fn is_whisper_compatible_provider(id: &str) -> bool { - matches!(id, "whisper" | "qwen" | "siliconflow" | "zhipu" | "groq") + matches!(id, "whisper" | "siliconflow" | "zhipu" | "groq") } /// QA 路径专用:begin_qa_session 永远走 Volcengine 流式(低延迟要求),所以 diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 56240039..7bed8b13 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -282,7 +282,6 @@ export const en: typeof zhCN = { openai: 'OpenAI', custom: 'Custom', asrVolcengine: 'Volcengine bigasr', - asrQwen: 'Qwen3-ASR (DashScope compatible mode)', asrSiliconflow: 'SiliconFlow SenseVoice', asrZhipu: 'Zhipu GLM-ASR', asrGroq: 'Groq Whisper-large-v3', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 892b9bfd..f1eaeff4 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -280,7 +280,6 @@ export const zhCN = { openai: 'OpenAI', custom: '自定义', asrVolcengine: '火山引擎 bigasr', - asrQwen: '通义千问 Qwen3-ASR(DashScope 兼容)', asrSiliconflow: '硅基流动 SenseVoice', asrZhipu: '智谱 GLM-ASR', asrGroq: 'Groq Whisper-large-v3', diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index f5e46d4e..f395071f 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -281,7 +281,6 @@ const ASR_DEFAULT_RESOURCE_ID = 'volc.seedasr.sauc.duration'; // 3. 在 i18n 的 `settings.providers.presets.` 加文案。 const ASR_PRESETS = [ { id: 'volcengine', nameKey: 'asrVolcengine', baseUrl: '', model: '' }, - { id: 'qwen', nameKey: 'asrQwen', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen3-asr-flash' }, { id: 'siliconflow', nameKey: 'asrSiliconflow', baseUrl: 'https://api.siliconflow.cn/v1', model: 'FunAudioLLM/SenseVoiceSmall' }, { id: 'zhipu', nameKey: 'asrZhipu', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-asr-2512' }, { id: 'groq', nameKey: 'asrGroq', baseUrl: 'https://api.groq.com/openai/v1', model: 'whisper-large-v3-turbo' }, From 17bb078f195d1746e7b566cc2de9d36a9a3b7164 Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 4 May 2026 06:58:13 +0800 Subject: [PATCH 09/13] =?UTF-8?q?fix(qa):=20=E5=BD=95=E9=9F=B3=E9=94=AE?= =?UTF-8?q?=E6=94=B9=E4=BA=86=E7=AB=8B=E5=88=BB=E5=88=B7=E6=96=B0=E6=B5=AE?= =?UTF-8?q?=E7=AA=97=E6=96=87=E6=A1=88=EF=BC=8C=E4=B8=8D=E7=94=A8=E7=AD=89?= =?UTF-8?q?=E9=87=8D=E5=BC=80=20(#205=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #206 review 反馈:QaPanel 的 recordHotkeyLabel 只在 mount / 语言切换时 重读 prefs;用户在主窗口改了录音键,浮窗如果还开着就一直停留在旧值 ("按 右 Option 提问" 之类),误导用户按错键。 修法是补一条事件通路,不动现有的初始读取: - commands.rs::set_settings 持久化后 `app.emit("prefs:changed", &prefs)`, 广播给所有 webview。沿用 vocab:updated 同款跨窗口通知模式。 - QaPanel.tsx 在原有 qa:state / qa:dismiss / qa:level 订阅旁边加一条 prefs:changed listener,收到后用 getHotkeyTriggerLabel 重算 label。 cleanup 一并处理。 QaPanel 跑在独立 webview(label="qa"),没有 HotkeySettingsContext 注入, 所以必须走 IPC 事件而不是 React context;这也是为什么不能复用 Settings 页面的本地 state 更新。 --- openless-all/app/src-tauri/src/commands.rs | 15 ++++++++++++--- openless-all/app/src/pages/QaPanel.tsx | 12 +++++++++++- 2 files changed, 23 insertions(+), 4 deletions(-) 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/pages/QaPanel.tsx b/openless-all/app/src/pages/QaPanel.tsx index 5bb43afd..5703ac82 100644 --- a/openless-all/app/src/pages/QaPanel.tsx +++ b/openless-all/app/src/pages/QaPanel.tsx @@ -14,7 +14,7 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import { useTranslation } from 'react-i18next'; import { marked } from 'marked'; import { getSettings, isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc'; -import type { QaChatMessage, QaStatePayload } from '../lib/types'; +import type { QaChatMessage, QaStatePayload, UserPreferences } from '../lib/types'; import { getHotkeyTriggerLabel } from '../lib/hotkey'; const SELECTION_PREVIEW_MAX = 60; @@ -49,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 { @@ -118,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); @@ -136,6 +145,7 @@ export function QaPanel() { unlistenState?.(); unlistenDismiss?.(); unlistenLevel?.(); + unlistenPrefs?.(); }; }, []); From e5608db21e2d950ff7325f0a1fa1fc488a7801cb Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 4 May 2026 07:07:55 +0800 Subject: [PATCH 10/13] =?UTF-8?q?feat(autostart):=20macOS=20/=20Linux=20/?= =?UTF-8?q?=20Windows=20=E4=B8=89=E5=B9=B3=E5=8F=B0=E5=BC=80=E6=9C=BA?= =?UTF-8?q?=E8=87=AA=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接入官方 tauri-plugin-autostart: - macOS:~/Library/LaunchAgents/com.openless.app.plist (LaunchAgent) - Linux:~/.config/autostart/openless.desktop (XDG) - Windows:HKCU\Software\Microsoft\Windows\CurrentVersion\Run (per-user, 不需要 admin) 设置页 → 录制 区追加「开机自启」Toggle;状态以 OS 为唯一真相, 直接调插件 isEnabled / enable / disable,不进 prefs,避免缓存 与系统设置不一致。 Closes #194 --- openless-all/app/package-lock.json | 14 +++- openless-all/app/package.json | 1 + openless-all/app/src-tauri/Cargo.lock | 80 +++++++++++++++++-- openless-all/app/src-tauri/Cargo.toml | 1 + .../app/src-tauri/capabilities/default.json | 3 +- openless-all/app/src-tauri/src/lib.rs | 7 ++ openless-all/app/src/i18n/en.ts | 2 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/pages/Settings.tsx | 48 +++++++++++ 9 files changed, 148 insertions(+), 10 deletions(-) diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 15112f79..a2085ffa 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,14 +1,15 @@ { "name": "openless-app", - "version": "1.2.8", + "version": "1.2.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "1.2.8", + "version": "1.2.13", "dependencies": { "@tauri-apps/api": "^2.1.1", + "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-shell": "^2.0.1", "@tauri-apps/plugin-updater": "^2.10.1", "i18next": "^26.0.8", @@ -1342,6 +1343,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-autostart": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz", + "integrity": "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-shell": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 8bd82c33..0a243db6 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@tauri-apps/api": "^2.1.1", + "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-shell": "^2.0.1", "@tauri-apps/plugin-updater": "^2.10.1", "i18next": "^26.0.8", diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index a40216c3..cf0dc120 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -308,6 +308,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -1090,13 +1101,33 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] @@ -1107,7 +1138,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -3278,6 +3309,7 @@ dependencies = [ "simplelog", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-updater", @@ -4042,6 +4074,17 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -5026,7 +5069,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -5076,7 +5119,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -5148,6 +5191,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-shell" version = "2.3.5" @@ -5191,7 +5248,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 6.0.0", "flate2", "futures-util", "http", @@ -5742,7 +5799,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2 0.6.4", @@ -6927,6 +6984,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.52.0" @@ -7057,7 +7123,7 @@ dependencies = [ "block2 0.6.2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 9df49b38..ef66d79c 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -18,6 +18,7 @@ tauri = { version = "2", features = ["macos-private-api", "tray-icon"] } tauri-plugin-shell = "2" tauri-plugin-updater = "2" tauri-plugin-single-instance = "2" +tauri-plugin-autostart = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } diff --git a/openless-all/app/src-tauri/capabilities/default.json b/openless-all/app/src-tauri/capabilities/default.json index a17c96af..b04dca4a 100644 --- a/openless-all/app/src-tauri/capabilities/default.json +++ b/openless-all/app/src-tauri/capabilities/default.json @@ -16,6 +16,7 @@ "core:webview:default", "core:event:default", "shell:allow-open", - "updater:default" + "updater:default", + "autostart:default" ] } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 539f224d..8323a686 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -56,6 +56,13 @@ pub fn run() { })) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_updater::Builder::new().build()) + // 跨平台开机自启:mac 写 LaunchAgent plist,linux 写 ~/.config/autostart/*.desktop, + // windows 写 HKCU\Software\Microsoft\Windows\CurrentVersion\Run。前端 toggle 直接 + // 调插件 isEnabled / enable / disable,不维持本地 prefs,让 OS 当唯一真相。issue #194。 + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + None, + )) .manage(coordinator.clone()) .setup(move |app| { // Capsule 启动时定位到屏幕底部居中并隐藏;coordinator 按需显示。 diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 157a63e5..8b408236 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -266,6 +266,8 @@ 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.', + startupAtBoot: 'Launch at login', + startupAtBootDesc: 'Start OpenLess automatically when you sign in. macOS uses a LaunchAgent, Linux writes ~/.config/autostart, Windows writes HKCU\\Run (no admin required). See issue #194.', }, providers: { llmTitle: 'LLM (polishing)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index c98358bb..825d56d8 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -264,6 +264,8 @@ export const zhCN = { capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。', restoreClipboardLabel: '插入后恢复剪贴板', restoreClipboardDesc: '仅 Windows / Linux:粘贴成功后恢复你原来的剪贴板内容(默认开)。关掉就把听写文本留在剪贴板,模拟粘贴没真正落地时可以手动 Ctrl+V 找回。详见 issue #111。', + startupAtBoot: '开机自启', + startupAtBootDesc: '登录后自动启动 OpenLess。macOS 写 LaunchAgent,Linux 写 ~/.config/autostart,Windows 写 HKCU\\Run(不需要管理员)。详见 issue #194。', }, providers: { llmTitle: 'LLM 模型(润色)', diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 2e60c839..de370263 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; +import { disable as disableAutostart, enable as enableAutostart, isEnabled as isAutostartEnabled } from '@tauri-apps/plugin-autostart'; import { Icon } from '../components/Icon'; import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoUpdate'; import { APP_VERSION_LABEL } from '../lib/appVersion'; @@ -231,6 +232,7 @@ function RecordingSection() { > + {capability.statusHint && (
{capability.statusHint} @@ -240,6 +242,52 @@ function RecordingSection() { ); } +// 不存进 prefs:autostart 状态由 OS 持有(mac LaunchAgent plist / linux .desktop / +// windows HKCU\Run),prefs 缓存反而会与 OS 真相不一致。issue #194。 +function AutostartRow() { + const { t } = useTranslation(); + const [enabled, setEnabled] = useState(false); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + let cancelled = false; + isAutostartEnabled() + .then(v => { + if (!cancelled) { + setEnabled(v); + setLoaded(true); + } + }) + .catch(err => { + console.error('[autostart] isEnabled failed', err); + if (!cancelled) setLoaded(true); + }); + return () => { + cancelled = true; + }; + }, []); + + const onToggle = async (next: boolean) => { + setEnabled(next); + try { + if (next) await enableAutostart(); + else await disableAutostart(); + } catch (err) { + console.error('[autostart] toggle failed', err); + setEnabled(!next); + } + }; + + return ( + + {loaded ? : null} + + ); +} + function Toggle({ on, onToggle }: { on: boolean; onToggle?: (next: boolean) => void }) { return (