feat(settings): 本地 ASR 模型管理重构 + 提供商界面颜色/动画统一 (#922)

* feat(settings): 本地 ASR 模型管理重构——两栏看板 + 下载弹框 + 右上角进度 + 提供商本地模型标注

- 模型管理改为两栏看板:左侧模型选择(竖排,已下载绿勾,全平台 Qwen3 /
  sherpa-onnx / foundry 归一化),右侧详情看板(HF 实时抓取的尺寸/文件数/
  repo + 状态 + 操作按钮),嵌套在设置里作为单独板块
- 下载弹框:左侧模型选择 + 右侧大小/文件数详情,底部「开始下载」,布局与
  看板一致(用户原话:单独的下载界面)
- 右上角下载进度浮层:Qwen3 与 sherpa 下载进度聚合 fixed 显示,完成/取消/
  失败即消失
- ASR 提供商界面:本地引擎激活时直接展示本地已下载模型(标注「本地下载
  (本地下载)」),可在此选用;Apple 语音显示零模型选择说明
- i18n:新增 19 个 key(5 语言)

* style(settings): 输入框/下拉底色统一 + 提供商下拉去 inputStyle + 检查更新按钮动画

- inputStyle 底色 surface-2 → select-trigger-bg:所有输入框(提供商凭据、
  数据存储数字框等)与 SelectLite 下拉(麦克风/胶囊样式)颜色统一
- ProvidersSection 5 处 SelectLite 去掉 inputStyle,改 SelectLite 默认触发器
  样式;LLM/ASR 供应商下拉固定 200 宽与麦克风对齐(用户反馈颜色与大小问题)
- CheckUpdateButton:状态图标颜色过渡 + 文案切换淡入动画(ol-select-value-in)

* fix(settings): 弹框 portal 到 body 修复定位/变灰 bug + 本地 ASR 逻辑重构

- DownloadDialog 全页式重设计:createPortal 到 document.body(WindowChrome
  的常驻 transform/will-change 会创建 containing block,导致 fixed 弹框相对
  设置面板定位、被裁剪且点不到),尺寸与设置弹窗一致、顶部锚定、左侧模型
  选择 + 右上角 ✕ 关闭;GlobalDownloadProgress / UpdateDialog 同步 portal,
  修复「弹框弹出时设置内容变灰、断层感」的同类 bug
- 本地 ASR 逻辑:删除 Qwen3/Foundry/Sherpa 启用开关与确认 modal——本地引擎
  始终可用,激活 = 在 ASR 语音转写选择本地模型供应商(保留「实验性」标记);
  供应商下拉直接列出本地引擎(标注「本地」)
- 删除「使用 Apple 语音」卡片:选中 apple-speech 供应商即等于使用
- 镜像源 / 模型存储位置 / 内存引擎收纳进「下载与存储设置」Collapsible(默认收起)
- 模型操作按钮:去掉「设为默认」,保留「加载并测试」(加载即激活为当前模型),
  打开目录 + 删除同一行;修复本地模型选项标签「本地下载(本地下载)」文案 bug
- ASR 板块供应商切换时 keyed ol-tab-fade 动画

* fix(asr): sherpa/foundry 分派显式传 alias + 本地模型下拉受控 value

- dispatchEntryAction 的 setTimeout 闭包读的是当前 render 的 selected*Alias,
  点非当前选中的模型会操作到上一个模型;四个 handler 改为接受 alias 参数并显式传递
- ProvidersSection 本地模型下拉 value 固定为第一个已下载模型,选择不生效;
  新增 selectedLocalModelId 受控状态,随后端 active 模型与已下载列表联动

* fix(asr): 删除当前选中模型后回落选择 + Foundry 下载改顺序执行

- useLayoutEffect 只在 selectedModelId 仍存在于条目列表时提前返回,删除选中
  模型后自动落到第一个已下载模型(pr-agent #922)
- Foundry 侧栏「下载」由并行 enable+prepare 改为 handleEnableAndPrepareFoundry
  顺序执行:消除 foundryBusy 竞态与 syncFoundryPrefs(true/false) 冲突

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
TRIPandsim authored and GitHub committed 2026-08-05 17:46:13 +08:00
1 parent c0e2213840
commit a580a8f32a
12 files changed
+1699 -767

No files matched your search

@@ -6,6 +6,7 @@
// Android:download/install 走 appDownloadAndInstallAndroidUpdate(minisign + 系统安装器)。 // Android:download/install 走 appDownloadAndInstallAndroidUpdate(minisign + 系统安装器)。
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { DownloadEvent } from '@tauri-apps/plugin-updater'; import type { DownloadEvent } from '@tauri-apps/plugin-updater';
import { Update } from '@tauri-apps/plugin-updater'; import { Update } from '@tauri-apps/plugin-updater';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
@@ -301,11 +302,12 @@ export function UpdateDialog({
const installing = status === 'installing'; const installing = status === 'installing';
const installError = status === 'installError'; const installError = status === 'installError';
const androidInstalled = isAndroid() && status === 'downloaded'; const androidInstalled = isAndroid() && status === 'downloaded';
return ( // Portal 到 document.body:WindowChrome / 设置弹窗带常驻 transform + will-change,
// 遮罩刻意极淡(0.05):用户反馈 0.18 会把设置页白底卡片盖成灰色 // 会创建 containing block——`position: fixed` 的遮罩会相对设置面板定位,只压暗
//(「通用下面的内容变灰、存在断层感」)——侧边栏/顶部渐变是深色看不出, // 白色内容区(侧边栏深色看不出,形成「内容变灰、断层感」,见 Modal.tsx 同款注释)。
// 白色选项行最明显。保留极淡遮罩维持模态语义 + 点击外部关闭。 // portal 出去后遮罩铺满整窗,灰度均匀。0.05 极淡遮罩因此可以恢复正常遮罩透明度。
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.05)', display: 'grid', placeItems: 'center', zIndex: 40 }}> return createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.22)', display: 'grid', placeItems: 'center', zIndex: 40, animation: 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)' }}>
<div style={{ width: 360, borderRadius: 16, background: 'var(--ol-surface)', border: '0.5px solid var(--ol-line-strong)', boxShadow: 'var(--ol-shadow-lg)', padding: 18 }}> <div style={{ width: 360, borderRadius: 16, background: 'var(--ol-surface)', border: '0.5px solid var(--ol-line-strong)', boxShadow: 'var(--ol-shadow-lg)', padding: 18 }}>
<div style={{ fontSize: 15, fontWeight: 650, marginBottom: 8 }}>{t(`settings.about.updateDialog.${status}.title`)}</div> <div style={{ fontSize: 15, fontWeight: 650, marginBottom: 8 }}>{t(`settings.about.updateDialog.${status}.title`)}</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.6, marginBottom: 14, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}> <div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.6, marginBottom: 14, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
@@ -339,7 +341,8 @@ export function UpdateDialog({
{installError && <Btn variant="blue" size="sm" onClick={() => void openExternal(RELEASE_DOWNLOAD_URL)}>{t('settings.about.updateDialog.manualDownload')}</Btn>} {installError && <Btn variant="blue" size="sm" onClick={() => void openExternal(RELEASE_DOWNLOAD_URL)}>{t('settings.about.updateDialog.manualDownload')}</Btn>}
</div> </div>
</div> </div>
</div> </div>,
document.body,
); );
} }
+26 -3
View File
@@ -833,6 +833,10 @@ export const en: typeof zhCN = {
startupAtBootError: 'Failed to toggle launch at login: {{message}}', startupAtBootError: 'Failed to toggle launch at login: {{message}}',
}, },
providers: { providers: {
localModelLabel: 'Local model',
localModelEmpty: 'No local model downloaded yet',
appleSpeechLocalNote: 'Apple Speech uses the system built-in engine — no model selection needed.',
localTag: 'Local',
llmTitle: 'LLM (polishing)', llmTitle: 'LLM (polishing)',
llmDesc: 'OpenAI-compatible protocol. Multiple vendors supported.', llmDesc: 'OpenAI-compatible protocol. Multiple vendors supported.',
providerLabel: 'Provider', providerLabel: 'Provider',
@@ -901,7 +905,7 @@ export const en: typeof zhCN = {
xfyunNote: 'Get AppID and API Key from the iFlytek Open Platform "Realtime ASR" service page. Audio is 16 kHz / 16-bit / mono PCM; the standard API has no hotword parameter (configure personalized hotwords in the iFlytek console), and the language defaults to Mandarin Chinese.', xfyunNote: 'Get AppID and API Key from the iFlytek Open Platform "Realtime ASR" service page. Audio is 16 kHz / 16-bit / mono PCM; the standard API has no hotword parameter (configure personalized hotwords in the iFlytek console), and the language defaults to Mandarin Chinese.',
localAsrActiveNotice: 'Local ASR ({{name}}) is currently active. Switch or disable it from the Advanced tab.', 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.', localAsrTakeoverHint: 'Once "{{name}}" is enabled, the ASR provider will be taken over.',
asrProviderTakenOver: 'A local engine is active. Pick another provider in the dropdown above to switch (the local engine stops automatically); manage local models under Advanced → Local models.', asrProviderTakenOver: 'A local engine is active. Pick another provider in the dropdown above to switch (the local engine stops automatically); manage local models under Services → Local models.',
localAsrHint: 'Runs on this machine, no API key needed. Download the model from HuggingFace.', localAsrHint: 'Runs on this machine, no API key needed. Download the model from HuggingFace.',
foundryLocalAsrHint: 'Runs on this device, no ASR API key needed. First use downloads runtime components and model.', foundryLocalAsrHint: 'Runs on this device, no ASR API key needed. First use downloads runtime components and model.',
localAsrPerformanceWarning: 'Local inference is slower than cloud ASR with potentially lower Chinese accuracy. Best for offline or privacy-sensitive use.', localAsrPerformanceWarning: 'Local inference is slower than cloud ASR with potentially lower Chinese accuracy. Best for offline or privacy-sensitive use.',
@@ -1397,8 +1401,8 @@ export const en: typeof zhCN = {
sherpaModelWhisperLargeV3Desc: 'The best open-source multilingual Whisper tier — high quality, large download.', sherpaModelWhisperLargeV3Desc: 'The best open-source multilingual Whisper tier — high quality, large download.',
sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8',
sherpaModelQwen3Desc: 'Converted sherpa-onnx Qwen3-ASR model with multilingual recognition and stronger long-form context handling.', sherpaModelQwen3Desc: 'Converted sherpa-onnx Qwen3-ASR model with multilingual recognition and stronger long-form context handling.',
modelSelectTitle: 'Model selection', modelSelectTitle: 'Model download & manage',
modelSelectDesc: 'Pick one of your downloaded models as the active recognizer; get more in “Download & manage” below.', modelSelectDesc: 'Downloaded models automatically appear under the local provider in ASR Transcription above — pick one to use it. Click "+ Download new model" to get more.',
modelSelectPlaceholder: 'Select a downloaded model…', modelSelectPlaceholder: 'Select a downloaded model…',
modelSelectEmpty: 'No downloaded models yet — grab one under “Download & manage”.', modelSelectEmpty: 'No downloaded models yet — grab one under “Download & manage”.',
groupDownload: 'Download & manage', groupDownload: 'Download & manage',
@@ -1440,5 +1444,24 @@ export const en: typeof zhCN = {
keep5min: '5 minutes after last use (default)', keep5min: '5 minutes after last use (default)',
keep30min: '30 minutes after last use', keep30min: '30 minutes after last use',
keepForever: 'Never release (always loaded)', keepForever: 'Never release (always loaded)',
sidebarTitle: 'Models',
activePill: 'Active',
setDefault: 'Set as default',
downloading: 'Downloading',
startDownload: 'Start download',
downloadNewModel: 'Download new model',
downloadDialogTitle: 'Select a local ASR model',
downloadDialogAlreadyHave: 'Already downloaded — ready to use',
downloadDialogDesc: 'Pick a model on the left, then click "Start download". Once downloaded, select the local model provider in ASR Transcription (Services → AI Providers) to use it.',
detailRepo: 'Repository',
detailFiles: 'files',
detailDownloaded: 'Downloaded',
detailEmpty: 'Select a model on the left to see details',
foundryLanguage: 'Language',
foundryRuntimeSource: 'Runtime source',
mirrorGithubRelease: 'GitHub Releases',
keep: 'Keep loaded',
downloadSettingsTitle: 'Download & storage',
downloadSettingsDesc: 'Mirror source · model storage location · in-memory engine',
}, },
}; };
+26 -3
View File
@@ -835,6 +835,10 @@ export const ja: typeof zhCN = {
startupAtBootError: '自動起動の切り替えに失敗:{{message}}', startupAtBootError: '自動起動の切り替えに失敗:{{message}}',
}, },
providers: { providers: {
localModelLabel: 'ローカルモデル',
localModelEmpty: 'ローカルモデル未ダウンロード',
appleSpeechLocalNote: 'Apple 音声認識はシステム内蔵エンジンを使用するため、モデル選択は不要です。',
localTag: 'ローカル',
llmTitle: 'LLM モデル(整文)', llmTitle: 'LLM モデル(整文)',
llmDesc: 'OpenAI 互換プロトコル、複数のサプライヤー切り替えに対応。', llmDesc: 'OpenAI 互換プロトコル、複数のサプライヤー切り替えに対応。',
providerLabel: 'サプライヤー', providerLabel: 'サプライヤー',
@@ -903,7 +907,7 @@ export const ja: typeof zhCN = {
xfyunNote: 'iFlytek オープンプラットフォームの「リアルタイム音声認識」サービスページで AppID と API Key を取得します。音声は 16kHz / 16bit / モノラル PCM。標準版 API にホットワード引数はありません(iFlytek コンソールで個別ホットワードを設定)。言語はデフォルトで中国語(普通話)です。', xfyunNote: 'iFlytek オープンプラットフォームの「リアルタイム音声認識」サービスページで AppID と API Key を取得します。音声は 16kHz / 16bit / モノラル PCM。標準版 API にホットワード引数はありません(iFlytek コンソールで個別ホットワードを設定)。言語はデフォルトで中国語(普通話)です。',
localAsrActiveNotice: '現在「{{name}}」を使用中。「詳細設定」タブから切り替えまたは無効化できます。', localAsrActiveNotice: '現在「{{name}}」を使用中。「詳細設定」タブから切り替えまたは無効化できます。',
localAsrTakeoverHint: '「{{name}}」を有効化すると ASR プロバイダーが引き継がれます。', localAsrTakeoverHint: '「{{name}}」を有効化すると ASR プロバイダーが引き継がれます。',
asrProviderTakenOver: 'ASR プロバイダーは引き継ぎ済み', asrProviderTakenOver: 'ローカルエンジンを使用中です。上のドロップダウンで別のプロバイダーを選ぶと切り替えられます(ローカルエンジンは自動的に停止します)。ローカルモデルは「サービス → ローカルモデル」で管理します。',
localAsrHint: 'デバイス上で動作、API キー不要。HuggingFace からモデルをダウンロード。', localAsrHint: 'デバイス上で動作、API キー不要。HuggingFace からモデルをダウンロード。',
foundryLocalAsrHint: 'デバイス上で動作、ASR API キー不要。初回はランタイムとモデルをダウンロード。', foundryLocalAsrHint: 'デバイス上で動作、ASR API キー不要。初回はランタイムとモデルをダウンロード。',
localAsrPerformanceWarning: 'ローカル推論はクラウドより遅く、中国語の精度が低くなる場合があります。オフラインまたはプライバシー重視の場合に。', localAsrPerformanceWarning: 'ローカル推論はクラウドより遅く、中国語の精度が低くなる場合があります。オフラインまたはプライバシー重視の場合に。',
@@ -1365,8 +1369,8 @@ export const ja: typeof zhCN = {
sherpaModelWhisperLargeV3Desc: 'オープンソース多言語モデルの中で最高品質の Whisper 系。高品質だが大容量。', sherpaModelWhisperLargeV3Desc: 'オープンソース多言語モデルの中で最高品質の Whisper 系。高品質だが大容量。',
sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8',
sherpaModelQwen3Desc: '変換済み sherpa-onnx Qwen3-ASR モデル。多言語認識とより強い長文コンテキスト処理に対応。', sherpaModelQwen3Desc: '変換済み sherpa-onnx Qwen3-ASR モデル。多言語認識とより強い長文コンテキスト処理に対応。',
modelSelectTitle: 'モデル選択', modelSelectTitle: 'モデルのダウンロードと管理',
modelSelectDesc: 'ダウンロード済みモデルから現在の認識モデルを選びます。追加は下の「ダウンロードと管理」から。', modelSelectDesc: 'ダウンロードしたモデルは上部の「ASR 文字起こし」のローカルプロバイダーに自動で表示され、選択すると利用できます。「+ 新しいモデルをダウンロード」でさらに入手できます。',
modelSelectPlaceholder: 'ダウンロード済みモデルを選択…', modelSelectPlaceholder: 'ダウンロード済みモデルを選択…',
modelSelectEmpty: 'ダウンロード済みモデルがありません。「ダウンロードと管理」から入手してください。', modelSelectEmpty: 'ダウンロード済みモデルがありません。「ダウンロードと管理」から入手してください。',
groupDownload: 'ダウンロードと管理', groupDownload: 'ダウンロードと管理',
@@ -1408,5 +1412,24 @@ export const ja: typeof zhCN = {
keep5min: '最終使用から 5 分(既定)', keep5min: '最終使用から 5 分(既定)',
keep30min: '最終使用から 30 分', keep30min: '最終使用から 30 分',
keepForever: '解放しない(常にロード)', keepForever: '解放しない(常にロード)',
sidebarTitle: 'モデル一覧',
activePill: '使用中',
setDefault: 'デフォルトに設定',
downloading: 'ダウンロード中',
startDownload: 'ダウンロード開始',
downloadNewModel: '新しいモデルをダウンロード',
downloadDialogTitle: 'ローカル ASR モデルを選択',
downloadDialogAlreadyHave: 'ダウンロード済みです',
downloadDialogDesc: '左のモデルを選択して「ダウンロード開始」をクリックします。完了後、「サービス → AI プロバイダー → ASR 文字起こし」でローカルモデルのプロバイダーを選択すると利用できます。',
detailRepo: 'リポジトリ',
detailFiles: 'ファイル',
detailDownloaded: 'ダウンロード済み',
detailEmpty: '左側からモデルを選択',
foundryLanguage: '言語',
foundryRuntimeSource: 'ランタイムソース',
mirrorGithubRelease: 'GitHub Releases',
keep: '保持',
downloadSettingsTitle: 'ダウンロードとストレージ設定',
downloadSettingsDesc: 'ミラーソース · モデル保存場所 · メモリ内エンジン',
}, },
}; };
+26 -3
View File
@@ -835,6 +835,10 @@ export const ko: typeof zhCN = {
startupAtBootError: '자동 시작 전환 실패: {{message}}', startupAtBootError: '자동 시작 전환 실패: {{message}}',
}, },
providers: { providers: {
localModelLabel: '로컬 모델',
localModelEmpty: '아직 다운로드된 로컬 모델이 없습니다',
appleSpeechLocalNote: 'Apple 음성 인식은 시스템 내장 엔진을 사용하므로 모델 선택이 필요 없습니다.',
localTag: '로컬',
llmTitle: 'LLM 모델(정리)', llmTitle: 'LLM 모델(정리)',
llmDesc: 'OpenAI 호환 프로토콜, 다양한 공급자 전환 지원.', llmDesc: 'OpenAI 호환 프로토콜, 다양한 공급자 전환 지원.',
providerLabel: '공급자', providerLabel: '공급자',
@@ -903,7 +907,7 @@ export const ko: typeof zhCN = {
xfyunNote: 'iFlytek 오픈 플랫폼 "실시간 음성 인식" 서비스 페이지에서 AppID와 API Key를 가져옵니다. 오디오는 16kHz/16bit/모노 PCM입니다. 표준 API에는 핫워드 매개변수가 없으며(iFlytek 콘솔에서 개별 핫워드 설정), 언어는 기본적으로 중국어(보통화)입니다.', xfyunNote: 'iFlytek 오픈 플랫폼 "실시간 음성 인식" 서비스 페이지에서 AppID와 API Key를 가져옵니다. 오디오는 16kHz/16bit/모노 PCM입니다. 표준 API에는 핫워드 매개변수가 없으며(iFlytek 콘솔에서 개별 핫워드 설정), 언어는 기본적으로 중국어(보통화)입니다.',
localAsrActiveNotice: '현재 "{{name}}" 사용 중. "고급" 탭에서 전환 또는 비활성화할 수 있습니다.', localAsrActiveNotice: '현재 "{{name}}" 사용 중. "고급" 탭에서 전환 또는 비활성화할 수 있습니다.',
localAsrTakeoverHint: '"{{name}}" 활성화 시 ASR 프로바이더가 인수됩니다.', localAsrTakeoverHint: '"{{name}}" 활성화 시 ASR 프로바이더가 인수됩니다.',
asrProviderTakenOver: 'ASR 프로바이더 인수 완료', asrProviderTakenOver: '현재 로컬 엔진을 사용 중입니다. 위의 드롭다운에서 다른 공급자를 선택하면 전환됩니다(로컬 엔진은 자동으로 중지됨). 로컬 모델은 「서비스 → 로컬 모델」에서 관리합니다.',
localAsrHint: '기기에서 실행, API 키 불필요. HuggingFace 에서 모델 다운로드.', localAsrHint: '기기에서 실행, API 키 불필요. HuggingFace 에서 모델 다운로드.',
foundryLocalAsrHint: '기기에서 실행, ASR API 키 불필요. 첫 사용 시 런타임과 모델 다운로드.', foundryLocalAsrHint: '기기에서 실행, ASR API 키 불필요. 첫 사용 시 런타임과 모델 다운로드.',
localAsrPerformanceWarning: '로컬 추론은 클라우드보다 느리며 중국어 정확도가 낮을 수 있습니다. 오프라인 또는 개인정보 보호 시나리오에 적합.', localAsrPerformanceWarning: '로컬 추론은 클라우드보다 느리며 중국어 정확도가 낮을 수 있습니다. 오프라인 또는 개인정보 보호 시나리오에 적합.',
@@ -1365,8 +1369,8 @@ export const ko: typeof zhCN = {
sherpaModelWhisperLargeV3Desc: '오픈소스 다국어 모델 중 품질이 가장 좋은 Whisper 계열. 고품질이지만 용량이 큽니다.', sherpaModelWhisperLargeV3Desc: '오픈소스 다국어 모델 중 품질이 가장 좋은 Whisper 계열. 고품질이지만 용량이 큽니다.',
sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8',
sherpaModelQwen3Desc: '변환된 sherpa-onnx Qwen3-ASR 모델로 다국어 인식과 더 강한 긴 문맥 처리를 지원합니다.', sherpaModelQwen3Desc: '변환된 sherpa-onnx Qwen3-ASR 모델로 다국어 인식과 더 강한 긴 문맥 처리를 지원합니다.',
modelSelectTitle: '모델 선택', modelSelectTitle: '모델 다운로드 및 관리',
modelSelectDesc: '다운로드된 모델 중 현재 인식 모델을 고릅니다. 추가는 아래 「다운로드 및 관리」에서.', modelSelectDesc: '다운로드한 모델은 위의 「ASR 음성 전사」 로컬 공급자에 자동으로 표시되며, 선택하면 사용할 수 있습니다. 「+ 새 모델 다운로드」로 더 많은 모델을 받을 수 있습니다.',
modelSelectPlaceholder: '다운로드된 모델 선택…', modelSelectPlaceholder: '다운로드된 모델 선택…',
modelSelectEmpty: '다운로드된 모델이 없습니다. 「다운로드 및 관리」에서 받으세요.', modelSelectEmpty: '다운로드된 모델이 없습니다. 「다운로드 및 관리」에서 받으세요.',
groupDownload: '다운로드 및 관리', groupDownload: '다운로드 및 관리',
@@ -1408,5 +1412,24 @@ export const ko: typeof zhCN = {
keep5min: '마지막 사용 후 5분(기본)', keep5min: '마지막 사용 후 5분(기본)',
keep30min: '마지막 사용 후 30분', keep30min: '마지막 사용 후 30분',
keepForever: '해제하지 않음(항상 유지)', keepForever: '해제하지 않음(항상 유지)',
sidebarTitle: '모델 목록',
activePill: '현재 사용 중',
setDefault: '기본값으로 설정',
downloading: '다운로드 중',
startDownload: '다운로드 시작',
downloadNewModel: '새 모델 다운로드',
downloadDialogTitle: '로컬 ASR 모델 선택',
downloadDialogAlreadyHave: '이미 다운로드됨 — 바로 사용 가능',
downloadDialogDesc: '왼쪽에서 모델을 선택하고 「다운로드 시작」을 클릭하세요. 완료 후 「서비스 → AI 공급자 → ASR 음성 전사」에서 로컬 모델 공급자를 선택하면 사용할 수 있습니다.',
detailRepo: '저장소',
detailFiles: '개 파일',
detailDownloaded: '다운로드됨',
detailEmpty: '왼쪽에서 모델을 선택하세요',
foundryLanguage: '언어',
foundryRuntimeSource: '런타임 소스',
mirrorGithubRelease: 'GitHub Releases',
keep: '유지',
downloadSettingsTitle: '다운로드 및 저장 설정',
downloadSettingsDesc: '미러 소스 · 모델 저장 위치 · 메모리 내 엔진',
}, },
}; };
+26 -3
View File
@@ -831,6 +831,10 @@ export const zhCN = {
startupAtBootError: '开机自启切换失败:{{message}}', startupAtBootError: '开机自启切换失败:{{message}}',
}, },
providers: { providers: {
localModelLabel: '本地模型',
localModelEmpty: '尚未下载本地模型',
appleSpeechLocalNote: 'Apple 语音识别使用系统内置引擎,无需选择模型。',
localTag: '本地',
llmTitle: 'LLM 模型(润色)', llmTitle: 'LLM 模型(润色)',
llmDesc: 'OpenAI 兼容协议,支持多家供应商切换。', llmDesc: 'OpenAI 兼容协议,支持多家供应商切换。',
providerLabel: '供应商', providerLabel: '供应商',
@@ -899,7 +903,7 @@ export const zhCN = {
xfyunNote: '在讯飞开放平台「实时语音转写」服务页获取 AppID 与 API Key。音频为 16kHz/16bit/单声道 PCM;标准版接口暂不支持热词参数(可在讯飞控制台配置个性化热词),语种默认中文普通话。', xfyunNote: '在讯飞开放平台「实时语音转写」服务页获取 AppID 与 API Key。音频为 16kHz/16bit/单声道 PCM;标准版接口暂不支持热词参数(可在讯飞控制台配置个性化热词),语种默认中文普通话。',
localAsrActiveNotice: '当前已启用「{{name}}」,可在「高级」中切换或禁用。', localAsrActiveNotice: '当前已启用「{{name}}」,可在「高级」中切换或禁用。',
localAsrTakeoverHint: '启动「{{name}}」后,ASR 提供商将被接管。', localAsrTakeoverHint: '启动「{{name}}」后,ASR 提供商将被接管。',
asrProviderTakenOver: '当前用的是本地引擎,在上方下拉直接选其它供应商即可切换(本地引擎会自动停用);本地模型在「高级 → 本地模型」里管理。', asrProviderTakenOver: '当前用的是本地引擎,在上方下拉直接选其它供应商即可切换(本地引擎会自动停用);本地模型在「服务 → 本地模型」里管理。',
localAsrHint: '在本机运行,无需 API Key。从 HuggingFace 下载模型即可使用。', localAsrHint: '在本机运行,无需 API Key。从 HuggingFace 下载模型即可使用。',
foundryLocalAsrHint: '在本机运行,无需 ASR API Key。首次使用需下载运行组件和模型。', foundryLocalAsrHint: '在本机运行,无需 ASR API Key。首次使用需下载运行组件和模型。',
localAsrPerformanceWarning: '本地推理比云端慢,中文准确率可能更低。适合离线或隐私敏感场景。', localAsrPerformanceWarning: '本地推理比云端慢,中文准确率可能更低。适合离线或隐私敏感场景。',
@@ -1395,8 +1399,8 @@ export const zhCN = {
sherpaModelWhisperLargeV3Desc: '开源多语通用里效果最好的 Whisper 档,质量高、体积大,适合高质量转写。', sherpaModelWhisperLargeV3Desc: '开源多语通用里效果最好的 Whisper 档,质量高、体积大,适合高质量转写。',
sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8',
sherpaModelQwen3Desc: '转换后的 sherpa-onnx Qwen3-ASR 模型,支持多语言识别与更强的长上下文能力。', sherpaModelQwen3Desc: '转换后的 sherpa-onnx Qwen3-ASR 模型,支持多语言识别与更强的长上下文能力。',
modelSelectTitle: '模型选择', modelSelectTitle: '模型下载与管理',
modelSelectDesc: '从已下载的模型里选一个作为当前识别模型;更多模型在下方「下载与管理」里获取。', modelSelectDesc: '下载的模型会自动出现在上方「ASR 语音转写」的本地供应商里,选择即可使用;点击「+ 下载新模型」获取更多模型。',
modelSelectPlaceholder: '选择已下载的模型…', modelSelectPlaceholder: '选择已下载的模型…',
modelSelectEmpty: '还没有已下载的模型,先到「下载与管理」下载一个。', modelSelectEmpty: '还没有已下载的模型,先到「下载与管理」下载一个。',
groupDownload: '下载与管理', groupDownload: '下载与管理',
@@ -1438,5 +1442,24 @@ export const zhCN = {
keep5min: '上次使用后 5 分钟(默认)', keep5min: '上次使用后 5 分钟(默认)',
keep30min: '上次使用后 30 分钟', keep30min: '上次使用后 30 分钟',
keepForever: '不释放(始终保留)', keepForever: '不释放(始终保留)',
sidebarTitle: '模型列表',
activePill: '当前使用',
setDefault: '设为默认',
downloading: '下载中',
startDownload: '开始下载',
downloadNewModel: '下载新模型',
downloadDialogTitle: '选择本地 ASR 模型',
downloadDialogAlreadyHave: '该模型已下载,可直接使用',
downloadDialogDesc: '选择左侧模型后点击「开始下载」。下载完成后,到「服务 → AI 提供商 → ASR 语音转写」选择本地模型供应商即可使用。',
detailRepo: '模型仓库',
detailFiles: '个文件',
detailDownloaded: '已下载',
detailEmpty: '从左侧选择模型查看详情',
foundryLanguage: '语言',
foundryRuntimeSource: '运行时来源',
mirrorGithubRelease: 'GitHub Releases',
keep: '保持加载',
downloadSettingsTitle: '下载与存储设置',
downloadSettingsDesc: '镜像源 · 模型存储位置 · 内存引擎',
}, },
}; };
+26 -3
View File
@@ -833,6 +833,10 @@ export const zhTW: typeof zhCN = {
startupAtBootError: '開機自啓切換失敗:{{message}}', startupAtBootError: '開機自啓切換失敗:{{message}}',
}, },
providers: { providers: {
localModelLabel: '本地模型',
localModelEmpty: '尚未下載本地模型',
appleSpeechLocalNote: 'Apple 語音辨識使用系統內建引擎,無需選擇模型。',
localTag: '本地',
llmTitle: 'LLM 模型(潤色)', llmTitle: 'LLM 模型(潤色)',
llmDesc: 'OpenAI 兼容協議,支持多家供應商切換。', llmDesc: 'OpenAI 兼容協議,支持多家供應商切換。',
providerLabel: '供應商', providerLabel: '供應商',
@@ -901,7 +905,7 @@ export const zhTW: typeof zhCN = {
xfyunNote: '在訊飛開放平台「即時語音轉寫」服務頁取得 AppID 與 API Key。音訊為 16kHz/16bit/單聲道 PCM;標準版介面暫不支援熱詞參數(可在訊飛控制台設定個人化熱詞),語種預設中文普通話。', xfyunNote: '在訊飛開放平台「即時語音轉寫」服務頁取得 AppID 與 API Key。音訊為 16kHz/16bit/單聲道 PCM;標準版介面暫不支援熱詞參數(可在訊飛控制台設定個人化熱詞),語種預設中文普通話。',
localAsrActiveNotice: '當前已啓用「{{name}}」,可在「高級」中切換或停用。', localAsrActiveNotice: '當前已啓用「{{name}}」,可在「高級」中切換或停用。',
localAsrTakeoverHint: '啓動「{{name}}」後,ASR 提供商將被接管。', localAsrTakeoverHint: '啓動「{{name}}」後,ASR 提供商將被接管。',
asrProviderTakenOver: 'ASR 提供商已被接管', asrProviderTakenOver: '目前使用的是本地引擎,在上方下拉直接選其他供應商即可切換(本地引擎會自動停用);本地模型在「服務 → 本地模型」裡管理。',
localAsrHint: '在本機運行,無需 API Key。從 HuggingFace 下載模型即可使用。', localAsrHint: '在本機運行,無需 API Key。從 HuggingFace 下載模型即可使用。',
foundryLocalAsrHint: '在本機運行,無需 ASR API Key。首次使用需下載運行元件和模型。', foundryLocalAsrHint: '在本機運行,無需 ASR API Key。首次使用需下載運行元件和模型。',
localAsrPerformanceWarning: '本地推理比雲端慢,中文準確率可能更低。適合離線或隱私敏感場景。', localAsrPerformanceWarning: '本地推理比雲端慢,中文準確率可能更低。適合離線或隱私敏感場景。',
@@ -1363,8 +1367,8 @@ export const zhTW: typeof zhCN = {
sherpaModelWhisperLargeV3Desc: '開源多語通用中效果最好的 Whisper 檔,品質高、體積大,適合高品質轉寫。', sherpaModelWhisperLargeV3Desc: '開源多語通用中效果最好的 Whisper 檔,品質高、體積大,適合高品質轉寫。',
sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8',
sherpaModelQwen3Desc: '轉換後的 sherpa-onnx Qwen3-ASR 模型,支援多語言識別與更強的長上下文能力。', sherpaModelQwen3Desc: '轉換後的 sherpa-onnx Qwen3-ASR 模型,支援多語言識別與更強的長上下文能力。',
modelSelectTitle: '模型選擇', modelSelectTitle: '模型下載與管理',
modelSelectDesc: '從已下載的模型裡選一個作為目前辨識模型;更多模型在下方「下載與管理」取得。', modelSelectDesc: '下載的模型會自動出現在上方「ASR 語音轉寫」的本地供應商裡,選擇即可使用;點擊「+ 下載新模型」取得更多模型。',
modelSelectPlaceholder: '選擇已下載的模型…', modelSelectPlaceholder: '選擇已下載的模型…',
modelSelectEmpty: '還沒有已下載的模型,先到「下載與管理」下載一個。', modelSelectEmpty: '還沒有已下載的模型,先到「下載與管理」下載一個。',
groupDownload: '下載與管理', groupDownload: '下載與管理',
@@ -1406,5 +1410,24 @@ export const zhTW: typeof zhCN = {
keep5min: '上次使用後 5 分鐘(默認)', keep5min: '上次使用後 5 分鐘(默認)',
keep30min: '上次使用後 30 分鐘', keep30min: '上次使用後 30 分鐘',
keepForever: '不釋放(始終保留)', keepForever: '不釋放(始終保留)',
sidebarTitle: '模型列表',
activePill: '目前使用',
setDefault: '設為預設',
downloading: '下載中',
startDownload: '開始下載',
downloadNewModel: '下載新模型',
downloadDialogTitle: '選擇本地 ASR 模型',
downloadDialogAlreadyHave: '該模型已下載,可直接使用',
downloadDialogDesc: '選擇左側模型後點擊「開始下載」。下載完成後,到「服務 → AI 提供商 → ASR 語音轉寫」選擇本地模型供應商即可使用。',
detailRepo: '模型倉庫',
detailFiles: '個檔案',
detailDownloaded: '已下載',
detailEmpty: '從左側選擇模型查看詳情',
foundryLanguage: '語言',
foundryRuntimeSource: '執行時來源',
mirrorGithubRelease: 'GitHub Releases',
keep: '保持載入',
downloadSettingsTitle: '下載與儲存設定',
downloadSettingsDesc: '鏡像源 · 模型儲存位置 · 記憶體引擎',
}, },
}; };
@@ -3,6 +3,7 @@
// stateless beyond local render memoization. // stateless beyond local render memoization.
import { useMemo } from "react" import { useMemo } from "react"
import { createPortal } from "react-dom"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { import {
type FoundryPrepareProgress, type FoundryPrepareProgress,
@@ -11,6 +12,7 @@ import {
type LocalAsrTestResult, type LocalAsrTestResult,
} from "../../lib/localAsr" } from "../../lib/localAsr"
import { Btn, Card, Pill } from "../_atoms" import { Btn, Card, Pill } from "../_atoms"
import { Icon } from "../../components/Icon"
import { formatBytes } from "./helpers" import { formatBytes } from "./helpers"
import type { RemoteSize } from "./types" import type { RemoteSize } from "./types"
@@ -549,3 +551,730 @@ export function TestResultBlock({
</div> </div>
) )
} }
// ─────────────────────────────────────────────────────────────────────
// 本地 ASR 模型管理重构(两栏看板 + 下载弹框 + 右上角下载进度浮层)。
// 纯展示组件;数据与动作由 LocalAsr/index.tsx 组装后传入。
// ─────────────────────────────────────────────────────────────────────
/** 侧栏统一条目:三套本地引擎(Qwen3 / sherpa-onnx / foundry)归一化。 */
export interface SidebarModelEntry {
id: string
/** 展示名(如 qwen3-asr-0.6b / whisper-small)。 */
name: string
/** HF 仓库标识(Qwen3 有;sherpa/foundry 可能为空)。 */
repo?: string
/** 已下载字节数(HF 拉取的真实尺寸)。 */
remoteBytes?: number
/** 已下载(有绿勾)。 */
isDownloaded: boolean
/** 下载中(有进度条/取消入口)。 */
isDownloading: boolean
/** 当前激活(设为默认的本地模型)。 */
isActive: boolean
/** 引擎标识,决定右侧动作按钮分派。 */
engine: "qwen3" | "sherpa" | "foundry"
}
/** 左侧模型选择栏:竖排条目,选中高亮;底部预留「下载新模型」按钮位。 */
export function ModelSidebar({
entries,
selectedId,
onSelect,
onOpenDownload,
downloadDisabled,
}: {
entries: SidebarModelEntry[]
selectedId: string | null
onSelect: (id: string) => void
onOpenDownload: () => void
downloadDisabled: boolean
}) {
const { t } = useTranslation()
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 6,
minWidth: 0,
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "var(--ol-ink-4)",
marginBottom: 2,
}}
>
{t("localAsr.sidebarTitle")}
</div>
{entries.map((entry) => {
const selected = entry.id === selectedId
return (
<button
key={entry.id}
type="button"
onClick={() => onSelect(entry.id)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
borderRadius: 8,
border: "0.5px solid var(--ol-line-soft)",
background: selected
? "var(--ol-segmented-active-bg)"
: "transparent",
boxShadow: selected
? "var(--ol-segmented-active-shadow)"
: "none",
color: "var(--ol-ink)",
fontFamily: "inherit",
fontSize: 12.5,
textAlign: "left",
cursor: "pointer",
transition:
"background 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)",
width: "100%",
}}
>
{/* 状态徽标:已下载绿勾 / 下载中 spinner / 未下载空心点 */}
<span
style={{
display: "inline-flex",
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
color: entry.isDownloaded
? "var(--ol-ok)"
: "var(--ol-ink-4)",
}}
>
{entry.isDownloaded ? (
<IconCheck />
) : entry.isDownloading ? (
<span
style={{
width: 10,
height: 10,
borderRadius: 999,
border: "1.5px solid var(--ol-ink-4)",
borderTopColor: "transparent",
animation: "ol-spin 0.8s linear infinite",
display: "inline-block",
}}
/>
) : (
<span
style={{
width: 6,
height: 6,
borderRadius: 999,
background: "var(--ol-ink-4)",
opacity: 0.5,
display: "inline-block",
}}
/>
)}
</span>
<span
style={{
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{entry.name}
</span>
{entry.isActive && (
<span
style={{
fontSize: 10,
color: "var(--ol-blue)",
flexShrink: 0,
}}
>
{t("localAsr.activePill")}
</span>
)}
{entry.remoteBytes != null && entry.remoteBytes > 0 && (
<span
style={{
fontSize: 10.5,
color: "var(--ol-ink-4)",
flexShrink: 0,
}}
>
{formatBytes(entry.remoteBytes)}
</span>
)}
</button>
)
})}
{entries.length === 0 && (
<div style={{ fontSize: 12, color: "var(--ol-ink-4)", padding: "4px 2px" }}>
{t("localAsr.modelSelectEmpty")}
</div>
)}
<button
type="button"
onClick={onOpenDownload}
disabled={downloadDisabled}
style={{
marginTop: 4,
padding: "11px 10px",
borderRadius: 8,
border: "1px dashed var(--ol-line-strong)",
background: "transparent",
color: "var(--ol-blue)",
fontFamily: "inherit",
fontSize: 12.5,
fontWeight: 600,
cursor: downloadDisabled ? "not-allowed" : "pointer",
opacity: downloadDisabled ? 0.5 : 1,
transition:
"background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)",
}}
>
+ {t("localAsr.downloadNewModel")}
</button>
</div>
)
}
function IconCheck() {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M3 8.5L6.2 11.7L13 4.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
/** 右侧详情看板:选中模型的信息(HF 抓取的尺寸/文件数)+ 操作按钮。 */
export function ModelDetailPanel({
entry,
fileCount,
mirrorLabel,
downloading,
progressPercent,
busy,
onDownload,
onCancel,
onDelete,
onReveal,
onTest,
showTest,
}: {
entry: SidebarModelEntry | null
fileCount: number | null
mirrorLabel?: string
downloading: boolean
progressPercent: number | null
busy: boolean
onDownload: () => void
onCancel: () => void
onDelete: () => void
onReveal: () => void
onTest: () => void
showTest: boolean
}) {
const { t } = useTranslation()
if (!entry) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 120,
fontSize: 12.5,
color: "var(--ol-ink-4)",
}}
>
{t("localAsr.detailEmpty")}
</div>
)
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12, minWidth: 0 }}>
<div>
<div style={{ fontSize: 14, fontWeight: 650, color: "var(--ol-ink)" }}>
{entry.name}
</div>
{entry.repo && (
<div style={{ fontSize: 11.5, color: "var(--ol-ink-4)", marginTop: 3 }}>
{t("localAsr.detailRepo")}: {entry.repo}
</div>
)}
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
marginTop: 8,
fontSize: 11,
}}
>
{entry.remoteBytes != null && entry.remoteBytes > 0 && (
<span
style={{
padding: "2px 8px",
borderRadius: 999,
background: "rgba(0,0,0,0.05)",
color: "var(--ol-ink-2)",
}}
>
{formatBytes(entry.remoteBytes)}
</span>
)}
{fileCount != null && fileCount > 0 && (
<span
style={{
padding: "2px 8px",
borderRadius: 999,
background: "rgba(0,0,0,0.05)",
color: "var(--ol-ink-2)",
}}
>
{fileCount} {t("localAsr.detailFiles")}
</span>
)}
{entry.isDownloaded && (
<span
style={{
padding: "2px 8px",
borderRadius: 999,
background: "rgba(40,160,90,0.12)",
color: "var(--ol-ok)",
}}
>
✓ {t("localAsr.detailDownloaded")}
</span>
)}
{mirrorLabel && (
<span
style={{
padding: "2px 8px",
borderRadius: 999,
background: "rgba(0,0,0,0.05)",
color: "var(--ol-ink-4)",
}}
>
{mirrorLabel}
</span>
)}
</div>
</div>
{downloading && progressPercent != null && (
<div>
<div style={{ height: 6, borderRadius: 999, background: "var(--ol-surface-2)", overflow: "hidden" }}>
<div
style={{
height: "100%",
width: `${progressPercent}%`,
background: "var(--ol-blue)",
transition: "width 0.18s var(--ol-motion-soft)",
}}
/>
</div>
<div style={{ fontSize: 11, color: "var(--ol-ink-4)", marginTop: 4 }}>
{Math.round(progressPercent)}%
</div>
</div>
)}
{/* 第一行:下载 / 取消,或「加载并测试」——加载即作为当前模型使用,
不再单独设「设为默认」(激活 = 在 ASR 语音转写里选本地模型供应商)。 */}
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{!entry.isDownloaded && (
<Btn variant="primary" size="sm" disabled={busy} onClick={onDownload}>
{downloading ? t("localAsr.downloading") : t("localAsr.download")}
</Btn>
)}
{downloading && (
<Btn variant="ghost" size="sm" onClick={onCancel}>
{t("common.cancel")}
</Btn>
)}
{entry.isDownloaded && showTest && (
<Btn variant="primary" size="sm" disabled={busy} onClick={onTest}>
{t("localAsr.test")}
</Btn>
)}
</div>
{/* 第二行:打开目录 + 删除(已下载模型,同一行)。 */}
{entry.isDownloaded && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
<Btn variant="ghost" size="sm" onClick={onReveal}>
{t("localAsr.revealDir")}
</Btn>
<Btn variant="ghost" size="sm" onClick={onDelete}>
{t("localAsr.delete")}
</Btn>
</div>
)}
</div>
)
}
/** 下载弹框:全页式(与设置弹窗同尺寸、顶部锚定),左侧模型选择(竖排,
* 已下载标勾)+ 右侧详情,右上角 ✕ 关闭,底部开始下载。
*
* 必须 createPortal 到 document.body:WindowChrome 根节点带常驻 transform /
* will-change(ol-window-enter 动画 fill-mode: both 保留终帧 transform),
* 会创建 containing block —— 直接渲染的话 `position: fixed` 会相对设置弹窗
* 而不是视口定位,遮罩只盖住设置面板(内容发灰)、弹框被裁掉一半且点不到
* (与 Modal.tsx 的 GitHub 登录弹窗同一 bug 根因)。portal 出去后 fixed
* 相对视口,铺满整窗、始终置顶。 */
export function DownloadDialog({
entries,
selectedId,
onSelect,
sizeOf,
fileCountOf,
busy,
onStart,
onClose,
}: {
entries: SidebarModelEntry[]
selectedId: string | null
onSelect: (id: string) => void
sizeOf: (id: string) => number | null
fileCountOf: (id: string) => number | null
busy: boolean
onStart: () => void
onClose: () => void
}) {
const { t } = useTranslation()
const selected = entries.find((e) => e.id === selectedId) ?? null
return createPortal(
<div
role="dialog"
aria-modal="true"
style={{
position: "fixed",
inset: 0,
background: "var(--ol-overlay-bg)",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
zIndex: 1000,
padding: 28,
animation: "ol-modal-backdrop-in 0.18s var(--ol-motion-soft)",
}}
onClick={(e) => {
if (e.target === e.currentTarget && !busy) onClose()
}}
>
<div
style={{
width: "min(880px, 100%)",
height: "min(600px, calc(100vh - 56px))",
display: "flex",
flexDirection: "column",
borderRadius: 14,
background: "var(--ol-surface)",
border: "0.5px solid var(--ol-line-strong)",
boxShadow: "var(--ol-shadow-xl)",
overflow: "hidden",
animation: "ol-modal-card-in 0.24s var(--ol-motion-spring)",
}}
>
{/* 标题行:左标题 + 右 ✕ 关闭 */}
<div
style={{
padding: "13px 16px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
borderBottom: "0.5px solid var(--ol-line)",
}}
>
<div
style={{
fontSize: 14,
fontWeight: 650,
color: "var(--ol-ink)",
letterSpacing: "-0.01em",
}}
>
{t("localAsr.downloadDialogTitle")}
</div>
<button
type="button"
onClick={onClose}
disabled={busy}
aria-label={t("common.close")}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 26,
height: 26,
borderRadius: 7,
border: 0,
padding: 0,
background: "transparent",
color: "var(--ol-ink-3)",
cursor: busy ? "not-allowed" : "pointer",
opacity: busy ? 0.5 : 1,
transition:
"background 0.12s var(--ol-motion-quick), color 0.12s var(--ol-motion-quick)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--ol-surface-2)"
e.currentTarget.style.color = "var(--ol-ink)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent"
e.currentTarget.style.color = "var(--ol-ink-3)"
}}
>
<Icon name="close" size={13} />
</button>
</div>
<div style={{ display: "flex", minHeight: 0, flex: 1, overflow: "hidden" }}>
{/* 左侧:模型选择(竖排,结构与设置页侧栏一致) */}
<div
style={{
width: 230,
flexShrink: 0,
borderRight: "0.5px solid var(--ol-line)",
padding: 12,
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "var(--ol-ink-4)",
marginBottom: 2,
}}
>
{t("localAsr.sidebarTitle")}
</div>
{entries.map((entry) => (
<button
key={entry.id}
type="button"
onClick={() => onSelect(entry.id)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
borderRadius: 8,
border: "0.5px solid var(--ol-line-soft)",
background:
entry.id === selectedId
? "var(--ol-segmented-active-bg)"
: "transparent",
boxShadow:
entry.id === selectedId
? "var(--ol-segmented-active-shadow)"
: "none",
color: "var(--ol-ink)",
fontFamily: "inherit",
fontSize: 12.5,
textAlign: "left",
cursor: "pointer",
transition:
"background 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)",
}}
>
<span
style={{
width: 14,
color: entry.isDownloaded
? "var(--ol-ok)"
: "var(--ol-ink-4)",
flexShrink: 0,
display: "inline-flex",
}}
>
{entry.isDownloaded ? <IconCheck /> : "•"}
</span>
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{entry.name}
</span>
</button>
))}
{entries.length === 0 && (
<div style={{ fontSize: 12, color: "var(--ol-ink-4)", padding: "4px 2px" }}>
{t("localAsr.modelSelectEmpty")}
</div>
)}
</div>
{/* 右侧:说明 + 详情(大小 / 文件数 / 状态) */}
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 16 }}>
<div style={{ fontSize: 11.5, color: "var(--ol-ink-4)", lineHeight: 1.6, marginBottom: 12 }}>
{t("localAsr.downloadDialogDesc")}
</div>
{selected ? (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: "var(--ol-ink)" }}>
{selected.name}
</div>
{selected.repo && (
<div style={{ fontSize: 11.5, color: "var(--ol-ink-4)" }}>
{t("localAsr.detailRepo")}: {selected.repo}
</div>
)}
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, fontSize: 11 }}>
{(() => {
const bytes = sizeOf(selected.id)
const files = fileCountOf(selected.id)
return (
<>
{bytes != null && bytes > 0 && (
<span style={{ padding: "2px 8px", borderRadius: 999, background: "rgba(0,0,0,0.05)", color: "var(--ol-ink-2)" }}>
{formatBytes(bytes)}
</span>
)}
{files != null && files > 0 && (
<span style={{ padding: "2px 8px", borderRadius: 999, background: "rgba(0,0,0,0.05)", color: "var(--ol-ink-2)" }}>
{files} {t("localAsr.detailFiles")}
</span>
)}
</>
)
})()}
{selected.isDownloaded && (
<span style={{ padding: "2px 8px", borderRadius: 999, background: "rgba(40,160,90,0.12)", color: "var(--ol-ok)" }}>
✓ {t("localAsr.detailDownloaded")}
</span>
)}
</div>
{selected.isDownloaded && (
<div style={{ fontSize: 11.5, color: "var(--ol-ink-4)" }}>
{t("localAsr.downloadDialogAlreadyHave")}
</div>
)}
</div>
) : (
<div style={{ fontSize: 12, color: "var(--ol-ink-4)" }}>
{t("localAsr.detailEmpty")}
</div>
)}
</div>
<div
style={{
padding: "12px 18px",
borderTop: "0.5px solid var(--ol-line)",
display: "flex",
justifyContent: "flex-end",
gap: 8,
}}
>
<Btn variant="ghost" size="sm" disabled={busy} onClick={onClose}>
{t("common.cancel")}
</Btn>
<Btn
variant="primary"
size="sm"
disabled={busy || !selected || selected.isDownloaded}
onClick={onStart}
>
{t("localAsr.startDownload")}
</Btn>
</div>
</div>
</div>
</div>
</div>,
document.body,
)
}
/** 右上角下载进度浮层:多个下载条目叠放,直到各自下载完成才消失。
* 同样 portal 到 document.body——fixed 定位必须相对视口(见 DownloadDialog 注释)。 */
export function GlobalDownloadProgress({
items,
}: {
items: {
id: string
name: string
percent: number | null
finished: boolean
}[]
}) {
const { t } = useTranslation()
const visible = items.filter((item) => !item.finished)
if (visible.length === 0) return null
return createPortal(
<div
style={{
position: "fixed",
top: 14,
right: 14,
zIndex: 900,
display: "flex",
flexDirection: "column",
gap: 8,
maxWidth: 260,
}}
>
{visible.map((item) => (
<div
key={item.id}
style={{
padding: "10px 12px",
borderRadius: 10,
background: "var(--ol-surface)",
border: "0.5px solid var(--ol-line-strong)",
boxShadow: "var(--ol-shadow-lg)",
animation: "ol-select-pop .18s var(--ol-motion-quick) both",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: 8,
fontSize: 11.5,
color: "var(--ol-ink)",
marginBottom: 6,
}}
>
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{item.name}
</span>
<span style={{ color: "var(--ol-ink-4)", flexShrink: 0 }}>
{item.percent != null ? `${Math.round(item.percent)}%` : t("localAsr.downloading")}
</span>
</div>
<div style={{ height: 5, borderRadius: 999, background: "var(--ol-surface-2)", overflow: "hidden" }}>
<div
style={{
height: "100%",
width: `${item.percent ?? 0}%`,
background: "var(--ol-blue)",
transition: "width 0.18s var(--ol-motion-soft)",
}}
/>
</div>
</div>
))}
</div>,
document.body,
)
}
File diff suppressed because it is too large. Load diff
@@ -51,10 +51,25 @@ export function CheckUpdateButton({ channel }: { channel: UpdateChannel }) {
<Icon <Icon
name={iconName} name={iconName}
size={12} size={12}
style={checking ? { animation: 'ol-spin 0.8s linear infinite' } : undefined} style={{
// 状态图标(check ↔ refresh ↔ 错误)切换时颜色过渡;
// 检查中旋转(ol-spin),旋转轴在图标容器中心(宽度锁死 14)。
transition: 'color 0.18s var(--ol-motion-quick)',
animation: checking ? 'ol-spin 0.8s linear infinite' : undefined,
}}
/> />
</span> </span>
<span style={{ whiteSpace: 'nowrap' }}>{label}</span> <span
key={label}
style={{
whiteSpace: 'nowrap',
// 状态文案("检查更新" ↔ "检查中…" ↔ 结果提示)切换时淡入微滑移,
// 与 SelectLite 选中值切换动画同款(ol-select-value-in,global.css)。
animation: 'ol-select-value-in .16s var(--ol-motion-quick)',
}}
>
{label}
</span>
</button> </button>
{isDialogStatus(status) && ( {isDialogStatus(status) && (
<UpdateDialog <UpdateDialog
@@ -1,23 +1,21 @@
// 高级 → 本地模型:本地 ASR 推理引擎的启用 / 禁用 + 模型下载管理。 // 服务 → 本地模型:本地 ASR 模型的管理入口。含 Qwen3(macOS)/ Foundry Local +
// 自 Settings.tsx 的 AdvancedSection 拆出(流式输入已挪到「录音与输入」)。 // sherpa-onnx(Windows)三条本地引擎。
// 含 Qwen3(macOS)/ Foundry Local + sherpa-onnx(Windows)三条本地引擎。 //
// 2026-08 重构:本地 ASR 不再有「启用开关」——本地 ASR 始终可用,是否激活由
// 「服务 → AI 提供商」的 ASR 语音转写供应商决定:选到本地模型供应商即使用本地
// 引擎(与 Apple 语音同理)。这里只保留模型下载 / 管理看板(<LocalAsr embedded />)。
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import type { PlatformCapabilities } from '../../lib/types'; import type { PlatformCapabilities } from '../../lib/types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LocalAsr } from '../LocalAsr'; import { LocalAsr } from '../LocalAsr';
import { detectOS } from '../../components/WindowChrome'; import { detectOS } from '../../components/WindowChrome';
import { getPlatformCapabilities } from '../../lib/platform'; import { getPlatformCapabilities } from '../../lib/platform';
import { setActiveAsrProvider } from '../../lib/ipc'; import { Card } from '../_atoms';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { Btn, Card } from '../_atoms';
import { SettingRow, Toggle, type AsrPresetId } from './shared';
export function LocalModelSection() { export function LocalModelSection() {
const { t } = useTranslation(); const { t } = useTranslation();
const { prefs, updatePrefs } = useHotkeySettings();
const os = detectOS(); const os = detectOS();
const isMac = os === 'mac';
const isWin = os === 'win'; const isWin = os === 'win';
const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(null); const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(null);
@@ -26,202 +24,41 @@ export function LocalModelSection() {
}, []); }, []);
const platformSupported = platformCaps?.supportsLocalAsr === true; const platformSupported = platformCaps?.supportsLocalAsr === true;
const switchSeqRef = useRef(0);
const [busy, setBusy] = useState(false);
// 待确认的启用目标。!== null 时中央 modal 弹出 + 背景模糊;用户点确认 → 真切;
// 点取消 → 回到 null。一次只允许一个 modal。
const [pendingTarget, setPendingTarget] = useState<AsrPresetId | null>(null);
const activeAsrProvider = (prefs?.activeAsrProvider ?? 'volcengine') as AsrPresetId;
const isOnLocalQwen3 = activeAsrProvider === 'local-qwen3';
const isOnFoundry = activeAsrProvider === 'foundry-local-whisper';
const isOnSherpaOnnx = activeAsrProvider === 'sherpa-onnx-local';
const isOnAnyLocal = isOnLocalQwen3 || isOnFoundry || isOnSherpaOnnx;
const requestEnable = (target: AsrPresetId) => {
setPendingTarget(target);
};
const performSwitch = async (target: AsrPresetId) => {
setBusy(true);
const seq = ++switchSeqRef.current;
try {
await setActiveAsrProvider(target);
if (seq !== switchSeqRef.current) return;
if (prefs) {
await updatePrefs({ ...prefs, activeAsrProvider: target });
}
} catch (err) {
// 调用方是 void performSwitch(...) 即发即忘 —— 这里吞掉并记日志,否则 IPC
// 失败会冒成未处理的 promise rejection。
console.error('[settings] switch local ASR provider failed', err);
} finally {
if (seq === switchSeqRef.current) {
setBusy(false);
setPendingTarget(null);
}
}
};
const pendingNameKey =
pendingTarget === 'local-qwen3' ? 'asrLocalQwen3'
: pendingTarget === 'foundry-local-whisper' ? 'asrFoundryLocalWhisper'
: pendingTarget === 'sherpa-onnx-local' ? 'asrSherpaOnnxLocal'
: null;
return ( return (
<> <Card>
{/* ─── 屏幕中央确认 modal(背景模糊) ───────────────────────────── {/* 标题 + 右上角 inline 警告小字(实验性标记保留)。
点击遮罩或取消按钮关闭;切换中(busy)禁止任何关闭路径以免半切失败。 */} Windows:标题区整体灰显 —— 本地 ASR 在 Win 上走 Foundry / sherpa 独立路径。 */}
{pendingTarget && pendingNameKey && ( <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
<div <div style={{ minWidth: 0, opacity: isWin ? 0.45 : 1 }}>
role="dialog" <div style={{ fontSize: 14, fontWeight: 600, letterSpacing: '-0.01em' }}>{t('settings.advanced.localAsrTitle')}</div>
aria-modal="true" </div>
style={{ <div style={{
position: 'fixed', fontSize: 11,
inset: 0, color: '#A04500',
background: 'var(--ol-overlay-bg)', fontWeight: 500,
backdropFilter: 'blur(8px)', lineHeight: 1.4,
WebkitBackdropFilter: 'blur(8px)', textAlign: 'right',
display: 'flex', flexShrink: 0,
alignItems: 'center', maxWidth: '52%',
justifyContent: 'center', paddingTop: 2,
zIndex: 1000, opacity: isWin ? 0.45 : 1,
padding: 16, }}>
}} ⚠️ {t('settings.advanced.localAsrWarningShort')}
onClick={(e) => { </div>
if (e.target === e.currentTarget && !busy) setPendingTarget(null); </div>
}}>
<Card {!platformSupported ? (
style={{ <div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.6, padding: '8px 0' }}>
background: 'rgba(255, 188, 60, 0.12)', {t('settings.advanced.platformNotSupported')}
border: '1px solid rgba(220, 110, 0, 0.55)', </div>
maxWidth: 360, ) : (
width: '100%', /* 模型下载 / 管理看板(模型选择 · 下载 · 删除 · 测试 · 镜像源收纳)——
}}> 较大的内嵌框。启用入口在「AI 提供商 → ASR 语音转写」选择本地供应商。 */
<div style={{ fontSize: 13, fontWeight: 600, color: '#A04500', marginBottom: 6 }}> <div style={{ marginTop: 16, borderTop: '0.5px solid var(--ol-line)', paddingTop: 16 }}>
⚠️ {t('settings.advanced.confirmEnableLocalTitle')} <LocalAsr embedded />
</div>
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-2)', lineHeight: 1.6, marginBottom: 10 }}>
{t('settings.advanced.confirmEnableLocalBody', {
target: t(`settings.providers.presets.${pendingNameKey}`),
})}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Btn variant="ghost" size="sm" disabled={busy} onClick={() => setPendingTarget(null)}>
{t('common.cancel')}
</Btn>
<Btn
variant="primary"
size="sm"
disabled={busy}
onClick={() => void performSwitch(pendingTarget)}>
{t('settings.advanced.confirm')}
</Btn>
</div>
</Card>
</div> </div>
)} )}
</Card>
<Card>
{/* 标题 + 右上角 inline 警告小字。
Windows:标题区整体灰显 —— 「本地 ASR 模型(实验性)」在 Win 上几乎只有
Qwen3 占位、本平台暂不支持;Foundry / sherpa-onnx 走的是另一条独立路径。 */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
<div style={{ minWidth: 0, opacity: isWin ? 0.45 : 1 }}>
<div style={{ fontSize: 14, fontWeight: 600, letterSpacing: '-0.01em' }}>{t('settings.advanced.localAsrTitle')}</div>
</div>
<div style={{
fontSize: 11,
color: '#A04500',
fontWeight: 500,
lineHeight: 1.4,
textAlign: 'right',
flexShrink: 0,
maxWidth: '52%',
paddingTop: 2,
opacity: isWin ? 0.45 : 1,
}}>
⚠️ {t('settings.advanced.localAsrWarningShort')}
</div>
</div>
{!platformSupported ? (
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.6, padding: '8px 0' }}>
{t('settings.advanced.platformNotSupported')}
</div>
) : (
<>
{/* Qwen3 行 —— macOS Toggle 可点切换;Windows 后端是 stub,Toggle 始终 off
+ 不可点。整行灰显,跟「实验性」标题区对齐。 */}
<div style={{ opacity: isWin ? 0.45 : 1 }}>
<SettingRow label={t('settings.providers.presets.asrLocalQwen3')}>
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<Toggle
on={isMac && isOnLocalQwen3}
onToggle={isMac && !busy && pendingTarget === null ? (next) => {
if (next) requestEnable('local-qwen3');
else void performSwitch('volcengine');
} : undefined}
/>
</div>
</SettingRow>
</div>
{/* Foundry Local + sherpa-onnx 行 —— 仅 Windows 露出。 */}
{isWin && (
<>
<SettingRow label={t('settings.providers.presets.asrFoundryLocalWhisper')}>
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<Toggle
on={isOnFoundry}
onToggle={!busy && pendingTarget === null ? (next) => {
if (next) requestEnable('foundry-local-whisper');
else void performSwitch('volcengine');
} : undefined}
/>
</div>
</SettingRow>
<SettingRow label={t('settings.providers.presets.asrSherpaOnnxLocal')}>
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<Toggle
on={isOnSherpaOnnx}
onToggle={!busy && pendingTarget === null ? (next) => {
if (next) requestEnable('sherpa-onnx-local');
else void performSwitch('volcengine');
} : undefined}
/>
</div>
</SettingRow>
</>
)}
</>
)}
{/* 「禁用本地 ASR」逃生入口——只在行内 Toggle 关不掉的场景露出(Linux / 跨平台
异常 profile 同步)。否则平台本机 Toggle 自身就能 off。 */}
{isOnAnyLocal && !((isMac && isOnLocalQwen3) || (isWin && (isOnFoundry || isOnSherpaOnnx))) && (
<SettingRow label={t('settings.advanced.disableLocalLabel')}>
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<Btn
variant="primary"
size="sm"
disabled={busy || pendingTarget !== null}
onClick={() => void performSwitch('volcengine')}>
{t('settings.advanced.disable')}
</Btn>
</div>
</SettingRow>
)}
{/* 模型下载 / 加载(镜像源 · 模型列表 · 下载 · 删除 · 设为默认 · Foundry / sherpa)
—— 跟上面的启动开关收进同一个框:「本地 ASR」是一个整体。 */}
{platformSupported && (
<div style={{ marginTop: 16, borderTop: '0.5px solid var(--ol-line)', paddingTop: 16 }}>
<LocalAsr embedded />
</div>
)}
</Card>
</>
); );
} }
@@ -24,6 +24,24 @@ import {
serializeAdvancedAsrConfig, serializeAdvancedAsrConfig,
type AdvancedAsrConfig, type AdvancedAsrConfig,
} from '../../lib/advancedAsrConfig'; } from '../../lib/advancedAsrConfig';
import {
getFoundryLocalAsrCatalog,
getSherpaOnnxAsrCatalog,
listLocalAsrModels,
setFoundryLocalAsrModel,
setLocalAsrActiveModel,
setSherpaOnnxAsrModel,
} from '../../lib/localAsr';
// 本地模型供应商:在主下拉里标注「本地」后缀,与云端供应商区分开。
const LOCAL_ASR_PRESET_IDS: ReadonlySet<string> = new Set([
'local-qwen3',
'foundry-local-whisper',
'sherpa-onnx-local',
]);
function isLocalAsrPreset(id: string): boolean {
return LOCAL_ASR_PRESET_IDS.has(id);
}
function LlmThinkingToggle({ enabled, onToggle }: { enabled: boolean; onToggle: (next: boolean) => void }) { function LlmThinkingToggle({ enabled, onToggle }: { enabled: boolean; onToggle: (next: boolean) => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -250,13 +268,56 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
useEffect(() => { useEffect(() => {
if (committedAsrProvider !== 'bailian') setBailianModel(''); if (committedAsrProvider !== 'bailian') setBailianModel('');
}, [committedAsrProvider]); }, [committedAsrProvider]);
// 本地重引擎(qwen3 / sherpa / foundry)仍只在「高级 → 本地模型」里启用, // 本地引擎激活时,ASR 区块展示本地已下载模型(标注「本地」),可直接选用。
// 防止新手在主下拉误开 CPU 推理。Apple 语音是系统自带、零凭据、轻量, // selectedLocalModelId 是受控 value:只跟模型列表 / 后端 active 模型联动,
// 在 macOS 上直接作为常规选项放进主下拉,方便随时选用 / 切走。 // 否则下拉会一直停留在第一个已下载模型(pr-agent #922 反馈)。
const [localAsrModels, setLocalAsrModels] = useState<{ id: string; isDownloaded: boolean }[]>([]);
const [selectedLocalModelId, setSelectedLocalModelId] = useState('');
useEffect(() => {
let cancelled = false;
const apply = (models: { id: string; isDownloaded: boolean }[]) => {
if (cancelled) return;
setLocalAsrModels(models);
// 优先保持后端当前激活的模型;不在已下载列表里则退回第一个已下载。
const activeId = committedAsrProvider === 'local-qwen3'
? prefs?.localAsrActiveModel
: committedAsrProvider === 'sherpa-onnx-local'
? prefs?.sherpaOnnxModel
: committedAsrProvider === 'foundry-local-whisper'
? prefs?.foundryLocalAsrModel
: undefined;
setSelectedLocalModelId(
activeId && models.some(m => m.id === activeId && m.isDownloaded)
? activeId
: (models.find(m => m.isDownloaded)?.id ?? ''),
);
};
if (committedAsrProvider === 'local-qwen3') {
void listLocalAsrModels().then(models => {
apply(models.map(m => ({ id: m.id, isDownloaded: m.isDownloaded })));
}).catch(() => { if (!cancelled) { setLocalAsrModels([]); setSelectedLocalModelId(''); } });
} else if (committedAsrProvider === 'sherpa-onnx-local') {
void getSherpaOnnxAsrCatalog().then(catalog => {
apply(catalog.map(c => ({ id: c.alias, isDownloaded: c.cached })));
}).catch(() => { if (!cancelled) { setLocalAsrModels([]); setSelectedLocalModelId(''); } });
} else if (committedAsrProvider === 'foundry-local-whisper') {
void getFoundryLocalAsrCatalog().then(catalog => {
apply(catalog.map(c => ({ id: c.alias, isDownloaded: c.cached })));
}).catch(() => { if (!cancelled) { setLocalAsrModels([]); setSelectedLocalModelId(''); } });
} else {
setLocalAsrModels([]);
setSelectedLocalModelId('');
}
return () => { cancelled = true; };
}, [committedAsrProvider, prefs?.localAsrActiveModel, prefs?.sherpaOnnxModel, prefs?.foundryLocalAsrModel]);
// 本地引擎(qwen3 / foundry / sherpa)直接作为常规选项放进主下拉(按平台 gating),
// 选项名标注「本地」——选了本地模型供应商,ASR 就用本地模型(与 Apple 语音同理),
// 不再需要单独的启用开关。模型下载与管理在「服务 → 本地模型」的看板里。
const visibleAsrPresets = ASR_PRESETS.filter( const visibleAsrPresets = ASR_PRESETS.filter(
p => p.id !== 'foundry-local-whisper' p => (p.id !== 'foundry-local-whisper' || os === 'win')
&& p.id !== 'local-qwen3' && (p.id !== 'sherpa-onnx-local' || os === 'win')
&& p.id !== 'sherpa-onnx-local' && (p.id !== 'local-qwen3' || os === 'mac')
&& (p.id !== 'apple-speech' || os === 'mac') && (p.id !== 'apple-speech' || os === 'mac')
// 百炼三协议收成一个「阿里云百炼」入口(id=bailian)+ 模型下拉。qwen3 / fun-asr-flash // 百炼三协议收成一个「阿里云百炼」入口(id=bailian)+ 模型下拉。qwen3 / fun-asr-flash
// 两个旧 id 作隐藏别名:新用户下拉里看不到,只有已经停在该 id 上的老用户仍显示, // 两个旧 id 作隐藏别名:新用户下拉里看不到,只有已经停在该 id 上的老用户仍显示,
@@ -421,7 +482,7 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
label: t(`settings.providers.presets.${p.nameKey}`), label: t(`settings.providers.presets.${p.nameKey}`),
}))} }))}
ariaLabel={t('settings.providers.providerLabel')} ariaLabel={t('settings.providers.providerLabel')}
style={{ ...inputStyle, width: '100%', maxWidth: mobile ? '100%' : 200 }} style={{ width: mobile ? '100%' : 200, maxWidth: '100%', minWidth: 0 }}
/> />
</SettingRow> </SettingRow>
{codexOAuthSelected ? ( {codexOAuthSelected ? (
@@ -501,7 +562,9 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
options={[ options={[
...visibleAsrPresets.map(p => ({ ...visibleAsrPresets.map(p => ({
value: p.id, value: p.id,
label: t(`settings.providers.presets.${p.nameKey}`), label: isLocalAsrPreset(p.id)
? `${t(`settings.providers.presets.${p.nameKey}`)}(${t('settings.providers.localTag')})`
: t(`settings.providers.presets.${p.nameKey}`),
})), })),
...(hiddenLocalActive && hiddenLocalNameKey ...(hiddenLocalActive && hiddenLocalNameKey
? [{ ? [{
@@ -511,7 +574,7 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
: []), : []),
]} ]}
ariaLabel={t('settings.providers.providerLabel')} ariaLabel={t('settings.providers.providerLabel')}
style={{ ...inputStyle, width: '100%', maxWidth: mobile ? '100%' : 200 }} style={{ width: mobile ? '100%' : 200, maxWidth: '100%', minWidth: 0 }}
/> />
{hiddenLocalActive && ( {hiddenLocalActive && (
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', lineHeight: 1.5 }}> <div style={{ fontSize: 11, color: 'var(--ol-ink-4)', lineHeight: 1.5 }}>
@@ -522,6 +585,8 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
); );
})()} })()}
</SettingRow> </SettingRow>
{/* 供应商切换时 ASR 板块高度 / 内容会变:keyed 淡入动画平滑过渡(ol-tab-fade)。 */}
<div key={committedAsrProvider} style={{ animation: 'ol-tab-fade 0.22s var(--ol-motion-soft)' }}>
{committedAsrProvider === 'volcengine' ? ( {committedAsrProvider === 'volcengine' ? (
<> <>
<SettingRow label={t('settings.providers.volcengineAuthModeLabel')}> <SettingRow label={t('settings.providers.volcengineAuthModeLabel')}>
@@ -546,7 +611,7 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
{ value: 'api_key', label: t('settings.providers.volcengineAuthModeApiKey') }, { value: 'api_key', label: t('settings.providers.volcengineAuthModeApiKey') },
]} ]}
ariaLabel={t('settings.providers.volcengineAuthModeLabel')} ariaLabel={t('settings.providers.volcengineAuthModeLabel')}
style={{ ...inputStyle, width: '100%', maxWidth: mobile ? '100%' : 260 }} style={{ width: mobile ? '100%' : 260, maxWidth: '100%', minWidth: 0 }}
/> />
</SettingRow> </SettingRow>
{/* 两种模式使用各自独立的凭据槽位:旧版 Access Token(volcengine.access_key) {/* 两种模式使用各自独立的凭据槽位:旧版 Access Token(volcengine.access_key)
@@ -615,10 +680,36 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
</div> </div>
</> </>
) : committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper' || committedAsrProvider === 'sherpa-onnx-local' || committedAsrProvider === 'apple-speech' ? ( ) : committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper' || committedAsrProvider === 'sherpa-onnx-local' || committedAsrProvider === 'apple-speech' ? (
// 用户已经在用本地 ASR——dropdown 行的 asrProviderTakenOver 已经把 // 本地引擎激活:直接展示本地已下载模型(标注「本地下载」),可在此选用,
// "在高级中切换或禁用"讲清楚了,body 不再重复。 // 与「高级 → 本地模型」看板共用同一批模型数据。Apple 语音零模型选择。
// 模型管理 UI 唯一入口在「高级 → 本地模型」里的 <LocalAsr embedded />。 committedAsrProvider === 'apple-speech' ? (
null <div style={{ marginTop: 2, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
{t('settings.providers.appleSpeechLocalNote')}
</div>
) : (
<SettingRow label={t('settings.providers.localModelLabel')}>
<SelectLite
value={selectedLocalModelId}
onChange={(next) => {
setSelectedLocalModelId(next);
if (committedAsrProvider === 'local-qwen3') {
void setLocalAsrActiveModel(next);
} else if (committedAsrProvider === 'sherpa-onnx-local') {
void setSherpaOnnxAsrModel(next);
} else if (committedAsrProvider === 'foundry-local-whisper') {
void setFoundryLocalAsrModel(next);
}
}}
options={localAsrModels.filter(m => m.isDownloaded).map(m => ({
value: m.id,
label: `${m.id}(${t('settings.providers.localTag')})`,
}))}
placeholder={t('settings.providers.localModelEmpty')}
ariaLabel={t('settings.providers.localModelLabel')}
style={{ width: '100%', maxWidth: 320, minWidth: 0 }}
/>
</SettingRow>
)
) : ( ) : (
<> <>
<CredentialField key={`${committedAsrProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="asr.api_key" provider={committedAsrProvider} mono mask /> <CredentialField key={`${committedAsrProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="asr.api_key" provider={committedAsrProvider} mono mask />
@@ -671,6 +762,7 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
)} )}
</> </>
)} )}
</div>
</Card> </Card>
)} )}
</> </>
@@ -945,7 +1037,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, showFetc
options={models.map(model => ({ value: model, label: model }))} options={models.map(model => ({ value: model, label: model }))}
placeholder={t('settings.providers.selectModel')} placeholder={t('settings.providers.selectModel')}
ariaLabel={t('settings.providers.selectModel')} ariaLabel={t('settings.providers.selectModel')}
style={{ ...inputStyle, flex: mobile ? '1 1 100%' : '1 1 180px', maxWidth: mobile ? '100%' : 220 }} style={{ flex: mobile ? '1 1 100%' : '1 1 180px', maxWidth: mobile ? '100%' : 220, minWidth: 0 }}
/> />
)} )}
</div> </div>
@@ -1162,7 +1254,7 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de
placeholder={loaded ? placeholder : t('common.loading')} placeholder={loaded ? placeholder : t('common.loading')}
disabled={disabled} disabled={disabled}
ariaLabel={label} ariaLabel={label}
style={{ ...inputStyle, flex: mobile ? '1 1 180px' : 1, minWidth: 0, maxWidth: '100%', fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }} style={{ flex: mobile ? '1 1 180px' : 1, minWidth: 0, maxWidth: '100%', fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
/> />
) : ( ) : (
<input <input
@@ -192,7 +192,9 @@ export const inputStyle: CSSProperties = {
fontSize: 12.5, fontSize: 12.5,
fontFamily: "inherit", fontFamily: "inherit",
outline: "none", outline: "none",
background: "var(--ol-surface-2)", // 与 SelectLite 触发器同底色:此前用 --ol-surface-2(浅灰)会让所有输入框/
// 下拉与其它设置控件(麦克风/胶囊样式等 select-trigger-bg)颜色不一致。
background: "var(--ol-select-trigger-bg)",
width: "100%", width: "100%",
maxWidth: 360, maxWidth: 360,
transition: transition: