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:
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -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: 'ミラーソース · モデル保存場所 · メモリ内エンジン',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -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: '미러 소스 · 모델 저장 위치 · 메모리 내 엔진',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -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: '镜像源 · 模型存储位置 · 内存引擎',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user