fix(local-asr): 下载进度全局浮层 + 事件节流 + 取消清理 + 看板过滤 + 3s 文件监测 (#925)
- 进度浮层全局化:新 GlobalDownloadProgress 自监听三引擎下载事件,挂 App 根, 主窗口所有页面常驻显示;含取消按钮(按引擎分派 cancel 命令) - Rust 进度事件 ≥150ms 节流(qwen3/sherpa/foundry),修进度条高频抽搐 - 取消下载清理 .partial/.partial.idx(sparse 预分配全长,1.7B 模型残留巨大), 新增 remove_partial_artifacts 单测 - 看板只列已下载/下载中的模型(下载弹窗保留全目录列表),条目行距加大 - 3s 轮询磁盘状态 + 选中瞬间校验 + 下载事件驱动刷新;fetchAll 按平台分支 (macOS 上 sherpa/foundry catalog 命令未注册导致 Promise.all 整体失败) - 同 provider 切换模型走轻量路径 + 乐观 draft 值,修下拉闪回旧模型 Co-authored-by: sim <sim@local>
This commit is contained in:
1 parent
cd33c89089
commit
df1a42c3a9
8 files changed
+563
-144
No files matched your search
@@ -23,6 +23,20 @@ use tokio::io::{AsyncSeekExt, AsyncWriteExt};
|
||||
|
||||
use super::models::{model_dir, ModelId, READY_SENTINEL};
|
||||
|
||||
/// 进度事件最小发射间隔(毫秒)。HTTP 每 chunk 回调一次 on_progress,若全量
|
||||
/// 转发,前端每秒收到上百个 IPC 事件、进度条高频刷新会「抽搐」(issue 见
|
||||
/// LocalAsr 下载浮层)。按 ≥150ms 节流后肉眼平滑(约 6-7 次/秒),首条进度
|
||||
/// 与 phase 事件(started/finished/cancelled/failed)不受此限。
|
||||
pub(crate) const PROGRESS_EMIT_MIN_INTERVAL_MS: u64 = 150;
|
||||
|
||||
/// 当前 Unix 毫秒时间戳(进度节流用)。
|
||||
pub(crate) fn now_millis() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 下载源镜像。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
@@ -245,8 +259,20 @@ pub(crate) fn build_client() -> Result<reqwest::Client> {
|
||||
builder.build().context("build reqwest client failed")
|
||||
}
|
||||
|
||||
/// 判定一个「已存在」的目标文件是否完整可信,纯函数便于单测(#686)。
|
||||
/// - 大小一致 → 完整;
|
||||
/// 用户主动取消下载后,清理断点续传产物(`<file>.partial` sparse 文件 +
|
||||
/// `<file>.partial.idx` 块索引)。`.partial` 按 `set_len` 预分配了目标全长
|
||||
/// —— 1.7B 模型即使只下了 1% 也占 1.7GB 逻辑大小,不删会让用户以为
|
||||
/// 「取消失效」且磁盘占用虚高。仅用户取消(非 worker 自 abort)时调用;
|
||||
/// worker 失败触发的中止保留续传点,重试可直接续传。
|
||||
pub(crate) fn remove_partial_artifacts(dir: &Path, dest_paths: &[String]) {
|
||||
for path in dest_paths {
|
||||
let dest = dir.join(path);
|
||||
let _ = std::fs::remove_file(dest.with_extension("partial"));
|
||||
let _ = std::fs::remove_file(dest.with_extension("partial.idx"));
|
||||
}
|
||||
}
|
||||
|
||||
/// 判定一个「已存在」的目标文件是否完整可信,纯函数便于单测(#686)。/// - 大小一致 → 完整;
|
||||
/// - 大小不符(截断 / 损坏 / 超大)→ 不完整,应删除重下;
|
||||
/// - `expected_size == 0`(HF 未给出大小)→ 退回旧行为「存在即信任」,避免对未知大小
|
||||
/// 的文件反复重下。
|
||||
@@ -393,8 +419,18 @@ async fn run_download(
|
||||
let model_id_emit = model_id_str.clone();
|
||||
let file_path_emit = file_path.clone();
|
||||
let in_flight_for_cb = Arc::clone(&in_flight_bytes);
|
||||
let last_emit = Arc::new(AtomicU64::new(0));
|
||||
let on_progress: Arc<dyn Fn(u64) + Send + Sync> = Arc::new(move |bytes_in_file| {
|
||||
in_flight_for_cb[idx].store(bytes_in_file, Ordering::Relaxed);
|
||||
// 节流:距上次 emit < 150ms 的中间进度直接丢弃(高频事件会让
|
||||
// 前端进度条抽搐),in_flight 仍照常累计,下次 emit 带的是最新值。
|
||||
let now = now_millis();
|
||||
if now - last_emit.load(Ordering::Relaxed)
|
||||
< PROGRESS_EMIT_MIN_INTERVAL_MS
|
||||
{
|
||||
return;
|
||||
}
|
||||
last_emit.store(now, Ordering::Relaxed);
|
||||
let total_in_flight: u64 = in_flight_for_cb
|
||||
.iter()
|
||||
.map(|a| a.load(Ordering::Relaxed))
|
||||
@@ -461,6 +497,10 @@ async fn run_download(
|
||||
|
||||
// 用户主动 cancel(不是我们因为错误自己 set 的)→ Cancelled
|
||||
if cancel.load(Ordering::SeqCst) && !self_aborted {
|
||||
// 取消 = 放弃该模型:清掉 .partial/.partial.idx,避免残留稀疏大文件
|
||||
// 占满磁盘(用户取消意图明确,不留续传点)。
|
||||
let dest_paths: Vec<String> = info.files.iter().map(|f| f.path.clone()).collect();
|
||||
remove_partial_artifacts(&dir, &dest_paths);
|
||||
emit_cancelled(app, model_id, "", 0, file_count, total_bytes);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1037,7 +1077,7 @@ fn emit_cancelled(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::existing_file_is_complete;
|
||||
use super::{existing_file_is_complete, remove_partial_artifacts};
|
||||
|
||||
#[test]
|
||||
fn complete_when_size_matches() {
|
||||
@@ -1060,4 +1100,30 @@ mod tests {
|
||||
assert!(existing_file_is_complete(0, 0));
|
||||
assert!(existing_file_is_complete(999, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_partial_artifacts_deletes_partials_keeps_complete() {
|
||||
// 用户取消后:`<file>.partial` 与 `<file>.partial.idx` 应被清掉,
|
||||
// 已完成/完整的目标文件不受影响。
|
||||
let uniq = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let dir = std::env::temp_dir().join(format!("ol-asr-dl-test-{uniq}"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dest = dir.join("model.safetensors");
|
||||
let partial = dest.with_extension("partial");
|
||||
let idx = partial.with_extension("partial.idx");
|
||||
let keep = dir.join("config.json");
|
||||
for p in [&dest, &partial, &idx, &keep] {
|
||||
std::fs::write(p, b"x").unwrap();
|
||||
}
|
||||
let dest_paths: Vec<String> = vec!["model.safetensors".into()];
|
||||
remove_partial_artifacts(&dir, &dest_paths);
|
||||
assert!(!partial.exists(), ".partial 应被删除");
|
||||
assert!(!idx.exists(), ".partial.idx 应被删除");
|
||||
assert!(dest.exists(), "完整目标文件不应被删除");
|
||||
assert!(keep.exists(), "未在清单里的文件不应被删除");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
mod imp {
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
@@ -97,6 +97,24 @@ mod imp {
|
||||
let _lifecycle = self.lifecycle.lock().await;
|
||||
self.cancel_prepare.store(false, Ordering::SeqCst);
|
||||
let progress: FoundryPrepareProgressCallback = Arc::new(progress);
|
||||
// 节流:SDK 的 percent 回调频率不可控(可能远高于前端可感知的
|
||||
// 刷新率),percent 类事件 ≥150ms 才转发,避免进度浮层抽搐;
|
||||
// phase 事件(percent=None,如 runtime/model/load 的阶段切换与
|
||||
// finished/failed)不受限,保证阶段提示不丢。
|
||||
let raw = Arc::clone(&progress);
|
||||
let last_emit = Arc::new(AtomicU64::new(0));
|
||||
let progress: FoundryPrepareProgressCallback = Arc::new(move |payload| {
|
||||
if payload.percent.is_some() {
|
||||
let now = crate::asr::local::download::now_millis();
|
||||
if now - last_emit.load(Ordering::Relaxed)
|
||||
< crate::asr::local::download::PROGRESS_EMIT_MIN_INTERVAL_MS
|
||||
{
|
||||
return;
|
||||
}
|
||||
last_emit.store(now, Ordering::Relaxed);
|
||||
}
|
||||
raw(payload);
|
||||
});
|
||||
let runtime_source = foundry_native::normalize_runtime_source(runtime_source);
|
||||
Ok(self
|
||||
.ensure_loaded_locked(alias, runtime_source, progress)
|
||||
|
||||
@@ -12,7 +12,8 @@ use sha2::{Digest, Sha256};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::download::{
|
||||
build_client, download_one, partial_actual_size, DownloadPhase, DownloadProgress, Mirror,
|
||||
build_client, download_one, now_millis, partial_actual_size, DownloadPhase,
|
||||
DownloadProgress, Mirror, PROGRESS_EMIT_MIN_INTERVAL_MS,
|
||||
};
|
||||
use super::sherpa;
|
||||
|
||||
@@ -417,8 +418,18 @@ async fn run_download(
|
||||
}
|
||||
let app_emit = app.clone();
|
||||
let in_flight_for_cb = Arc::clone(&in_flight_bytes);
|
||||
let last_emit = Arc::new(AtomicU64::new(0));
|
||||
let on_progress: Arc<dyn Fn(u64) + Send + Sync> = Arc::new(move |bytes_in_file| {
|
||||
in_flight_for_cb[idx].store(bytes_in_file, Ordering::Relaxed);
|
||||
// 节流(同 download.rs):每 HTTP chunk 回调一次,全量转发会
|
||||
// 高频刷前端进度条;in_flight 照常累计,只按 ≥150ms 转发最新值。
|
||||
let now = now_millis();
|
||||
if now - last_emit.load(Ordering::Relaxed)
|
||||
< PROGRESS_EMIT_MIN_INTERVAL_MS
|
||||
{
|
||||
return;
|
||||
}
|
||||
last_emit.store(now, Ordering::Relaxed);
|
||||
let total_in_flight: u64 = in_flight_for_cb
|
||||
.iter()
|
||||
.map(|bytes| bytes.load(Ordering::Relaxed))
|
||||
@@ -479,6 +490,10 @@ async fn run_download(
|
||||
}
|
||||
|
||||
if cancel.load(Ordering::SeqCst) && !self_aborted {
|
||||
// 用户主动取消 = 放弃该模型:清掉 .partial/.partial.idx(同 qwen3 路径,
|
||||
// 避免稀疏大文件占满磁盘),不留续传点。
|
||||
let dest_paths: Vec<String> = info.files.iter().map(|f| f.local_path.clone()).collect();
|
||||
super::download::remove_partial_artifacts(&dir, &dest_paths);
|
||||
emit_cancelled(app, model_alias, file_count, total_bytes);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -553,7 +568,14 @@ async fn run_release_archive_download(
|
||||
let app_emit = app.clone();
|
||||
let model_alias_emit = model_alias.to_string();
|
||||
let file_name_emit = archive.file_name.to_string();
|
||||
let last_emit = Arc::new(AtomicU64::new(0));
|
||||
let on_progress: Arc<dyn Fn(u64) + Send + Sync> = Arc::new(move |bytes_downloaded| {
|
||||
// 节流(同 download.rs):release 包下载同样按 ≥150ms 转发进度。
|
||||
let now = now_millis();
|
||||
if now - last_emit.load(Ordering::Relaxed) < PROGRESS_EMIT_MIN_INTERVAL_MS {
|
||||
return;
|
||||
}
|
||||
last_emit.store(now, Ordering::Relaxed);
|
||||
let _ = app_emit.emit(
|
||||
"sherpa-onnx-asr-download-progress",
|
||||
DownloadProgress {
|
||||
@@ -589,6 +611,9 @@ async fn run_release_archive_download(
|
||||
.await
|
||||
};
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
// 用户取消:release 包同样清理 .partial/.partial.idx(与多文件路径一致)。
|
||||
let _ = std::fs::remove_file(archive_path.with_extension("partial"));
|
||||
let _ = std::fs::remove_file(archive_path.with_extension("partial.idx"));
|
||||
emit_cancelled(app, model_alias, file_count, total_bytes);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||
import { Capsule } from './components/Capsule';
|
||||
import { GlobalDownloadProgress } from './components/GlobalDownloadProgress';
|
||||
import { detectOS, type OS } from './components/WindowChrome';
|
||||
import {
|
||||
checkAccessibilityPermission,
|
||||
@@ -298,6 +299,8 @@ export function App({ isCapsule, isQa, isSelectionPolishPreview, isLessComputer,
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<HotkeySettingsProvider>
|
||||
{/* 全局下载进度浮层:主窗口所有页面常驻(自身监听事件,与页面解耦)。 */}
|
||||
<GlobalDownloadProgress />
|
||||
{platformCaps?.platform === 'android' && (
|
||||
<div style={{ display: mobileQaOpen ? 'block' : 'none', height: '100%' }}>
|
||||
<QaPanel
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
// GlobalDownloadProgress.tsx —— 全局下载进度浮层。
|
||||
//
|
||||
// 主窗口任何页面常驻显示(portal 到 document.body,fixed 右上角):下载开始即
|
||||
// 出现、结束前一直显示,切页面 / 开关设置弹窗都不影响它。组件自包含——自己
|
||||
// 订阅三套本地 ASR 引擎的下载进度事件并维护本地 state,与页面状态解耦,页面
|
||||
// 重渲染不会拖累它,它也不会让页面跟随每个进度事件重渲染。
|
||||
//
|
||||
// 事件在 Rust 侧已按 ≥150ms 节流(见 download.rs PROGRESS_EMIT_MIN_INTERVAL_MS),
|
||||
// 进度条不会因高频 IPC 抽搐;这里只做展示 + 取消入口,不参与模型状态管理。
|
||||
//
|
||||
// portal 到 body 的原因与 DownloadDialog 相同:WindowChrome 根节点的常驻
|
||||
// transform / will-change 会为 position:fixed 后代创建 containing block,不
|
||||
// portal 的话 fixed 会相对设置弹窗而非视口定位(灰屏 + 点不到的经典 bug)。
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isTauri } from '../lib/ipc';
|
||||
import {
|
||||
cancelFoundryLocalAsrPrepare,
|
||||
cancelLocalAsrDownload,
|
||||
cancelSherpaOnnxAsrDownload,
|
||||
type FoundryPrepareProgress,
|
||||
type LocalAsrDownloadProgress,
|
||||
} from '../lib/localAsr';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
type Engine = 'qwen3' | 'sherpa' | 'foundry';
|
||||
|
||||
interface ProgressItem {
|
||||
key: string;
|
||||
/** 引擎侧模型 id / alias(调用对应 cancel 命令用)。 */
|
||||
id: string;
|
||||
name: string;
|
||||
percent: number | null;
|
||||
engine: Engine;
|
||||
}
|
||||
|
||||
const DOWNLOAD_TERMINAL_PHASES = new Set(['finished', 'cancelled', 'failed']);
|
||||
const FOUNDRY_TERMINAL_PHASES = new Set(['finished', 'failed']);
|
||||
|
||||
export function GlobalDownloadProgress() {
|
||||
const { t } = useTranslation();
|
||||
const [items, setItems] = useState<Record<string, ProgressItem>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
let unlistens: Array<() => void> = [];
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const { listen } = await import('@tauri-apps/api/event');
|
||||
const qwenOff = await listen<LocalAsrDownloadProgress>(
|
||||
'local-asr-download-progress',
|
||||
(e) => {
|
||||
const p = e.payload;
|
||||
const key = `qwen3:${p.modelId}`;
|
||||
setItems((prev) => {
|
||||
if (DOWNLOAD_TERMINAL_PHASES.has(p.phase)) {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[key]: {
|
||||
key,
|
||||
id: p.modelId,
|
||||
name: p.modelId,
|
||||
percent:
|
||||
p.bytesTotal > 0
|
||||
? (p.bytesDownloaded / p.bytesTotal) * 100
|
||||
: null,
|
||||
engine: 'qwen3' as const,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
const sherpaOff = await listen<LocalAsrDownloadProgress>(
|
||||
'sherpa-onnx-asr-download-progress',
|
||||
(e) => {
|
||||
const p = e.payload;
|
||||
const key = `sherpa:${p.modelId}`;
|
||||
setItems((prev) => {
|
||||
if (DOWNLOAD_TERMINAL_PHASES.has(p.phase)) {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[key]: {
|
||||
key,
|
||||
id: p.modelId,
|
||||
name: p.modelId,
|
||||
percent:
|
||||
p.bytesTotal > 0
|
||||
? (p.bytesDownloaded / p.bytesTotal) * 100
|
||||
: null,
|
||||
engine: 'sherpa' as const,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
const foundryOff = await listen<FoundryPrepareProgress>(
|
||||
'foundry-local-asr-prepare-progress',
|
||||
(e) => {
|
||||
const p = e.payload;
|
||||
const key = `foundry:${p.modelAlias}`;
|
||||
setItems((prev) => {
|
||||
if (FOUNDRY_TERMINAL_PHASES.has(p.phase)) {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
}
|
||||
// phase 切换事件(runtime→model→load)不带进度,保留原条目不刷。
|
||||
if (p.percent == null) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[key]: {
|
||||
key,
|
||||
id: p.modelAlias,
|
||||
name: p.label || p.modelAlias,
|
||||
percent: p.percent,
|
||||
engine: 'foundry' as const,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
if (cancelled) {
|
||||
qwenOff();
|
||||
sherpaOff();
|
||||
foundryOff();
|
||||
} else {
|
||||
unlistens = [qwenOff, sherpaOff, foundryOff];
|
||||
}
|
||||
})().catch((err) =>
|
||||
console.warn('[global-download-progress] subscribe failed', err),
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
for (const off of unlistens) off();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCancel = (item: ProgressItem) => {
|
||||
if (item.engine === 'qwen3') void cancelLocalAsrDownload(item.id);
|
||||
else if (item.engine === 'sherpa') void cancelSherpaOnnxAsrDownload(item.id);
|
||||
else void cancelFoundryLocalAsrPrepare();
|
||||
};
|
||||
|
||||
const visible = Object.values(items);
|
||||
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.key}
|
||||
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',
|
||||
alignItems: 'center',
|
||||
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={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--ol-ink-4)' }}>
|
||||
{item.percent != null
|
||||
? `${Math.round(item.percent)}%`
|
||||
: t('localAsr.downloading')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCancel(item)}
|
||||
aria-label={t('common.cancel')}
|
||||
title={t('common.cancel')}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 5,
|
||||
border: 0,
|
||||
padding: 0,
|
||||
background: 'transparent',
|
||||
color: 'var(--ol-ink-3)',
|
||||
cursor: 'pointer',
|
||||
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={10} />
|
||||
</button>
|
||||
</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,
|
||||
);
|
||||
}
|
||||
@@ -570,6 +570,8 @@ export interface SidebarModelEntry {
|
||||
isDownloaded: boolean
|
||||
/** 下载中(有进度条/取消入口)。 */
|
||||
isDownloading: boolean
|
||||
/** 下载中实时百分比(0-100;仅 isDownloading 时有值)。 */
|
||||
percent?: number | null
|
||||
/** 当前激活(设为默认的本地模型)。 */
|
||||
isActive: boolean
|
||||
/** 引擎标识,决定右侧动作按钮分派。 */
|
||||
@@ -621,7 +623,8 @@ export function ModelSidebar({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 10px",
|
||||
// 行距加大:列表可容纳约 4 个模型,竖排更长、横向不变。
|
||||
padding: "11px 14px",
|
||||
borderRadius: 8,
|
||||
border: "0.5px solid var(--ol-line-soft)",
|
||||
background: selected
|
||||
@@ -632,7 +635,7 @@ export function ModelSidebar({
|
||||
: "none",
|
||||
color: "var(--ol-ink)",
|
||||
fontFamily: "inherit",
|
||||
fontSize: 12.5,
|
||||
fontSize: 13,
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
transition:
|
||||
@@ -703,7 +706,17 @@ export function ModelSidebar({
|
||||
{t("localAsr.activePill")}
|
||||
</span>
|
||||
)}
|
||||
{entry.remoteBytes != null && entry.remoteBytes > 0 && (
|
||||
{entry.percent != null && entry.percent >= 0 ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
color: "var(--ol-ink-4)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{Math.round(entry.percent)}%
|
||||
</span>
|
||||
) : entry.remoteBytes != null && entry.remoteBytes > 0 ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
@@ -713,7 +726,7 @@ export function ModelSidebar({
|
||||
>
|
||||
{formatBytes(entry.remoteBytes)}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -1263,77 +1276,3 @@ export function DownloadDialog({
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -95,7 +95,6 @@ import {
|
||||
import {
|
||||
DownloadProgressBlock,
|
||||
FoundryPrepareProgressBlock,
|
||||
GlobalDownloadProgress,
|
||||
ModelDetailPanel,
|
||||
ModelSidebar,
|
||||
type SidebarModelEntry,
|
||||
@@ -523,7 +522,14 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
// 3s 轮询磁盘状态:模型被外部删除 / 下载中断时前端自动跟随(删除后
|
||||
// 看板选中自动回落、下拉回到引擎级入口),不用等重开页面。qwen3 的
|
||||
// list 是本地 fs walk,很轻;远端尺寸有缓存不会重复请求。
|
||||
const pollTimer = window.setInterval(() => {
|
||||
void refresh()
|
||||
}, 3000)
|
||||
return () => {
|
||||
window.clearInterval(pollTimer)
|
||||
if (scrollGuardCleanup.current) scrollGuardCleanup.current()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -1663,10 +1669,17 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
)
|
||||
|
||||
// ─── 两栏看板的统一模型条目(Qwen3 / sherpa-onnx / foundry 归一化) ───
|
||||
const sidebarEntries = useMemo<SidebarModelEntry[]>(() => {
|
||||
// allSidebarEntries = 全目录(下载弹窗用);sidebarEntries = 只列已下载 /
|
||||
// 下载中的模型(看板用,未下载的走「+ 下载新模型」弹窗获取)。
|
||||
const allSidebarEntries = useMemo<SidebarModelEntry[]>(() => {
|
||||
const entries: SidebarModelEntry[] = []
|
||||
// macOS:Qwen3 引擎
|
||||
for (const m of models) {
|
||||
const isDownloading =
|
||||
Boolean(progress[m.id]) &&
|
||||
(progress[m.id]?.phase === "started" ||
|
||||
progress[m.id]?.phase === "progress")
|
||||
if (!m.isDownloaded && !isDownloading) continue
|
||||
entries.push({
|
||||
id: m.id,
|
||||
name: m.id,
|
||||
@@ -1674,9 +1687,14 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
remoteBytes:
|
||||
remoteSizes[m.id]?.totalBytes || m.downloadedBytes || undefined,
|
||||
isDownloaded: m.isDownloaded,
|
||||
isDownloading: Boolean(progress[m.id]) &&
|
||||
(progress[m.id]?.phase === "started" ||
|
||||
progress[m.id]?.phase === "progress"),
|
||||
isDownloading,
|
||||
percent: isDownloading
|
||||
? progress[m.id] && progress[m.id]?.bytesTotal > 0
|
||||
? (progress[m.id]!.bytesDownloaded /
|
||||
progress[m.id]!.bytesTotal) *
|
||||
100
|
||||
: 0
|
||||
: null,
|
||||
isActive:
|
||||
settings?.activeModel === m.id &&
|
||||
prefs?.activeAsrProvider === "local-qwen3",
|
||||
@@ -1685,6 +1703,11 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
}
|
||||
// Windows:sherpa-onnx + foundry
|
||||
for (const c of sherpaCatalog) {
|
||||
const isDownloading =
|
||||
Boolean(sherpaDownloadProgress[c.alias]) &&
|
||||
(sherpaDownloadProgress[c.alias]?.phase === "started" ||
|
||||
sherpaDownloadProgress[c.alias]?.phase === "progress")
|
||||
if (!c.cached && !isDownloading) continue
|
||||
entries.push({
|
||||
id: c.alias,
|
||||
name: c.displayName || c.alias,
|
||||
@@ -1692,9 +1715,15 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
sherpaRemoteSizes[c.alias]?.totalBytes ||
|
||||
(c.fileSizeMb != null ? c.fileSizeMb * 1024 * 1024 : undefined),
|
||||
isDownloaded: c.cached,
|
||||
isDownloading: Boolean(sherpaDownloadProgress[c.alias]) &&
|
||||
(sherpaDownloadProgress[c.alias]?.phase === "started" ||
|
||||
sherpaDownloadProgress[c.alias]?.phase === "progress"),
|
||||
isDownloading,
|
||||
percent: isDownloading
|
||||
? sherpaDownloadProgress[c.alias] &&
|
||||
sherpaDownloadProgress[c.alias]?.bytesTotal > 0
|
||||
? (sherpaDownloadProgress[c.alias]!.bytesDownloaded /
|
||||
sherpaDownloadProgress[c.alias]!.bytesTotal) *
|
||||
100
|
||||
: 0
|
||||
: null,
|
||||
isActive:
|
||||
sherpaStatus?.activeModel === c.alias &&
|
||||
prefs?.activeAsrProvider === "sherpa-onnx-local",
|
||||
@@ -1702,13 +1731,25 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
})
|
||||
}
|
||||
for (const c of foundryCatalog) {
|
||||
// foundry 下载发生在 prepare 内(runtime/model/load 阶段),cached
|
||||
// 仍是 false,靠 prepare 进度判定「下载中」保住条目。
|
||||
const isDownloading =
|
||||
foundryProgress?.modelAlias === c.alias &&
|
||||
(foundryProgress.phase === "runtime" ||
|
||||
foundryProgress.phase === "model" ||
|
||||
foundryProgress.phase === "load")
|
||||
if (!c.cached && !isDownloading) continue
|
||||
entries.push({
|
||||
id: c.alias,
|
||||
name: c.displayName || c.alias,
|
||||
remoteBytes:
|
||||
c.fileSizeMb != null ? c.fileSizeMb * 1024 * 1024 : undefined,
|
||||
isDownloaded: c.cached,
|
||||
isDownloading: false,
|
||||
isDownloading,
|
||||
percent:
|
||||
isDownloading && foundryProgress?.percent != null
|
||||
? foundryProgress.percent
|
||||
: null,
|
||||
isActive:
|
||||
foundryStatus?.activeModel === c.alias &&
|
||||
prefs?.activeAsrProvider === "foundry-local-whisper",
|
||||
@@ -1727,14 +1768,24 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
sherpaDownloadProgress,
|
||||
sherpaStatus?.activeModel,
|
||||
foundryCatalog,
|
||||
foundryProgress,
|
||||
foundryStatus?.activeModel,
|
||||
])
|
||||
|
||||
// 看板只展示已下载 / 下载中的模型(下载中必须有实时进度可见)。
|
||||
const sidebarEntries = useMemo<SidebarModelEntry[]>(
|
||||
() => allSidebarEntries.filter((e) => e.isDownloaded || e.isDownloading),
|
||||
[allSidebarEntries],
|
||||
)
|
||||
|
||||
const selectedEntry =
|
||||
sidebarEntries.find((e) => e.id === selectedModelId) ?? null
|
||||
|
||||
// 侧栏选中默认:首次渲染后若没有选中项,选中第一个已下载模型。
|
||||
useLayoutEffect(() => {
|
||||
// 下载弹窗打开时弹窗内高亮未下载模型是合法的(选中即准备下载),
|
||||
// 不能让看板的回落逻辑把弹窗高亮抢走;弹窗关闭后再回落。
|
||||
if (downloadDialogOpen) return
|
||||
// 选中项被删除(或从未选中)时回落到第一个已下载模型,避免侧栏无高亮、
|
||||
// 详情面板停在空态。
|
||||
const stillExists =
|
||||
@@ -1743,7 +1794,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
if (stillExists) return
|
||||
const firstDownloaded = sidebarEntries.find((e) => e.isDownloaded)
|
||||
setSelectedModelId(firstDownloaded?.id ?? sidebarEntries[0]?.id ?? null)
|
||||
}, [sidebarEntries, selectedModelId])
|
||||
}, [sidebarEntries, selectedModelId, downloadDialogOpen])
|
||||
|
||||
// 从侧栏/看板分派引擎动作。不再有 setActive——激活 = 在 ASR 语音转写里
|
||||
// 选本地模型供应商,「加载并测试」负责把模型设为当前使用。
|
||||
@@ -1776,9 +1827,12 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
}
|
||||
|
||||
// 下载弹框「开始下载」:把弹框当前选中项分派到对应引擎的下载入口。
|
||||
// 弹框列表是全目录(allSidebarEntries),选中项可能不在看板过滤列表里。
|
||||
const startDownloadFromDialog = () => {
|
||||
if (!selectedEntry || selectedEntry.isDownloaded) return
|
||||
dispatchEntryAction(selectedEntry, "download")
|
||||
const dialogEntry =
|
||||
allSidebarEntries.find((e) => e.id === selectedModelId) ?? null
|
||||
if (!dialogEntry || dialogEntry.isDownloaded) return
|
||||
dispatchEntryAction(dialogEntry, "download")
|
||||
setDownloadDialogOpen(false)
|
||||
}
|
||||
|
||||
@@ -1812,37 +1866,9 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ─── 右上角下载进度浮层:所有引擎的下载进度聚合显示,完成即消失。 ─── */}
|
||||
<GlobalDownloadProgress
|
||||
items={[
|
||||
// Qwen3(macOS)
|
||||
...Object.entries(progress).map(([id, p]) => ({
|
||||
id: `qwen3:${id}`,
|
||||
name: id,
|
||||
percent:
|
||||
p.bytesTotal > 0
|
||||
? (p.bytesDownloaded / p.bytesTotal) * 100
|
||||
: 0,
|
||||
finished:
|
||||
p.phase === "finished" ||
|
||||
p.phase === "cancelled" ||
|
||||
p.phase === "failed",
|
||||
})),
|
||||
// sherpa-onnx(Windows)
|
||||
...Object.entries(sherpaDownloadProgress).map(([alias, p]) => ({
|
||||
id: `sherpa:${alias}`,
|
||||
name: alias,
|
||||
percent:
|
||||
p.bytesTotal > 0
|
||||
? (p.bytesDownloaded / p.bytesTotal) * 100
|
||||
: 0,
|
||||
finished:
|
||||
p.phase === "finished" ||
|
||||
p.phase === "cancelled" ||
|
||||
p.phase === "failed",
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
{/* ─── 右上角下载进度浮层已全局化(App 根挂载,任何页面常驻),
|
||||
此处不再渲染;页面内进度仍由 progress / sherpaDownloadProgress
|
||||
驱动看板详情条。 ─── */}
|
||||
|
||||
{!embedded && (
|
||||
/* 性能/质量预期警告 —— embedded 模式下由 AdvancedSection 自己渲染,避免重复。 */
|
||||
@@ -1888,11 +1914,15 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
<ModelSidebar
|
||||
entries={sidebarEntries}
|
||||
selectedId={selectedModelId}
|
||||
onSelect={setSelectedModelId}
|
||||
onSelect={(id) => {
|
||||
setSelectedModelId(id)
|
||||
// 选中瞬间校验磁盘状态(模型文件可能已被外部删除),
|
||||
// 立刻反映到列表与详情,不等 3s 轮询。
|
||||
void refresh()
|
||||
}}
|
||||
onOpenDownload={() => setDownloadDialogOpen(true)}
|
||||
downloadDisabled={busyModelId !== null || sherpaBusy !== null}
|
||||
/>
|
||||
<div
|
||||
/> <div
|
||||
style={{
|
||||
paddingLeft: 16,
|
||||
borderLeft: "0.5px solid var(--ol-line)",
|
||||
@@ -2259,15 +2289,15 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
{/* ─── 下载弹框:左侧模型选择 + 右侧详情,最下方开始下载。 ─── */}
|
||||
{downloadDialogOpen && (
|
||||
<DownloadDialog
|
||||
entries={sidebarEntries}
|
||||
entries={allSidebarEntries}
|
||||
selectedId={selectedModelId}
|
||||
onSelect={setSelectedModelId}
|
||||
sizeOf={(id) => {
|
||||
const entry = sidebarEntries.find((e) => e.id === id)
|
||||
const entry = allSidebarEntries.find((e) => e.id === id)
|
||||
return entry?.remoteBytes ?? null
|
||||
}}
|
||||
fileCountOf={(id) => {
|
||||
const entry = sidebarEntries.find((e) => e.id === id)
|
||||
const entry = allSidebarEntries.find((e) => e.id === id)
|
||||
if (!entry) return null
|
||||
const remote =
|
||||
entry.engine === "qwen3"
|
||||
|
||||
@@ -274,26 +274,56 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
|
||||
const [localModelOptions, setLocalModelOptions] = useState<
|
||||
{ engine: 'qwen3' | 'sherpa' | 'foundry'; id: string; name: string; isDownloaded: boolean }[]
|
||||
>([]);
|
||||
// 同 provider 内切换本地模型的乐观值:下拉立即显示用户点的模型,不等后端
|
||||
// set_settings + prefs:changed 事件回来(回来前的几帧会闪回旧模型 = 闪烁)。
|
||||
const [localAsrModelDraft, setLocalAsrModelDraft] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
// 供应商切换后旧引擎的 draft 不再适用,清掉让 asrValue 回退 prefs。
|
||||
setLocalAsrModelDraft(null);
|
||||
}, [committedAsrProvider]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// 平台分支拉取:sherpa/foundry 的 catalog 命令只在 Windows 注册,macOS 上
|
||||
// invoke 未注册命令会 reject——Promise.all 拉三个会把 qwen3 的结果也一起
|
||||
// 吞掉(下拉永远只剩引擎级入口)。按平台只拉本平台存在的引擎。
|
||||
const fetchAll = async () => {
|
||||
try {
|
||||
const [qwen3, sherpa, foundry] = await Promise.all([
|
||||
listLocalAsrModels(),
|
||||
getSherpaOnnxAsrCatalog(),
|
||||
getFoundryLocalAsrCatalog(),
|
||||
]);
|
||||
const qwen3 = await listLocalAsrModels();
|
||||
const extra =
|
||||
os === 'win'
|
||||
? await Promise.all([
|
||||
getSherpaOnnxAsrCatalog(),
|
||||
getFoundryLocalAsrCatalog(),
|
||||
])
|
||||
: null;
|
||||
if (cancelled) return;
|
||||
setLocalModelOptions([
|
||||
const next = [
|
||||
...qwen3.map(m => ({ engine: 'qwen3' as const, id: m.id, name: m.id, isDownloaded: m.isDownloaded })),
|
||||
...sherpa.map(c => ({ engine: 'sherpa' as const, id: c.alias, name: c.displayName || c.alias, isDownloaded: c.cached })),
|
||||
...foundry.map(c => ({ engine: 'foundry' as const, id: c.alias, name: c.displayName || c.alias, isDownloaded: c.cached })),
|
||||
]);
|
||||
...(extra?.[0] ?? []).map(c => ({ engine: 'sherpa' as const, id: c.alias, name: c.displayName || c.alias, isDownloaded: c.cached })),
|
||||
...(extra?.[1] ?? []).map(c => ({ engine: 'foundry' as const, id: c.alias, name: c.displayName || c.alias, isDownloaded: c.cached })),
|
||||
];
|
||||
// 浅比较:数据没变就不 setState,避免 3s 轮询让下拉每轮重渲染(闪烁)。
|
||||
setLocalModelOptions(prev =>
|
||||
prev.length === next.length &&
|
||||
prev.every((m, i) =>
|
||||
m.engine === next[i].engine &&
|
||||
m.id === next[i].id &&
|
||||
m.name === next[i].name &&
|
||||
m.isDownloaded === next[i].isDownloaded,
|
||||
)
|
||||
? prev
|
||||
: next,
|
||||
);
|
||||
} catch {
|
||||
if (!cancelled) setLocalModelOptions([]);
|
||||
}
|
||||
};
|
||||
void fetchAll();
|
||||
// 3s 轮询磁盘状态:模型被外部删除(或下载完成后)下拉选项自动跟随,
|
||||
// 用户不需要重开设置页。本地 fs 检查很轻,无感。
|
||||
const pollTimer = window.setInterval(() => {
|
||||
void fetchAll();
|
||||
}, 3000);
|
||||
// 下载完成事件驱动刷新:本页下方「本地模型」看板下载完模型后,下拉立刻出现新选项。
|
||||
let unlistenQ: (() => void) | undefined;
|
||||
let unlistenS: (() => void) | undefined;
|
||||
@@ -307,6 +337,7 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
|
||||
}).catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(pollTimer);
|
||||
unlistenQ?.();
|
||||
unlistenS?.();
|
||||
};
|
||||
@@ -409,6 +440,35 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
|
||||
|
||||
const onAsrProviderChange = async (id: AsrPresetId, modelId?: string) => {
|
||||
setAsrProvider(id);
|
||||
// 轻量路径:供应商没变、只是换本地模型 → 不重跑 set_active_provider /
|
||||
// 凭据回填整套流程(那些会触发 prefs:changed 全量重渲染 + 下拉闪回旧值),
|
||||
// 只写模型命令 + prefs 字段。draft 让下拉立即显示用户点的模型。
|
||||
if (id === committedAsrProvider && modelId && isLocalAsrPreset(id)) {
|
||||
setLocalAsrModelDraft(modelId);
|
||||
try {
|
||||
if (id === 'local-qwen3') {
|
||||
await setLocalAsrActiveModel(modelId);
|
||||
} else if (id === 'sherpa-onnx-local') {
|
||||
await setSherpaOnnxAsrModel(modelId);
|
||||
} else if (id === 'foundry-local-whisper') {
|
||||
await setFoundryLocalAsrModel(modelId);
|
||||
}
|
||||
if (prefs) {
|
||||
const next = { ...prefs, activeAsrProvider: id };
|
||||
if (id === 'local-qwen3') next.localAsrActiveModel = modelId;
|
||||
else if (id === 'sherpa-onnx-local') next.sherpaOnnxModel = modelId;
|
||||
else if (id === 'foundry-local-whisper') next.foundryLocalAsrModel = modelId;
|
||||
await updatePrefs(next);
|
||||
}
|
||||
emitSaved('saved', t('common.saved'));
|
||||
} catch (err) {
|
||||
// 写入失败回滚 draft,让下拉回到 prefs 里的真实值。
|
||||
setLocalAsrModelDraft(null);
|
||||
emitSaved('failed', t('common.operationFailed'));
|
||||
console.error('[settings] switch local ASR model failed', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const seq = ++asrSwitchSeqRef.current;
|
||||
emitSaved('saving', t('common.saving'));
|
||||
let backendSwitched = false;
|
||||
@@ -580,6 +640,8 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
|
||||
}));
|
||||
});
|
||||
// 受控 value:本地引擎激活且 active 模型已下载时显示 "引擎:模型ID"。
|
||||
// draft(用户刚点的模型)优先于 prefs——同 provider 换模型时后端
|
||||
// 还没回写完成,直接读 prefs 会闪回旧模型。
|
||||
const activeModelId = committedAsrProvider === 'local-qwen3'
|
||||
? prefs?.localAsrActiveModel
|
||||
: committedAsrProvider === 'sherpa-onnx-local'
|
||||
@@ -587,11 +649,16 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
|
||||
: committedAsrProvider === 'foundry-local-whisper'
|
||||
? prefs?.foundryLocalAsrModel
|
||||
: undefined;
|
||||
const resolvedModelId =
|
||||
localAsrModelDraft &&
|
||||
localModelOptions.some(m => m.id === localAsrModelDraft && m.isDownloaded)
|
||||
? localAsrModelDraft
|
||||
: activeModelId;
|
||||
const asrValue =
|
||||
isLocalAsrPreset(committedAsrProvider) &&
|
||||
activeModelId &&
|
||||
localModelOptions.some(m => m.id === activeModelId && m.isDownloaded)
|
||||
? `${committedAsrProvider}:${activeModelId}`
|
||||
resolvedModelId &&
|
||||
localModelOptions.some(m => m.id === resolvedModelId && m.isDownloaded)
|
||||
? `${committedAsrProvider}:${resolvedModelId}`
|
||||
: asrProvider;
|
||||
// 平台不匹配的旧配置(如 Windows 上仍激活 local-qwen3):补一个选项兜底。
|
||||
const hiddenLocalActive: AsrPresetId | null =
|
||||
|
||||
Reference in new issue
Block a user