From af8b1a31a7187ec2101597afdb929a64ed2f8575 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 21:09:37 +0800 Subject: [PATCH 1/5] feat(settings): cross-platform parity for local ASR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdvancedSection: warning collapses to inline 11px amber badge in the title row's top-right; the enable-confirm popup is now a centered fixed modal with backdrop blur(8px) and click-outside- to-cancel (busy-guarded). - Symmetric rows by platform — fixes the #400 bug where the Qwen3 row was rendered on Windows even though all qwen_engine / local_provider / cache modules are #[cfg(target_os = "macos")], so Enable on Windows would always error: macOS: Qwen3 row enabled / Foundry row hidden Windows: Foundry row enabled / Qwen3 row disabled with notSupportedHere desc - Providers ASR Card: replaces the plain-text localAsrActiveNotice branch with an always-rendered dropdown that includes the platform-native local engine (macOS=Qwen3, Windows=Foundry) as a disabled option, plus a takeover hint underneath. Entire dropdown locks when a local engine is active, so it can only be released via Advanced (matches "启动之后供应商将被接管" UX). - onLlmProviderChange / onAsrProviderChange now emit saving → saved through emitSaved. Data was already being persisted; the saved-toast was missing. - LocalAsr embedded: hide Qwen3 model management (mirror / engine-status / model list) on Windows — dead UI without the Qwen3 backend. The shared error Card is kept unconditional since Foundry handlers also call setError. - i18n × 5 (zh-CN/zh-TW/en/ja/ko): shorten localAsrWarningShort; rewrite qwen3Desc and foundryDesc to '启动之后,ASR 提供商将被接管'; add notSupportedHere and localAsrTakeoverHint. --- openless-all/app/src/i18n/en.ts | 8 +- openless-all/app/src/i18n/ja.ts | 8 +- openless-all/app/src/i18n/ko.ts | 8 +- openless-all/app/src/i18n/zh-CN.ts | 8 +- openless-all/app/src/i18n/zh-TW.ts | 8 +- openless-all/app/src/pages/LocalAsr.tsx | 7 + openless-all/app/src/pages/Settings.tsx | 223 +++++++++++++++--------- 7 files changed, 173 insertions(+), 97 deletions(-) diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index bf1a4631..96037628 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -384,6 +384,7 @@ export const en: typeof zhCN = { volcengineResourceIdLabel: 'Resource ID', volcengineMappingNote: 'Secret Key is not required right now. Resource ID defaults to volc.bigasr.sauc.duration.', localAsrActiveNotice: 'Local ASR ({{name}}) is currently active. Switch or disable it from the Advanced tab.', + localAsrTakeoverHint: 'Once "{{name}}" is enabled, the ASR provider will be taken over.', localAsrHint: 'Local Qwen3-ASR runs entirely on this machine. No API key needed — just download the model from HuggingFace.', foundryLocalAsrHint: 'Windows local Whisper runs on this device and does not need an ASR API key. First use downloads Foundry Local runtime components and a Whisper model; LLM polishing still uses your configured LLM provider.', localAsrPerformanceWarning: 'Local inference runs on CPU + Apple Silicon Accelerate; each transcription takes **several seconds longer than cloud ASR**, and Chinese / dialect accuracy is **typically lower** than Volcengine or Whisper turbo. Use it for offline, privacy-sensitive, or no-cloud-API scenarios.', @@ -475,9 +476,10 @@ export const en: typeof zhCN = { advanced: { localAsrTitle: 'Local ASR models (experimental)', localAsrDesc: 'Move transcription from cloud ASR to on-device inference. Offline / privacy-sensitive use only.', - localAsrWarningShort: 'Local ASR is several seconds slower than cloud, typically less accurate, and on under-spec hardware can drop words (output only part of your speech).', - qwen3Desc: 'Alibaba Qwen3-ASR, cross-platform. Models download from HuggingFace.', - foundryDesc: 'Microsoft Foundry Local Whisper. Windows only.', + localAsrWarningShort: 'Local inference is slower; under-spec hardware may drop words.', + qwen3Desc: 'Once enabled, the ASR provider will be taken over.', + foundryDesc: 'Once enabled, the ASR provider will be taken over.', + notSupportedHere: 'Not supported on this platform — no inference module bundled.', enable: 'Enable', alreadyActive: 'Active', disableLocalLabel: 'Disable local ASR', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 45c2836a..f8068a67 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -386,6 +386,7 @@ export const ja: typeof zhCN = { volcengineResourceIdLabel: 'Resource ID', volcengineMappingNote: 'Secret Key は現在不要。Resource ID のデフォルトは volc.bigasr.sauc.duration。', localAsrActiveNotice: '現在「{{name}}」を使用中。「詳細設定」タブから切り替えまたは無効化できます。', + localAsrTakeoverHint: '「{{name}}」を有効化すると ASR プロバイダーが引き継がれます。', localAsrHint: 'ローカル Qwen3-ASR は本機で実行されるため API Key 不要。HuggingFace からモデルをダウンロードすればすぐに利用できます。', foundryLocalAsrHint: 'Windows ローカル Whisper は本機で実行され、ASR API Key は不要です。初回使用時に Foundry Local ランタイムコンポーネントと Whisper モデルをダウンロードします。LLM 整文は引き続き設定済みの LLM プロバイダーを使用します。', localAsrPerformanceWarning: 'ローカル推論は CPU + Apple Silicon Accelerate で動作するため、1 回の転写時間は **クラウド ASR より数秒長くなります**。中国語認識精度や方言/訛り対応も **通常は** Volcengine / Whisper turbo に劣ります。ネットワーク制限下またはプライバシー重視の場合に選択してください。', @@ -477,9 +478,10 @@ export const ja: typeof zhCN = { advanced: { localAsrTitle: 'ローカル ASR モデル(実験的)', localAsrDesc: '転写をクラウドから本機推論に切り替えます。オフライン/プライバシー重視向け。', - localAsrWarningShort: 'ローカル ASR はクラウドより数秒遅く、精度も通常は低め。スペック不足のマシンでは欠字(音声の一部のみ出力)が起きる場合があります。', - qwen3Desc: 'Alibaba Qwen3-ASR、クロスプラットフォーム。モデルは HuggingFace からダウンロード。', - foundryDesc: 'Microsoft Foundry Local Whisper、Windows のみ。', + localAsrWarningShort: 'ローカル推論は遅く、スペック不足では欠字の可能性があります。', + qwen3Desc: '有効化すると ASR プロバイダーが引き継がれます。', + foundryDesc: '有効化すると ASR プロバイダーが引き継がれます。', + notSupportedHere: 'このプラットフォームでは未対応(推論モジュール未組込)。', enable: '有効化', alreadyActive: '有効', disableLocalLabel: 'ローカル ASR を無効化', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 1fbf83a5..e5c6da54 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -386,6 +386,7 @@ export const ko: typeof zhCN = { volcengineResourceIdLabel: 'Resource ID', volcengineMappingNote: 'Secret Key 는 현재 입력 불필요. Resource ID 기본값은 volc.bigasr.sauc.duration.', localAsrActiveNotice: '현재 "{{name}}" 사용 중. "고급" 탭에서 전환 또는 비활성화할 수 있습니다.', + localAsrTakeoverHint: '"{{name}}" 활성화 시 ASR 프로바이더가 인수됩니다.', localAsrHint: '로컬 Qwen3-ASR 은 본 기기에서 실행되며 API Key 가 필요 없습니다. HuggingFace 에서 모델을 로컬로 다운로드하면 즉시 사용 가능합니다.', foundryLocalAsrHint: 'Windows 로컬 Whisper 는 이 기기에서 실행되며 ASR API Key 가 필요 없습니다. 첫 사용 시 Foundry Local 런타임 구성 요소와 Whisper 모델을 다운로드합니다. LLM 정리는 계속 설정된 LLM 공급자를 사용합니다.', localAsrPerformanceWarning: '로컬 추론은 CPU + Apple Silicon Accelerate 에서 동작하므로, 한 번의 전사 시간이 **클라우드 ASR 보다 몇 초 더 걸립니다**. 중국어 인식 정확도와 방언/억양 대응도 **일반적으로** Volcengine / Whisper turbo 에 미치지 못합니다. 네트워크 제한 또는 프라이버시가 중요한 경우에 사용하세요.', @@ -477,9 +478,10 @@ export const ko: typeof zhCN = { advanced: { localAsrTitle: '로컬 ASR 모델 (실험적)', localAsrDesc: '전사를 클라우드에서 로컬 추론으로 전환합니다. 오프라인 / 프라이버시용에만 권장됩니다.', - localAsrWarningShort: '로컬 ASR 은 클라우드보다 몇 초 느리고 정확도도 일반적으로 낮습니다. 사양이 부족한 기기에서는 글자 누락(음성의 일부만 출력)이 발생할 수 있습니다.', - qwen3Desc: 'Alibaba Qwen3-ASR, 크로스 플랫폼. 모델은 HuggingFace 에서 다운로드.', - foundryDesc: 'Microsoft Foundry Local Whisper, Windows 전용.', + localAsrWarningShort: '로컬 추론은 느리며, 사양 부족 시 글자 누락이 발생할 수 있습니다.', + qwen3Desc: '활성화하면 ASR 프로바이더가 인수됩니다.', + foundryDesc: '활성화하면 ASR 프로바이더가 인수됩니다.', + notSupportedHere: '이 플랫폼에서는 미지원 (추론 모듈 미내장).', enable: '활성화', alreadyActive: '활성', disableLocalLabel: '로컬 ASR 비활성화', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index d9da9665..0079272c 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -382,6 +382,7 @@ export const zhCN = { volcengineResourceIdLabel: 'Resource ID', volcengineMappingNote: 'Secret Key 当前无需填写。Resource ID 默认使用 volc.bigasr.sauc.duration。', localAsrActiveNotice: '当前已启用「{{name}}」,可在「高级」中切换或禁用。', + localAsrTakeoverHint: '启动「{{name}}」后,ASR 提供商将被接管。', localAsrHint: '本地 Qwen3-ASR 在本机运行,无需 API Key。模型从 HuggingFace 下载到本地后即可使用。', foundryLocalAsrHint: 'Windows 本地 Whisper 在本机运行,无需 ASR API Key。首次使用会下载 Foundry Local 运行组件和 Whisper 模型;LLM 润色仍按你配置的 LLM 提供商调用。', localAsrPerformanceWarning: '本地推理跑在 CPU + Apple Silicon Accelerate 上,单次转写时间会**比云端 ASR 长几秒**;中文识别准确率与方言/口音表现也**通常不如**火山引擎 / Whisper turbo。请按需取舍:网络受限或对隐私敏感时再用本地。', @@ -473,9 +474,10 @@ export const zhCN = { advanced: { localAsrTitle: '本地 ASR 模型(实验性)', localAsrDesc: '把转写从云端切到本机推理。仅推荐离线 / 隐私敏感场景。', - localAsrWarningShort: '本地 ASR 比云端慢若干秒、准确率更低;配置不足时可能吞字(仅输出部分语音)。', - qwen3Desc: 'Alibaba Qwen3-ASR,跨平台。模型从 HuggingFace 下载。', - foundryDesc: 'Microsoft Foundry Local Whisper,仅 Windows。', + localAsrWarningShort: '本地推理较慢,配置不足时可能吞字。', + qwen3Desc: '启动之后,ASR 提供商将被接管。', + foundryDesc: '启动之后,ASR 提供商将被接管。', + notSupportedHere: '本平台暂不支持,未集成推理模块。', enable: '启用', alreadyActive: '已启用', disableLocalLabel: '禁用本地 ASR', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index d9417145..30327793 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -384,6 +384,7 @@ export const zhTW: typeof zhCN = { volcengineResourceIdLabel: 'Resource ID', volcengineMappingNote: 'Secret Key 當前無需填寫。Resource ID 默認使用 volc.bigasr.sauc.duration。', localAsrActiveNotice: '當前已啓用「{{name}}」,可在「高級」中切換或停用。', + localAsrTakeoverHint: '啓動「{{name}}」後,ASR 提供商將被接管。', localAsrHint: '本地 Qwen3-ASR 在本機運行,無需 API Key。模型從 HuggingFace 下載到本地後即可使用。', foundryLocalAsrHint: 'Windows 本地 Whisper 在本機運行,無需 ASR API Key。首次使用會下載 Foundry Local 運行組件和 Whisper 模型;LLM 潤色仍按你配置的 LLM 提供商調用。', localAsrPerformanceWarning: '本地推理跑在 CPU + Apple Silicon Accelerate 上,**首次轉寫需要加載模型(數秒)**,之後單次轉寫也會比雲端 ASR 慢若干秒;中文識別準確率與方言/口音表現通常不如火山引擎 / Whisper turbo。適用場景:離線 / 隱私敏感 / 不願付費雲 API。', @@ -475,9 +476,10 @@ export const zhTW: typeof zhCN = { advanced: { localAsrTitle: '本地 ASR 模型(實驗性)', localAsrDesc: '把轉寫從雲端切到本機推理。僅推薦離線 / 隱私敏感場景。', - localAsrWarningShort: '本地 ASR 比雲端慢若干秒、準確率更低;配置不足時可能吞字(僅輸出部分語音)。', - qwen3Desc: 'Alibaba Qwen3-ASR,跨平臺。模型從 HuggingFace 下載。', - foundryDesc: 'Microsoft Foundry Local Whisper,僅 Windows。', + localAsrWarningShort: '本地推理較慢,配置不足時可能吞字。', + qwen3Desc: '啓動之後,ASR 提供商將被接管。', + foundryDesc: '啓動之後,ASR 提供商將被接管。', + notSupportedHere: '本平臺暫不支持,未集成推理模塊。', enable: '啓用', alreadyActive: '已啓用', disableLocalLabel: '停用本地 ASR', diff --git a/openless-all/app/src/pages/LocalAsr.tsx b/openless-all/app/src/pages/LocalAsr.tsx index 135a94a4..601f8ea7 100644 --- a/openless-all/app/src/pages/LocalAsr.tsx +++ b/openless-all/app/src/pages/LocalAsr.tsx @@ -772,6 +772,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { )} + {/* Qwen3 模型管理区——Windows 隐藏(Qwen3 后端 macOS-only,Windows 看见镜像源/ + 下载/模型列表都是 dead UI)。Foundry 块自身已经被上方 IS_WINDOWS 守卫, + 错误 Card(共享 setError,被 Foundry handler 也写)保持露出。 */} + {!IS_WINDOWS && (<> {!engineAvailable && (
@@ -870,6 +874,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
)} + )} {error && ( @@ -877,6 +882,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { )} + {!IS_WINDOWS && (
{models.map(model => ( ))}
+ )} ); } diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 89124acb..3c612a23 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -1288,6 +1288,7 @@ function ProvidersSection() { const onLlmProviderChange = async (id: LlmPresetId) => { setLlmProvider(id); const seq = ++llmSwitchSeqRef.current; + emitSaved('saving', t('common.saving')); await setActiveLlmProvider(id); if (seq !== llmSwitchSeqRef.current) return; if (prefs) { @@ -1312,11 +1313,13 @@ function ProvidersSection() { } } setCommittedLlmProvider(id); + emitSaved('saved', t('common.saved')); }; const onAsrProviderChange = async (id: AsrPresetId) => { setAsrProvider(id); const seq = ++asrSwitchSeqRef.current; + emitSaved('saving', t('common.saving')); await setActiveAsrProvider(id); if (seq !== asrSwitchSeqRef.current) return; if (prefs) { @@ -1345,6 +1348,7 @@ function ProvidersSection() { } } setCommittedAsrProvider(id); + emitSaved('saved', t('common.saved')); }; // preset 决定 placeholder 与 default —— 必须跟着 committed*Provider 走, @@ -1390,26 +1394,53 @@ function ProvidersSection() {
{t('settings.providers.asrDesc')}
- {committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper' ? ( - // active 是本地 ASR 时,下拉里没有它的 - ))} - - )} + {(() => { + // 平台本机引擎:macOS=Qwen3,Windows=Foundry。该项始终在下拉里露出, + // 当用户未启用本地推理时呈 disabled——用户能看到"它存在 + 提示", + // 但只能在 Advanced 启用;启用后整下拉锁住,本机项被选中(user req 2.a)。 + const platformLocalAsr: AsrPresetId | null = + os === 'mac' ? 'local-qwen3' : os === 'win' ? 'foundry-local-whisper' : null; + const platformLocalNameKey = platformLocalAsr === 'local-qwen3' + ? 'asrLocalQwen3' + : platformLocalAsr === 'foundry-local-whisper' + ? 'asrFoundryLocalWhisper' + : null; + const isLocked = committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper'; + // active 是本地时,下拉显示本机 local 项被选中;非 active 时仍按 + // asrProvider(云端选项)显示。selectedValue 必须在 + ))} + {platformLocalAsr && platformLocalNameKey && ( + + )} + + {platformLocalAsr && platformLocalNameKey && ( +
+ {t('settings.providers.localAsrTakeoverHint', { + name: t(`settings.providers.presets.${platformLocalNameKey}`), + })} +
+ )} + + ); + })()}
{committedAsrProvider === 'volcengine' ? ( <> @@ -1477,24 +1508,25 @@ function ProvidersSection() { /// 设计: /// - 主 Providers 下拉只列云端选项;本地推理 (local-qwen3 / foundry-local-whisper) /// 完全收纳到此栏,新手不会在主流程里误开 CPU 推理。 -/// - 启用本地推理时弹出**页面顶部**的 React 浮层 popup(不再用 Tauri 系统对话框, -/// 也不再常驻一段长警告文本)——一个框、一段短话、确认/取消。 +/// - 启用本地推理时弹出**屏幕中央**的 modal(背景模糊)——一个框、一段短话、确认/取消。 /// - 旧的「模型设置」独立页 (LocalAsr.tsx 作为 nav tab) 已下线,模型管理 UI /// 通过 内嵌在此处统一管理。 /// -/// 平台可见性: -/// - macOS: 仅 Qwen3-ASR -/// - Windows: Qwen3-ASR + Foundry Local Whisper -/// - Linux: 「该平台暂未支持本地 ASR 模型集成」 +/// 平台对称(每端只露本机有后端的引擎,另一端的引擎以 disabled 行 + "本平台暂不支持"提示露出): +/// - macOS: Qwen3-ASR 主行可启用;Foundry **不显示**(macOS 不展示 Windows 端模型内容)。 +/// - Windows: Foundry 主行可启用;Qwen3 行 disabled,desc 为 notSupportedHere。 +/// - Linux: 「该平台暂未支持本地 ASR 模型集成」整段兜底。 function AdvancedSection() { const { t } = useTranslation(); const { prefs, updatePrefs } = useHotkeySettings(); const os = detectOS(); - const platformSupported = os === 'mac' || os === 'win'; + const isMac = os === 'mac'; + const isWin = os === 'win'; + const platformSupported = isMac || isWin; const switchSeqRef = useRef(0); const [busy, setBusy] = useState(false); - // 待确认的启用目标。!== null 时浮层 popup 显示在页面顶部;用户点确认 → 真切; - // 点取消 → 回到 null。一次只允许一个 popup(用户的"只一个框"硬要求)。 + // 待确认的启用目标。!== null 时中央 modal 弹出 + 背景模糊;用户点确认 → 真切; + // 点取消 → 回到 null。一次只允许一个 modal。 const [pendingTarget, setPendingTarget] = useState(null); const activeAsrProvider = (prefs?.activeAsrProvider ?? 'volcengine') as AsrPresetId; @@ -1530,60 +1562,79 @@ function AdvancedSection() { return ( <> - {/* ─── 页面顶部确认浮层 ──────────────────────────────────────────── - 用户硬要求:启用本地推理时**只**弹一个框、直接出现在页面最上方。 - 琥珀边框 + 琥珀字醒目;不阻塞滚动;点取消返回不切。 */} + {/* ─── 屏幕中央确认 modal(背景模糊) ───────────────────────────── + 点击遮罩或取消按钮关闭;切换中(busy)禁止任何关闭路径以免半切失败。 */} {pendingTarget && pendingNameKey && ( - { + if (e.target === e.currentTarget && !busy) setPendingTarget(null); }}> -
- ⚠️ {t('settings.advanced.confirmEnableLocalTitle')} -
-
- {t('settings.advanced.confirmEnableLocalBody', { - target: t(`settings.providers.presets.${pendingNameKey}`), - })} -
-
- void performSwitch(pendingTarget)}> - {t('settings.advanced.confirm')} - - setPendingTarget(null)}> - {t('common.cancel')} - -
-
+ +
+ ⚠️ {t('settings.advanced.confirmEnableLocalTitle')} +
+
+ {t('settings.advanced.confirmEnableLocalBody', { + target: t(`settings.providers.presets.${pendingNameKey}`), + })} +
+
+ setPendingTarget(null)}> + {t('common.cancel')} + + void performSwitch(pendingTarget)}> + {t('settings.advanced.confirm')} + +
+
+ )} -
-
{t('settings.advanced.localAsrTitle')}
-
- {t('settings.advanced.localAsrDesc')} + {/* 标题 + 右上角 inline 警告小字(替换原琥珀大警告条)。 */} +
+
+
{t('settings.advanced.localAsrTitle')}
+
+ {t('settings.advanced.localAsrDesc')} +
-
- - {/* 常驻短警告——一行字够了,详细解释留给点 Enable 后的页面顶部 popup。 */} -
- ⚠️ {t('settings.advanced.localAsrWarningShort')} + ⚠️ {t('settings.advanced.localAsrWarningShort')} +
{!platformSupported ? ( @@ -1592,19 +1643,27 @@ function AdvancedSection() {
) : ( <> + {/* Qwen3 行 —— macOS 可启用;Windows 后端是 stub,整行 disabled + notSupportedHere desc。 */} - requestEnable('local-qwen3')}> - {isOnLocalQwen3 ? t('settings.advanced.alreadyActive') : t('settings.advanced.enable')} - + desc={isMac ? t('settings.advanced.qwen3Desc') : t('settings.advanced.notSupportedHere')}> + {isMac ? ( + requestEnable('local-qwen3')}> + {isOnLocalQwen3 ? t('settings.advanced.alreadyActive') : t('settings.advanced.enable')} + + ) : ( + + {t('settings.advanced.enable')} + + )} - {os === 'win' && ( + {/* Foundry 行 —— 仅 Windows 露出(macOS 不展示 Windows 端模型内容)。 */} + {isWin && ( From 7a65ceeb839914afff323964f67b0eb83ba0214b Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 21:37:56 +0800 Subject: [PATCH 2/5] fix(settings): clear saving toast on provider-switch failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr_agent #403 caught: emitSaved('saving') with no try/catch — if setActiveLlmProvider / setActiveAsrProvider / any later credential write throws, the right-corner toast stays stuck on saving forever. Wrap both onLlmProviderChange and onAsrProviderChange bodies in try/catch. On error, emit 'failed' (and re-throw) only if seq still matches — stale calls superseded by a newer switch shouldn't overwrite the newer call's own saving state. --- openless-all/app/src/pages/Settings.tsx | 105 ++++++++++++++---------- 1 file changed, 62 insertions(+), 43 deletions(-) diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 3c612a23..267242f1 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -1289,66 +1289,85 @@ function ProvidersSection() { setLlmProvider(id); const seq = ++llmSwitchSeqRef.current; emitSaved('saving', t('common.saving')); - await setActiveLlmProvider(id); - if (seq !== llmSwitchSeqRef.current) return; - if (prefs) { - const next = { ...prefs, activeLlmProvider: id }; - await updatePrefs(next); + try { + await setActiveLlmProvider(id); if (seq !== llmSwitchSeqRef.current) return; - } - const preset = LLM_PRESETS.find(p => p.id === id); - // 修 bug:所有 LLM provider 共用 `ark.endpoint` / `ark.model_id` 一对凭据槽 - // (persistence.rs 没做 per-provider 隔离)。旧逻辑只在槽空时填默认值, - // 老用户切换 preset 时槽里早有旧值——dropdown 看着切了,polish 实际还是 - // 打老 endpoint。改成:切到任何非 custom 预设都强制覆盖 endpoint 与 model - // 到该预设的默认值,让"切换"真切到位。custom 预设没有默认值,跳过。 - if (preset && preset.id !== 'custom') { - if (preset.baseUrl) { - await setCredential('ark.endpoint', preset.baseUrl); + if (prefs) { + const next = { ...prefs, activeLlmProvider: id }; + await updatePrefs(next); if (seq !== llmSwitchSeqRef.current) return; } - if (preset.modelPlaceholder) { - await setCredential('ark.model_id', preset.modelPlaceholder); - if (seq !== llmSwitchSeqRef.current) return; + const preset = LLM_PRESETS.find(p => p.id === id); + // 修 bug:所有 LLM provider 共用 `ark.endpoint` / `ark.model_id` 一对凭据槽 + // (persistence.rs 没做 per-provider 隔离)。旧逻辑只在槽空时填默认值, + // 老用户切换 preset 时槽里早有旧值——dropdown 看着切了,polish 实际还是 + // 打老 endpoint。改成:切到任何非 custom 预设都强制覆盖 endpoint 与 model + // 到该预设的默认值,让"切换"真切到位。custom 预设没有默认值,跳过。 + if (preset && preset.id !== 'custom') { + if (preset.baseUrl) { + await setCredential('ark.endpoint', preset.baseUrl); + if (seq !== llmSwitchSeqRef.current) return; + } + if (preset.modelPlaceholder) { + await setCredential('ark.model_id', preset.modelPlaceholder); + if (seq !== llmSwitchSeqRef.current) return; + } } + setCommittedLlmProvider(id); + emitSaved('saved', t('common.saved')); + } catch (err) { + // seq 守卫:只有当前 call 还是最新时才把 saving 翻成 failed; + // 旧 call 早被 newer call 的 emitSaved('saving') 覆盖,再叠 failed 会 + // 把 newer 正在跑的 saving 假伪成失败。 + if (seq === llmSwitchSeqRef.current) { + emitSaved('failed', t('common.operationFailed')); + } + throw err; } - setCommittedLlmProvider(id); - emitSaved('saved', t('common.saved')); }; const onAsrProviderChange = async (id: AsrPresetId) => { setAsrProvider(id); const seq = ++asrSwitchSeqRef.current; emitSaved('saving', t('common.saving')); - await setActiveAsrProvider(id); - if (seq !== asrSwitchSeqRef.current) return; - if (prefs) { - const next = { ...prefs, activeAsrProvider: id }; - await updatePrefs(next); - if (seq !== asrSwitchSeqRef.current) return; - } - // OpenAI 兼容厂商首次切换时预填 baseUrl / model 默认值,省得用户必踩 - // 「跨厂商 model 名根本不一样」的坑;但用户已自定义后就不再覆盖。 - // volcengine 走另一套凭据,跳过。 - const preset = ASR_PRESETS.find(p => p.id === id); - if (preset && preset.baseUrl) { - const existing = await readCredential('asr.endpoint'); + try { + await setActiveAsrProvider(id); if (seq !== asrSwitchSeqRef.current) return; - if (!existing) { - await setCredential('asr.endpoint', preset.baseUrl); + if (prefs) { + const next = { ...prefs, activeAsrProvider: id }; + await updatePrefs(next); if (seq !== asrSwitchSeqRef.current) return; } - } - if (preset && preset.model) { - const existing = await readCredential('asr.model'); - if (seq !== asrSwitchSeqRef.current) return; - if (!existing) { - await setCredential('asr.model', preset.model); + // OpenAI 兼容厂商首次切换时预填 baseUrl / model 默认值,省得用户必踩 + // 「跨厂商 model 名根本不一样」的坑;但用户已自定义后就不再覆盖。 + // volcengine 走另一套凭据,跳过。 + const preset = ASR_PRESETS.find(p => p.id === id); + if (preset && preset.baseUrl) { + const existing = await readCredential('asr.endpoint'); if (seq !== asrSwitchSeqRef.current) return; + if (!existing) { + await setCredential('asr.endpoint', preset.baseUrl); + if (seq !== asrSwitchSeqRef.current) return; + } + } + if (preset && preset.model) { + const existing = await readCredential('asr.model'); + if (seq !== asrSwitchSeqRef.current) return; + if (!existing) { + await setCredential('asr.model', preset.model); + if (seq !== asrSwitchSeqRef.current) return; + } + } + setCommittedAsrProvider(id); + emitSaved('saved', t('common.saved')); + } catch (err) { + // seq 守卫同上 onLlmProviderChange:旧 call 不要把 newer call 的 saving + // 伪造成 failed。 + if (seq === asrSwitchSeqRef.current) { + emitSaved('failed', t('common.operationFailed')); } + throw err; } - setCommittedAsrProvider(id); - emitSaved('saved', t('common.saved')); }; // preset 决定 placeholder 与 default —— 必须跟着 committed*Provider 走, From 678a5fdd2f704d8cfe7ba02d7115600de843b241 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 23:10:01 +0800 Subject: [PATCH 3/5] fix(settings): provider-card visual cleanup + log-export permission + pr_agent #403 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes piled up in user feedback during local verify of the previous commit; bundled into one commit to keep #403 small. - capabilities/default.json: add `dialog:default` permission. The About → Export error log button always failed because the Tauri 2 capabilities did not allow `tauri-plugin-dialog`'s `save()` command; the JS rejected before export_error_log was ever invoked. - ProvidersSection ASR Card (pr_agent #403 'Wrong active label'): when committedAsrProvider is a local engine, selectedValue now reflects the actually-active engine instead of being forced to platformLocalAsr. Cross-machine config sync (macOS profile with foundry-local-whisper / Windows profile with local-qwen3) used to make the dropdown lie about which backend is running. Anomalous local engines are added as disabled