feat(models): 2.0 模型设置页与使用页——目录元数据、使用中模型选择与残留清理

模型设置页:新增 settings/models/LocalModelsSection 替换旧 LocalModelSection,
管理看板条目接入 Core 目录元数据快照(displayName / languages / sizeBytes),
基础信息展示不再依赖实时访问 HuggingFace;详情面板新增「清理未完成下载」
(local_asr_cleanup_incomplete,仅在有 staging 残留时出现)。

模型使用页:渠道编辑器本地引擎分支内嵌 LocalModelPicker——直接切换使用中的
模型(qwen/whisper 切换后预载引擎;sherpa/foundry 走各自 set 接口),显示引擎
可用性与残留清理入口;通过 ServicesTab 提供的 LocalModelsNavContext 一键跳到
「服务 → 本地模型」管理页(复用此前闲置的 localAsrManage 文案键)。

八语文案补齐(activeModelLabel / partialDownloads* / cleanupIncomplete /
languagesLabel / partialBytesLabel 等 7 键)。tsc 0 错误,前端全套通过。
This commit is contained in:
sim committed 2026-09-10 18:17:36 +08:00
1 parent fe95354e25
commit 3b93d7421a
15 files changed
+485 -82

No files matched your search

+9
View File
@@ -2221,6 +2221,15 @@ export const de: typeof zhCN = {
downloading: 'Wird heruntergeladen',
startDownload: 'Download starten',
downloadNewModel: 'Neues Modell herunterladen',
activeModelLabel: 'Aktives Modell',
pickerNoModelDownloaded:
'Noch keine Modelle heruntergeladen — lade sie zuerst auf der Seite „Lokale Modelle“.',
partialDownloadsLabel: 'Unvollständige Downloads',
partialDownloadsDesc:
'Abgebrochene Downloads haben temporäre Dateien hinterlassen; bereinige sie ohne die installierten Modelle zu beeinflussen.',
cleanupIncomplete: 'Unvollständigen Download bereinigen',
languagesLabel: 'Sprachen',
partialBytesLabel: 'Restdateien',
downloadDialogTitle: 'Modell herunterladen',
downloadDialogAlreadyHave:
'Die Modelldateien sind heruntergeladen. Kehre zur Modellseite zurück, um es zu laden und zu testen, oder wähle seinen Anbieter unter „ASR-Transkription“.',
+26 -10
View File
@@ -1748,17 +1748,24 @@ export const en: typeof zhCN = {
regeneratePin: 'Regenerate',
portInUse: 'Port {{port}} is in use, please change it',
startError: 'Failed to start the remote input service: {{reason}}',
securityHint: 'Reachable only on the same LAN and requires the pairing code; turn it off when not in use.',
certHint: "Verify the root certificate fingerprint before trusting it on first use. Older versions require one-time setup; subsequent restarts and IP changes preserve trust.",
securityHint:
'Reachable only on the same LAN and requires the pairing code; turn it off when not in use.',
certHint:
'Verify the root certificate fingerprint before trusting it on first use. Older versions require one-time setup; subsequent restarts and IP changes preserve trust.',
certFingerprintLabel: "This computer's root CA SHA-256",
certFingerprintCopy: "Copy full fingerprint",
certFingerprintCopied: "Fingerprint copied",
certFingerprintUnavailable: "The full fingerprint is unavailable. Do not install or trust a downloaded certificate.",
certVerifyHint: "Find SHA-256 in the phone's system certificate details and compare all 64 characters with this value (ignore spaces and colons) before enabling full trust. A web page, profile name or identifier cannot prove identity. If the fingerprint differs or cannot be viewed in full, stop and remove the downloaded or installed profile.",
certProfileHint: "Expect exactly one root certificate. Do not install a profile containing additional certificates, VPN or device management settings.",
certTrustWarning: "The initial certificate download cannot verify the computer's identity; a malicious device on the LAN could replace the root certificate in a man-in-the-middle attack. Install it only on a trusted home or private network, never on a public or shared network. The root CA can issue certificates and its private key stays on this computer; remove it from your phone when no longer needed.",
certSetupLink: "Copy iPhone certificate link",
waitingStart: 'The service is not running yet. Turn the switch off, then on again. Do not restart the app.',
certFingerprintCopy: 'Copy full fingerprint',
certFingerprintCopied: 'Fingerprint copied',
certFingerprintUnavailable:
'The full fingerprint is unavailable. Do not install or trust a downloaded certificate.',
certVerifyHint:
"Find SHA-256 in the phone's system certificate details and compare all 64 characters with this value (ignore spaces and colons) before enabling full trust. A web page, profile name or identifier cannot prove identity. If the fingerprint differs or cannot be viewed in full, stop and remove the downloaded or installed profile.",
certProfileHint:
'Expect exactly one root certificate. Do not install a profile containing additional certificates, VPN or device management settings.',
certTrustWarning:
"The initial certificate download cannot verify the computer's identity; a malicious device on the LAN could replace the root certificate in a man-in-the-middle attack. Install it only on a trusted home or private network, never on a public or shared network. The root CA can issue certificates and its private key stays on this computer; remove it from your phone when no longer needed.",
certSetupLink: 'Copy iPhone certificate link',
waitingStart:
'The service is not running yet. Turn the switch off, then on again. Do not restart the app.',
starting: 'Starting the remote input service…',
urlsStale: 'These addresses come from the previous run and may be out of date.',
},
@@ -2146,6 +2153,15 @@ export const en: typeof zhCN = {
downloading: 'Downloading',
startDownload: 'Start download',
downloadNewModel: 'Download new model',
activeModelLabel: 'Model in use',
pickerNoModelDownloaded:
'No downloaded models yet — download one on the Local models page first.',
partialDownloadsLabel: 'Incomplete downloads',
partialDownloadsDesc:
'Interrupted downloads left staging files behind; clean them up without affecting installed models.',
cleanupIncomplete: 'Clean up incomplete download',
languagesLabel: 'Languages',
partialBytesLabel: 'Leftover files',
downloadDialogTitle: 'Download Model',
downloadDialogAlreadyHave:
'The model files are downloaded. Return to the model page to load and test, or choose its provider in ASR transcription.',
+9
View File
@@ -2202,6 +2202,15 @@ export const es: typeof zhCN = {
downloading: 'Descargando',
startDownload: 'Iniciar descarga',
downloadNewModel: 'Descargar nuevo modelo',
activeModelLabel: 'Modelo en uso',
pickerNoModelDownloaded:
'Aún no hay modelos descargados; descarga primero uno en la página de modelos locales.',
partialDownloadsLabel: 'Descargas incompletas',
partialDownloadsDesc:
'Las descargas interrumpidas dejaron archivos temporales; límpialos sin afectar a los modelos instalados.',
cleanupIncomplete: 'Limpiar descarga incompleta',
languagesLabel: 'Idiomas',
partialBytesLabel: 'Archivos residuales',
downloadDialogTitle: 'Descargar modelo',
downloadDialogAlreadyHave:
'Los archivos del modelo ya están descargados. Vuelve a su página para cargarlo y probarlo, o elige su proveedor en Transcripción ASR.',
+9
View File
@@ -2231,6 +2231,15 @@ export const fr: typeof zhCN = {
downloading: 'Téléchargement',
startDownload: 'Démarrer le téléchargement',
downloadNewModel: 'Télécharger un nouveau modèle',
activeModelLabel: 'Modèle utilisé',
pickerNoModelDownloaded:
'Aucun modèle téléchargé pour l’instant — commencez par la page des modèles locaux.',
partialDownloadsLabel: 'Téléchargements incomplets',
partialDownloadsDesc:
'Des téléchargements interrompus ont laissé des fichiers temporaires ; nettoyez-les sans toucher aux modèles installés.',
cleanupIncomplete: 'Nettoyer le téléchargement incomplet',
languagesLabel: 'Langues',
partialBytesLabel: 'Fichiers restants',
downloadDialogTitle: 'Télécharger un modèle',
downloadDialogAlreadyHave:
'Les fichiers du modèle sont téléchargés. Revenez à sa page pour le charger et le tester, ou choisissez son fournisseur dans Transcription ASR.',
+27 -11
View File
@@ -1717,17 +1717,24 @@ export const ja: typeof zhCN = {
regeneratePin: '再生成',
portInUse: 'ポート {{port}} は使用中です。変更してください',
startError: 'リモート入力サービスの起動に失敗しました:{{reason}}',
securityHint: '同一 LAN からのみアクセス可能で、ペアリングコードの入力が必要です。使わないときはオフにすることを推奨します。',
certHint: "初回接続ではルート証明書の指紋を確認してから信頼してください。旧バージョンからは一度設定が必要ですが、その後は再起動や IP 変更でも信頼が保持されます。",
certFingerprintLabel: "このコンピューターのルート CA SHA-256",
certFingerprintCopy: "指紋全体をコピー",
certFingerprintCopied: "指紋をコピーしました",
certFingerprintUnavailable: "完全な指紋を取得できません。ダウンロードした証明書をインストールしたり信頼したりしないでください。",
certVerifyHint: "スマートフォンのシステム証明書詳細にある SHA-256 の全 64 文字を、空白とコロンを除いてこの値と照合し、完全に信頼する前に確認してください。Web ページ、プロファイル名や識別子は身元の証明にはなりません。一致しない場合や全体を表示できない場合は中止し、ダウンロード済みまたはインストール済みのプロファイルを削除してください。",
certProfileHint: "プロファイルにはルート証明書が 1 枚だけ含まれるはずです。追加の証明書、VPN、デバイス管理の設定がある場合はインストールしないでください。",
certTrustWarning: "初回の証明書ダウンロードではコンピューターの身元を確認できず、LAN 上の悪意あるデバイスが中間者攻撃でルート証明書を置き換える可能性があります。信頼できる家庭内またはプライベートネットワークでのみインストールし、公共または共有ネットワークでは操作しないでください。ルート CA は証明書を発行でき、秘密鍵はこのコンピューターに保存されます。不要になったらスマートフォンから削除してください。",
certSetupLink: "iPhone 証明書リンクをコピー",
waitingStart: 'サービスはまだ起動していません。スイッチを一度オフにしてからオンにしてください。アプリを再起動しないでください。',
securityHint:
'同一 LAN からのみアクセス可能で、ペアリングコードの入力が必要です。使わないときはオフにすることを推奨します。',
certHint:
'初回接続ではルート証明書の指紋を確認してから信頼してください。旧バージョンからは一度設定が必要ですが、その後は再起動や IP 変更でも信頼が保持されます。',
certFingerprintLabel: 'このコンピューターのルート CA SHA-256',
certFingerprintCopy: '指紋全体をコピー',
certFingerprintCopied: '指紋をコピーしました',
certFingerprintUnavailable:
'完全な指紋を取得できません。ダウンロードした証明書をインストールしたり信頼したりしないでください。',
certVerifyHint:
'スマートフォンのシステム証明書詳細にある SHA-256 の全 64 文字を、空白とコロンを除いてこの値と照合し、完全に信頼する前に確認してください。Web ページ、プロファイル名や識別子は身元の証明にはなりません。一致しない場合や全体を表示できない場合は中止し、ダウンロード済みまたはインストール済みのプロファイルを削除してください。',
certProfileHint:
'プロファイルにはルート証明書が 1 枚だけ含まれるはずです。追加の証明書、VPN、デバイス管理の設定がある場合はインストールしないでください。',
certTrustWarning:
'初回の証明書ダウンロードではコンピューターの身元を確認できず、LAN 上の悪意あるデバイスが中間者攻撃でルート証明書を置き換える可能性があります。信頼できる家庭内またはプライベートネットワークでのみインストールし、公共または共有ネットワークでは操作しないでください。ルート CA は証明書を発行でき、秘密鍵はこのコンピューターに保存されます。不要になったらスマートフォンから削除してください。',
certSetupLink: 'iPhone 証明書リンクをコピー',
waitingStart:
'サービスはまだ起動していません。スイッチを一度オフにしてからオンにしてください。アプリを再起動しないでください。',
starting: 'リモート入力サービスを起動しています…',
urlsStale: 'これらのアドレスは前回の起動時のもので、古くなっている可能性があります。',
},
@@ -2115,6 +2122,15 @@ export const ja: typeof zhCN = {
downloading: 'ダウンロード中',
startDownload: 'ダウンロード開始',
downloadNewModel: '新しいモデルをダウンロード',
activeModelLabel: '使用中のモデル',
pickerNoModelDownloaded:
'ダウンロード済みのモデルがありません。先にローカルモデルページで取得してください。',
partialDownloadsLabel: '未完了のダウンロード',
partialDownloadsDesc:
'中断されたダウンロードの一時ファイルが残っています。インストール済みモデルに影響せず一括削除できます。',
cleanupIncomplete: '未完了ダウンロードを削除',
languagesLabel: '言語',
partialBytesLabel: '残存ファイル',
downloadDialogTitle: 'モデルをダウンロード',
downloadDialogAlreadyHave:
'モデルファイルはダウンロード済みです。モデルページで読み込みとテストを行うか、「ASR 音声文字起こし」で対応するプロバイダーを選択してください。',
+27 -11
View File
@@ -1705,17 +1705,24 @@ export const ko: typeof zhCN = {
regeneratePin: '재생성',
portInUse: '포트 {{port}}이(가) 사용 중입니다. 변경하세요',
startError: '원격 입력 서비스 시작에 실패했습니다: {{reason}}',
securityHint: '같은 LAN에서만 접속 가능하며 페어링 코드 입력이 필요합니다. 사용하지 않을 때는 끄는 것을 권장합니다.',
certHint: "처음 연결할 때 루트 인증서 지문을 확인한 후 신뢰하세요. 이전 버전에서는 한 번 설정해야 하며, 이후 재시작과 IP 변경 시 신뢰가 유지됩니다.",
certFingerprintLabel: "이 컴퓨터의 루트 CA SHA-256",
certFingerprintCopy: "전체 지문 복사",
certFingerprintCopied: "지문 복사됨",
certFingerprintUnavailable: "전체 지문을 확인할 수 없습니다. 다운로드한 인증서를 설치하거나 신뢰하지 마세요.",
certVerifyHint: "휴대폰 시스템의 인증서 상세 정보에서 SHA-256을 찾아, 완전한 신뢰를 켜기 전에 공백과 콜론을 제외한 64자 전체를 이 값과 비교하세요. 웹 페이지, 프로파일 이름이나 식별자는 신원 증명이 아닙니다. 일치하지 않거나 전체 지문을 볼 수 없으면 중단하고 다운로드했거나 설치한 프로파일을 제거하세요.",
certProfileHint: "프로파일에는 루트 인증서 한 개만 있어야 합니다. 추가 인증서, VPN 또는 기기 관리 설정이 있으면 설치하지 마세요.",
certTrustWarning: "최초 인증서 다운로드에서는 컴퓨터의 신원을 확인할 수 없으며, LAN의 악성 기기가 중간자 공격으로 루트 인증서를 바꿀 수 있습니다. 신뢰할 수 있는 가정용 또는 사설 네트워크에서만 설치하고 공용 또는 공유 네트워크에서는 진행하지 마세요. 루트 CA는 인증서를 발급할 수 있고 개인 키는 이 컴퓨터에 저장됩니다. 더 이상 사용하지 않으면 휴대폰에서 제거하세요.",
certSetupLink: "iPhone 인증서 링크 복사",
waitingStart: '서비스가 아직 시작되지 않았습니다. 스위치를 끈 다음 다시 켜세요. 앱을 다시 시작하지 마세요.',
securityHint:
'같은 LAN에서만 접속 가능하며 페어링 코드 입력이 필요합니다. 사용하지 않을 때는 끄는 것을 권장합니다.',
certHint:
'처음 연결할 때 루트 인증서 지문을 확인한 후 신뢰하세요. 이전 버전에서는 한 번 설정해야 하며, 이후 재시작과 IP 변경 시 신뢰가 유지됩니다.',
certFingerprintLabel: '이 컴퓨터의 루트 CA SHA-256',
certFingerprintCopy: '전체 지문 복사',
certFingerprintCopied: '지문 복사됨',
certFingerprintUnavailable:
'전체 지문을 확인할 수 없습니다. 다운로드한 인증서를 설치하거나 신뢰하지 마세요.',
certVerifyHint:
'휴대폰 시스템의 인증서 상세 정보에서 SHA-256을 찾아, 완전한 신뢰를 켜기 전에 공백과 콜론을 제외한 64자 전체를 이 값과 비교하세요. 웹 페이지, 프로파일 이름이나 식별자는 신원 증명이 아닙니다. 일치하지 않거나 전체 지문을 볼 수 없으면 중단하고 다운로드했거나 설치한 프로파일을 제거하세요.',
certProfileHint:
'프로파일에는 루트 인증서 한 개만 있어야 합니다. 추가 인증서, VPN 또는 기기 관리 설정이 있으면 설치하지 마세요.',
certTrustWarning:
'최초 인증서 다운로드에서는 컴퓨터의 신원을 확인할 수 없으며, LAN의 악성 기기가 중간자 공격으로 루트 인증서를 바꿀 수 있습니다. 신뢰할 수 있는 가정용 또는 사설 네트워크에서만 설치하고 공용 또는 공유 네트워크에서는 진행하지 마세요. 루트 CA는 인증서를 발급할 수 있고 개인 키는 이 컴퓨터에 저장됩니다. 더 이상 사용하지 않으면 휴대폰에서 제거하세요.',
certSetupLink: 'iPhone 인증서 링크 복사',
waitingStart:
'서비스가 아직 시작되지 않았습니다. 스위치를 끈 다음 다시 켜세요. 앱을 다시 시작하지 마세요.',
starting: '원격 입력 서비스를 시작하는 중입니다…',
urlsStale: '이 주소는 이전 실행에서 가져온 것으로 최신이 아닐 수 있습니다.',
},
@@ -2098,6 +2105,15 @@ export const ko: typeof zhCN = {
downloading: '다운로드 중',
startDownload: '다운로드 시작',
downloadNewModel: '새 모델 다운로드',
activeModelLabel: '사용 중인 모델',
pickerNoModelDownloaded:
'다운로드된 모델이 아직 없습니다. 로컬 모델 페이지에서 먼저 내려받으세요.',
partialDownloadsLabel: '완료되지 않은 다운로드',
partialDownloadsDesc:
'중단된 다운로드의 임시 파일이 남아 있습니다. 설치된 모델에 영향 없이 정리할 수 있습니다.',
cleanupIncomplete: '미완료 다운로드 정리',
languagesLabel: '언어',
partialBytesLabel: '남은 파일',
downloadDialogTitle: '모델 다운로드',
downloadDialogAlreadyHave:
'모델 파일이 다운로드되었습니다. 모델 페이지에서 불러와 테스트하거나 ASR 음성 전사에서 해당 제공업체를 선택하세요.',
+19 -9
View File
@@ -1651,15 +1651,18 @@ export const zhCN = {
portInUse: '端口 {{port}} 被占用,请更换',
startError: '远程输入服务启动失败:{{reason}}',
securityHint: '仅同一局域网可访问,需输入配对码;不用时建议关闭。',
certHint: "首次连接需核对根证书指纹后再信任。升级旧版需设置一次;以后重启和换 IP 会保留信任。",
certFingerprintLabel: "本机根证书 SHA-256",
certFingerprintCopy: "复制完整指纹",
certFingerprintCopied: "指纹已复制",
certFingerprintUnavailable: "完整指纹不可用。请勿安装或信任下载的证书。",
certVerifyHint: "在手机系统的证书详情中找到 SHA-256,与这里的全部 64 个字符逐一核对(忽略空格和冒号)。必须在开启完全信任前完成。网页、描述文件名称和标识不能证明证书身份;若不一致或无法查看完整指纹,请停止并移除已下载或安装的描述文件。",
certProfileHint: "描述文件应只包含一张根证书。若有其他证书、VPN 或设备管理配置,请勿安装。",
certTrustWarning: "首次证书下载无法验证电脑身份,恶意局域网设备可能通过中间人攻击替换根证书。仅在可信的家庭或私人网络中安装,勿在公共或共享网络操作。根证书具备签发能力,私钥保存在这台电脑;不再使用时请从手机移除。",
certSetupLink: "复制 iPhone 证书链接",
certHint:
'首次连接需核对根证书指纹后再信任。升级旧版需设置一次;以后重启和换 IP 会保留信任。',
certFingerprintLabel: '本机根证书 SHA-256',
certFingerprintCopy: '复制完整指纹',
certFingerprintCopied: '指纹已复制',
certFingerprintUnavailable: '完整指纹不可用。请勿安装或信任下载的证书。',
certVerifyHint:
'在手机系统的证书详情中找到 SHA-256,与这里的全部 64 个字符逐一核对(忽略空格和冒号)。必须在开启完全信任前完成。网页、描述文件名称和标识不能证明证书身份;若不一致或无法查看完整指纹,请停止并移除已下载或安装的描述文件。',
certProfileHint: '描述文件应只包含一张根证书。若有其他证书、VPN 或设备管理配置,请勿安装。',
certTrustWarning:
'首次证书下载无法验证电脑身份,恶意局域网设备可能通过中间人攻击替换根证书。仅在可信的家庭或私人网络中安装,勿在公共或共享网络操作。根证书具备签发能力,私钥保存在这台电脑;不再使用时请从手机移除。',
certSetupLink: '复制 iPhone 证书链接',
waitingStart: '服务尚未启动。请关闭开关再打开一次,不要重启软件。',
starting: '正在启动远程输入服务…',
urlsStale: '这些地址来自上次运行,可能已经过期。',
@@ -2031,6 +2034,13 @@ export const zhCN = {
downloading: '下载中',
startDownload: '开始下载',
downloadNewModel: '下载新模型',
activeModelLabel: '使用中的模型',
pickerNoModelDownloaded: '还没有已下载的模型,请先在本地模型页下载。',
partialDownloadsLabel: '未完成下载',
partialDownloadsDesc: '存在中断下载的临时残留,可一键清理,不影响已安装模型。',
cleanupIncomplete: '清理未完成下载',
languagesLabel: '语言',
partialBytesLabel: '残留文件',
downloadDialogTitle: '下载模型',
downloadDialogAlreadyHave:
'模型文件已下载。可回到模型页加载并测试,或在「ASR 语音转写」中选择对应供应商。',
+19 -9
View File
@@ -1637,15 +1637,18 @@ export const zhTW: typeof zhCN = {
portInUse: '連接埠 {{port}} 被佔用,請更換',
startError: '遠端輸入服務啟動失敗:{{reason}}',
securityHint: '僅同一區域網路可存取,需輸入配對碼;不用時建議關閉。',
certHint: "首次連線需核對根憑證指紋後再信任。升級舊版需設定一次;之後重新啟動和更換 IP 會保留信任。",
certFingerprintLabel: "本機根憑證 SHA-256",
certFingerprintCopy: "複製完整指紋",
certFingerprintCopied: "已複製指紋",
certFingerprintUnavailable: "完整指紋無法取得。請勿安裝或信任下載的憑證。",
certVerifyHint: "在手機系統的憑證詳細資訊中找到 SHA-256,與此處全部 64 個字元逐一核對(忽略空格和冒號)。必須在開啟完全信任前完成。網頁、描述檔名稱與識別碼不能證明憑證身分;若不一致或無法查看完整指紋,請停止並移除已下載或安裝的描述檔。",
certProfileHint: "描述檔應只包含一張根憑證。若有其他憑證、VPN 或裝置管理設定,請勿安裝。",
certTrustWarning: "首次憑證下載無法驗證電腦身分,惡意區域網路裝置可能透過中間人攻擊替換根憑證。僅在可信任的家庭或私人網路中安裝,請勿在公共或共享網路操作。根憑證能簽發憑證,私密金鑰保存在這台電腦;不再使用時請從手機移除。",
certSetupLink: "複製 iPhone 憑證連結",
certHint:
'首次連線需核對根憑證指紋後再信任。升級舊版需設定一次;之後重新啟動和更換 IP 會保留信任。',
certFingerprintLabel: '本機根憑證 SHA-256',
certFingerprintCopy: '複製完整指紋',
certFingerprintCopied: '已複製指紋',
certFingerprintUnavailable: '完整指紋無法取得。請勿安裝或信任下載的憑證。',
certVerifyHint:
'在手機系統的憑證詳細資訊中找到 SHA-256,與此處全部 64 個字元逐一核對(忽略空格和冒號)。必須在開啟完全信任前完成。網頁、描述檔名稱與識別碼不能證明憑證身分;若不一致或無法查看完整指紋,請停止並移除已下載或安裝的描述檔。',
certProfileHint: '描述檔應只包含一張根憑證。若有其他憑證、VPN 或裝置管理設定,請勿安裝。',
certTrustWarning:
'首次憑證下載無法驗證電腦身分,惡意區域網路裝置可能透過中間人攻擊替換根憑證。僅在可信任的家庭或私人網路中安裝,請勿在公共或共享網路操作。根憑證能簽發憑證,私密金鑰保存在這台電腦;不再使用時請從手機移除。',
certSetupLink: '複製 iPhone 憑證連結',
waitingStart: '服務尚未啟動。請關閉開關再打開一次,不要重啟軟體。',
starting: '正在啟動遠端輸入服務…',
urlsStale: '這些地址來自上次執行,可能已經過期。',
@@ -2017,6 +2020,13 @@ export const zhTW: typeof zhCN = {
downloading: '下載中',
startDownload: '開始下載',
downloadNewModel: '下載新模型',
activeModelLabel: '使用中的模型',
pickerNoModelDownloaded: '尚無已下載的模型,請先在本機模型頁下載。',
partialDownloadsLabel: '未完成下載',
partialDownloadsDesc: '存在中斷下載的暫存殘留,可一鍵清理,不影響已安裝模型。',
cleanupIncomplete: '清理未完成下載',
languagesLabel: '語言',
partialBytesLabel: '殘留檔案',
downloadDialogTitle: '下載模型',
downloadDialogAlreadyHave:
'模型檔案已下載。可返回模型頁載入並測試,或在「ASR 語音轉寫」中選擇對應供應商。',
@@ -474,6 +474,14 @@ export interface SidebarModelEntry {
id: string;
/** 展示名(如 qwen3-asr-0.6b / whisper-small)。 */
name: string;
/** 目录快照的正式展示名(Core descriptor);无则回退 name。 */
displayName?: string;
/** 目录快照的支持语言短码(zh / en / ja…)。 */
languages?: string[];
/** 目录快照的远端完整尺寸;无需实时访问 HuggingFace 即可展示。 */
sizeBytes?: number;
/** 有已下载字节但未装好 = 中断残留,可一键清理 staging 目录。 */
partialBytes?: number;
/** HF 仓库标识(Qwen3 有;sherpa/foundry 可能为空)。 */
repo?: string;
/** 已下载字节数(HF 拉取的真实尺寸)。 */
@@ -532,11 +540,14 @@ function ModelChoice({
const { t } = useTranslation();
return (
<button type="button" className="ol-model-choice" aria-pressed={selected} onClick={onSelect}>
<span className="ol-model-choice-name">{entry.name}</span>
<span className="ol-model-choice-name">{entry.displayName || entry.name}</span>
<span className="ol-model-choice-meta">
<span>{ENGINE_LABELS[entry.engine]}</span>
{entry.languages?.length ? <span>{entry.languages.join(' / ')}</span> : null}
<span>
{entry.remoteBytes ? formatBytes(entry.remoteBytes) : t('localAsr.sizeUnknown')}
{entry.remoteBytes || entry.sizeBytes
? formatBytes(entry.remoteBytes ?? entry.sizeBytes!)
: t('localAsr.sizeUnknown')}
</span>
</span>
<span className="ol-model-choice-state">
@@ -605,8 +616,24 @@ function ModelFacts({
</div>
<div>
<dt>{t('localAsr.sizeLabel')}</dt>
<dd>{entry.remoteBytes ? formatBytes(entry.remoteBytes) : t('localAsr.sizeUnknown')}</dd>
<dd>
{entry.remoteBytes || entry.sizeBytes
? formatBytes(entry.remoteBytes ?? entry.sizeBytes!)
: t('localAsr.sizeUnknown')}
</dd>
</div>
{entry.languages?.length ? (
<div>
<dt>{t('localAsr.languagesLabel')}</dt>
<dd>{entry.languages.join(' / ')}</dd>
</div>
) : null}
{entry.partialBytes ? (
<div>
<dt>{t('localAsr.partialBytesLabel')}</dt>
<dd>{formatBytes(entry.partialBytes)}</dd>
</div>
) : null}
{fileCount != null && fileCount > 0 && (
<div>
<dt>{t('localAsr.files')}</dt>
@@ -641,6 +668,7 @@ export function ModelDetailPanel({
onDelete,
onReveal,
onTest,
onCleanup,
showTest,
testResult,
testing,
@@ -655,6 +683,8 @@ export function ModelDetailPanel({
onDelete: () => void;
onReveal: () => void;
onTest: () => void;
/** 清理中断下载的 staging 目录(仅存在残留且未在下载时出现)。 */
onCleanup?: () => void;
showTest: boolean;
testResult: LocalAsrTestResult | { error: string } | null;
testing: boolean;
@@ -665,7 +695,7 @@ export function ModelDetailPanel({
<div className="ol-model-detail">
<div className="ol-model-detail-heading">
<span className="ol-model-eyebrow">{t('localAsr.detailsTitle')}</span>
<h3>{entry.name}</h3>
<h3>{entry.displayName || entry.name}</h3>
<ModelStatus entry={entry} />
</div>
<ModelFacts entry={entry} fileCount={fileCount} mirrorLabel={mirrorLabel} />
@@ -685,6 +715,11 @@ export function ModelDetailPanel({
{entry.downloadError ? t('common.retry') : t('localAsr.download')}
</Btn>
)}
{!entry.isDownloaded && !entry.isDownloading && entry.partialBytes && onCleanup && (
<Btn variant="ghost" disabled={busy} onClick={onCleanup}>
{t('localAsr.cleanupIncomplete')}
</Btn>
)}
{entry.isDownloading && (
<Btn variant="ghost" onClick={onCancel}>
{t('localAsr.cancel')}
@@ -11,6 +11,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode }
import { useTranslation } from 'react-i18next';
import { restartApp } from '../../lib/ipc/permissions';
import { isTauri } from '../../lib/ipc';
import { emitSaved } from '../../lib/savedEvent';
import { useLayoutStack } from '../../lib/useMobileLayout';
import {
FOUNDRY_LOCAL_ASR_MODELS,
@@ -20,6 +21,7 @@ import {
cancelSherpaOnnxAsrDownload,
cancelSherpaOnnxAsrPrepare,
cancelLocalAsrDownload,
cleanupIncompleteLocalAsrModel,
deleteFoundryLocalAsrModel,
deleteSherpaOnnxAsrModel,
deleteLocalAsrModel,
@@ -1366,6 +1368,21 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}
};
// 清理中断下载的 staging 目录;已安装模型不受影响(后端保证)。
const handleCleanupIncomplete = async (modelId: string) => {
setBusyModelId(modelId);
try {
setError(null);
await cleanupIncompleteLocalAsrModel(modelId);
emitSaved('saved', t('common.saved'));
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusyModelId(null);
}
};
const handleKeepLoadedChange = async (seconds: number) => {
try {
await setLocalAsrKeepLoadedSecs(seconds);
@@ -1619,6 +1636,14 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
entries.push({
id: m.id,
name: m.id.replace(/^qwen3-asr-/, 'Qwen3-ASR ').replace(/^whisper-/, 'Whisper '),
// 目录元数据来自 Core descriptor 快照,展示不再依赖实时 HuggingFace。
displayName: m.displayName || undefined,
languages: m.languages?.length ? m.languages : undefined,
sizeBytes: m.sizeBytes ?? undefined,
partialBytes:
!m.isDownloaded && !isDownloading && m.downloadedBytes > 0
? m.downloadedBytes
: undefined,
repo: m.hfRepo,
remoteBytes:
remoteSizes[m.id]?.totalBytes || (m.isDownloaded ? m.downloadedBytes : undefined),
@@ -1650,6 +1675,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
entries.push({
id: c.alias,
name: c.displayName || c.alias,
displayName: c.displayName || undefined,
sizeBytes: c.fileSizeMb != null ? c.fileSizeMb * 1024 * 1024 : undefined,
remoteBytes:
sherpaRemoteSizes[c.alias]?.totalBytes ||
(c.fileSizeMb != null ? c.fileSizeMb * 1024 * 1024 : undefined),
@@ -1682,6 +1709,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
entries.push({
id: c.alias,
name: c.displayName || c.alias,
displayName: c.displayName || undefined,
sizeBytes: c.fileSizeMb != null ? c.fileSizeMb * 1024 * 1024 : undefined,
remoteBytes: c.fileSizeMb != null ? c.fileSizeMb * 1024 * 1024 : undefined,
isDownloaded: c.cached,
isDownloading,
@@ -1928,6 +1957,13 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}}
onDelete={() => selectedEntry && dispatchEntryAction(selectedEntry, 'delete')}
onReveal={() => selectedEntry && dispatchEntryAction(selectedEntry, 'reveal')}
onCleanup={
selectedEntry &&
(selectedEntry.engine === 'qwen3' || selectedEntry.engine === 'whisper') &&
selectedEntry.partialBytes
? () => void handleCleanupIncomplete(selectedEntry.id)
: undefined
}
onTest={() => {
if (
selectedEntry?.engine === 'qwen3' ||
@@ -26,6 +26,7 @@ import {
} from '../../lib/ipc';
import { LlmProtocolFields } from './LlmProtocolFields';
import { ProviderFormContext } from './ProviderForm';
import { LocalModelPicker } from './models/LocalModelPicker';
import { emitSaved } from '../../lib/savedEvent';
import { useLayoutStack, useConservativeLayout } from '../../lib/useMobileLayout';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
@@ -588,10 +589,15 @@ export function ChannelCredentialFields({
// 本地引擎(qwen3 / sherpa / foundry / Apple 语音)没有 key 与地址;模型的下载与
// 切换仍由「高级 → 本地模型」里的 <LocalAsr embedded /> 负责,这里只说明一句。
if (descriptor?.authRequirement === 'none') {
// 本地引擎:模型即凭据。这里直接切换使用中的模型(全局生效),
// 下载 / 删除 / 镜像在「服务 → 本地模型」管理页。
return (
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
{t('settings.providers.localEngineNoCredentials')}
</div>
<>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
{t('settings.providers.localEngineNoCredentials')}
</div>
<LocalModelPicker providerType={providerType} />
</>
);
}
@@ -0,0 +1,213 @@
// 模型使用页:渠道编辑器「本地引擎」分支的内嵌模型选择器。
// 全局 active model 与引擎状态来自本地 ASR IPC——本地模型不按渠道隔离,
// 这里切的是全局使用中的模型;下载 / 删除 / 镜像等管理动作仍在
// 「服务 → 本地模型」(useLocalModelsNav 跳转)。
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SelectLite } from '../../../components/ui/SelectLite';
import { Btn } from '../../_atoms';
import { SettingRow, inputStyle } from '../shared';
import {
cleanupIncompleteLocalAsrModel,
getFoundryLocalAsrCatalog,
getFoundryLocalAsrStatus,
getLocalAsrSettings,
getSherpaOnnxAsrCatalog,
getSherpaOnnxAsrStatus,
listLocalAsrModels,
preloadLocalAsr,
setFoundryLocalAsrModel,
setLocalAsrActiveModel,
setSherpaOnnxAsrModel,
type FoundryLocalAsrCatalogModel,
type LocalAsrModelStatus,
type SherpaOnnxCatalogModel,
} from '../../../lib/localAsr';
import { emitSaved } from '../../../lib/savedEvent';
import { useLocalModelsNav } from './modelsNav';
/** 本地引擎 providerType → 选择器数据源家族。 */
function familyOfProviderType(providerType: string): 'generic' | 'sherpa' | 'foundry' | 'apple' {
if (providerType === 'sherpa-onnx-local') return 'sherpa';
if (providerType === 'foundry-local-whisper') return 'foundry';
if (providerType === 'apple-speech') return 'apple';
return 'generic';
}
interface ModelOption {
value: string;
label: string;
downloaded: boolean;
/** 有已下载字节但未装好 = 中断残留,可一键清理。 */
partialBytes?: number;
}
export function LocalModelPicker({ providerType }: { providerType: string }) {
const { t } = useTranslation();
const openModels = useLocalModelsNav();
const family = familyOfProviderType(providerType);
const [options, setOptions] = useState<ModelOption[] | null>(null);
const [activeModel, setActiveModel] = useState('');
const [engineNote, setEngineNote] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
if (family === 'generic') {
const [settings, models] = await Promise.all([getLocalAsrSettings(), listLocalAsrModels()]);
setOptions(
models.map((m: LocalAsrModelStatus) => ({
value: m.id,
label: m.displayName || m.id,
downloaded: m.isDownloaded,
partialBytes: !m.isDownloaded && m.downloadedBytes > 0 ? m.downloadedBytes : undefined,
})),
);
setActiveModel(settings.activeModel);
setEngineNote(settings.engineAvailable ? null : t('localAsr.engineUnavailable'));
} else if (family === 'sherpa') {
const [status, catalog] = await Promise.all([
getSherpaOnnxAsrStatus(),
getSherpaOnnxAsrCatalog(),
]);
setOptions(
catalog.map((c: SherpaOnnxCatalogModel) => ({
value: c.alias,
label: c.displayName || c.alias,
downloaded: c.cached,
})),
);
setActiveModel(status.activeModel);
setEngineNote(status.available ? null : (status.error ?? t('localAsr.engineUnavailable')));
} else if (family === 'foundry') {
const [status, catalog] = await Promise.all([
getFoundryLocalAsrStatus(),
getFoundryLocalAsrCatalog(),
]);
setOptions(
catalog.map((c: FoundryLocalAsrCatalogModel) => ({
value: c.alias,
label: c.displayName || c.alias,
downloaded: c.cached,
})),
);
setActiveModel(status.activeModel);
setEngineNote(status.available ? null : (status.error ?? t('localAsr.engineUnavailable')));
}
} catch (error) {
console.error('[local-models] failed to load picker state', error);
setEngineNote(t('settings.providers.readFailed'));
}
}, [family, t]);
useEffect(() => {
void load();
}, [load]);
const partials = useMemo(
() => (options ?? []).filter((option) => option.partialBytes),
[options],
);
const changeModel = async (value: string) => {
setBusy(true);
try {
if (family === 'generic') {
await setLocalAsrActiveModel(value);
await preloadLocalAsr().catch(() => undefined);
} else if (family === 'sherpa') {
await setSherpaOnnxAsrModel(value);
} else if (family === 'foundry') {
await setFoundryLocalAsrModel(value);
}
emitSaved('saved', t('common.saved'));
await load();
} catch (error) {
console.error('[local-models] failed to switch active model', error);
emitSaved('failed', t('common.operationFailed'));
} finally {
setBusy(false);
}
};
const cleanupPartial = async (modelId: string) => {
setBusy(true);
try {
await cleanupIncompleteLocalAsrModel(modelId);
emitSaved('saved', t('common.saved'));
await load();
} catch (error) {
console.error('[local-models] failed to cleanup incomplete download', error);
emitSaved('failed', t('common.operationFailed'));
} finally {
setBusy(false);
}
};
if (family === 'apple') {
return (
<p className="ol-channel-local-hint">{t('settings.providers.localEngineNoCredentials')}</p>
);
}
const downloadedCount = (options ?? []).filter((option) => option.downloaded).length;
return (
<>
<SettingRow label={t('localAsr.activeModelLabel')}>
{options === null ? (
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>{t('common.loading')}</span>
) : downloadedCount === 0 ? (
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>
{t('localAsr.pickerNoModelDownloaded')}
</span>
) : (
<SelectLite
value={activeModel}
disabled={busy}
onChange={(value) => void changeModel(value)}
options={(options ?? [])
.filter((option) => option.downloaded)
.map((option) => ({ value: option.value, label: option.label }))}
ariaLabel={t('localAsr.activeModelLabel')}
style={{ ...inputStyle, width: '100%', maxWidth: '100%', height: 38 }}
/>
)}
</SettingRow>
{partials.length > 0 && (
<SettingRow
label={t('localAsr.partialDownloadsLabel')}
desc={t('localAsr.partialDownloadsDesc')}
>
<Btn
variant="ghost"
size="sm"
disabled={busy}
onClick={() => {
for (const option of partials) void cleanupPartial(option.value);
}}
>
{t('localAsr.cleanupIncomplete')}
</Btn>
</SettingRow>
)}
{engineNote && (
<p role="alert" style={{ fontSize: 12, color: 'var(--ol-warn)' }}>
{engineNote}
</p>
)}
{openModels && (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 2 }}>
<Btn variant="ghost" size="sm" onClick={openModels}>
{t('settings.providers.localAsrManage')}
</Btn>
</div>
)}
</>
);
}
@@ -1,20 +1,21 @@
// 服务 → 本地模型:本地 ASR 模型的管理入口。含 Qwen3(macOS)/ Foundry Local +
// sherpa-onnx(Windows)三条本地引擎。
// 模型设置页(2.0):本地 ASR 模型的下载与管理看板。
// 含 Qwen3(macOS)/ Foundry Local + sherpa-onnx(Windows)三条本地引擎。
//
// 2026-08 重构:本地 ASR 不再有「启用开关」——本地 ASR 始终可用,是否激活由
// 「服务 → AI 提供商」的 ASR 语音转写供应商决定:选到本地模型供应商即使用本地
// 引擎(与 Apple 语音同理)。这里只保留模型下载 / 管理看板(<LocalAsr embedded />)。
// 本地 ASR 不再有「启用开关」——是否激活由「服务 → AI 提供商」的 ASR
// 语音转写供应商决定:选到本地模型供应商即使用本地引擎(与 Apple 语音同理)。
// 这里是模型目录与管理(下载 / 删除 / 清理残留 / 测试 / 镜像源);
// 使用中的模型切换在渠道编辑器的 LocalModelPicker。
import { useEffect, useState } from 'react';
import type { PlatformCapabilities } from '../../lib/types';
import type { PlatformCapabilities } from '../../../lib/types';
import { useTranslation } from 'react-i18next';
import { LocalAsr } from '../LocalAsr';
import { detectOS } from '../../components/WindowChrome';
import { getPlatformCapabilities } from '../../lib/platform';
import { Card } from '../_atoms';
import { ExperimentalSectionTitle } from './shared';
import { LocalAsr } from '../../LocalAsr';
import { detectOS } from '../../../components/WindowChrome';
import { getPlatformCapabilities } from '../../../lib/platform';
import { Card } from '../../_atoms';
import { ExperimentalSectionTitle } from '../shared';
export function LocalModelSection() {
export function LocalModelsSection() {
const { t } = useTranslation();
const os = detectOS();
const isWin = os === 'win';
@@ -68,8 +69,9 @@ export function LocalModelSection() {
{t('settings.advanced.platformNotSupported')}
</div>
) : (
/* 模型下载 / 管理看板(模型选择 · 下载 · 删除 · 测试 · 镜像源收纳)——
较大的内嵌框。启用入口在「AI 提供商 → ASR 语音转写」选择本地供应商。 */
/* 模型目录 / 管理看板(模型选择 · 下载 · 清理 · 删除 · 测试 · 镜像源)。
使用入口在「AI 提供商 → ASR 语音转写」选择本地供应商后,
由渠道编辑器内的 LocalModelPicker 切换使用中的模型。 */
<div style={{ marginTop: 16, borderTop: '0.5px solid var(--ol-line)', paddingTop: 16 }}>
<LocalAsr embedded />
</div>
@@ -0,0 +1,12 @@
import { createContext, useContext } from 'react';
/**
* 跳转到「服务 → 本地模型」视图的回调。由拥有视图状态的 ServicesTab 提供,
* 渠道编辑器等深层界面通过它把用户带到模型管理页;取不到时(如独立测试渲染)
* 调用方应退化为隐藏跳转入口。
*/
export const LocalModelsNavContext = createContext<(() => void) | null>(null);
export function useLocalModelsNav(): (() => void) | null {
return useContext(LocalModelsNavContext);
}
+15 -11
View File
@@ -16,7 +16,8 @@ import { MarketplaceSection } from './MarketplaceSection';
import { PermissionsSection } from './PermissionsSection';
import { DataStorageSection } from './DataStorageSection';
import { CloudSyncSection } from './CloudSyncSection';
import { LocalModelSection } from './LocalModelSection';
import { LocalModelsSection } from './models/LocalModelsSection';
import { LocalModelsNavContext } from './models/modelsNav';
import { DebugToolsSection } from './DebugToolsSection';
import { MultimodalPipelineSection } from './MultimodalPipelineSection';
import { CodingAgentSection } from './CodingAgentSection';
@@ -163,16 +164,19 @@ export function ServicesTab() {
})}
</div>
<div key={selectedView} ref={contentRef} className="ol-service-content">
{selectedView === 'llm' && <ProvidersSection kind="llm" />}
{selectedView === 'asr' && <ProvidersSection kind="asr" />}
{selectedView === 'omni' && <ProvidersSection />}
{selectedView === 'models' && <LocalModelSection />}
{selectedView === 'connections' && (
<>
<NetworkSection />
<MarketplaceSection />
</>
)}
{/* 渠道编辑器里的 LocalModelPicker 通过该上下文跳到本视图。 */}
<LocalModelsNavContext.Provider value={() => setView('models')}>
{selectedView === 'llm' && <ProvidersSection kind="llm" />}
{selectedView === 'asr' && <ProvidersSection kind="asr" />}
{selectedView === 'omni' && <ProvidersSection />}
{selectedView === 'models' && <LocalModelsSection />}
{selectedView === 'connections' && (
<>
<NetworkSection />
<MarketplaceSection />
</>
)}
</LocalModelsNavContext.Provider>
{(selectedView === 'llm' || selectedView === 'asr') && (
<p className="ol-service-storage-note">
{t('settings.providers.credentialStorageNotice')}