diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs index 3f18d5e61..14b26b864 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -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 { builder.build().context("build reqwest client failed") } -/// 判定一个「已存在」的目标文件是否完整可信,纯函数便于单测(#686)。 -/// - 大小一致 → 完整; +/// 用户主动取消下载后,清理断点续传产物(`.partial` sparse 文件 + +/// `.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 = 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 = 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() { + // 用户取消后:`.partial` 与 `.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 = 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); + } } diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs index 75380dfe6..c80ed99f3 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs @@ -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) diff --git a/openless-all/app/src-tauri/src/asr/local/sherpa_download.rs b/openless-all/app/src-tauri/src/asr/local/sherpa_download.rs index ad8d0a19a..772e7de04 100644 --- a/openless-all/app/src-tauri/src/asr/local/sherpa_download.rs +++ b/openless-all/app/src-tauri/src/asr/local/sherpa_download.rs @@ -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 = 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 = 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 = 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(()); } diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index d126c75a0..b5abe0efc 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -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 ( + {/* 全局下载进度浮层:主窗口所有页面常驻(自身监听事件,与页面解耦)。 */} + {platformCaps?.platform === 'android' && (
>({}); + + 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( + '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( + '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( + '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( +
+ {visible.map((item) => ( +
+
+ + {item.name} + + + + {item.percent != null + ? `${Math.round(item.percent)}%` + : t('localAsr.downloading')} + + + +
+
+
+
+
+ ))} +
, + document.body, + ); +} diff --git a/openless-all/app/src/pages/LocalAsr/components.tsx b/openless-all/app/src/pages/LocalAsr/components.tsx index cbbb94ebf..362e411f9 100644 --- a/openless-all/app/src/pages/LocalAsr/components.tsx +++ b/openless-all/app/src/pages/LocalAsr/components.tsx @@ -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")} )} - {entry.remoteBytes != null && entry.remoteBytes > 0 && ( + {entry.percent != null && entry.percent >= 0 ? ( + + {Math.round(entry.percent)}% + + ) : entry.remoteBytes != null && entry.remoteBytes > 0 ? ( {formatBytes(entry.remoteBytes)} - )} + ) : null} ) })} @@ -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( -
- {visible.map((item) => ( -
-
- - {item.name} - - - {item.percent != null ? `${Math.round(item.percent)}%` : t("localAsr.downloading")} - -
-
-
-
-
- ))} -
, - document.body, - ) -} diff --git a/openless-all/app/src/pages/LocalAsr/index.tsx b/openless-all/app/src/pages/LocalAsr/index.tsx index faa24e99c..4b1a109e1 100644 --- a/openless-all/app/src/pages/LocalAsr/index.tsx +++ b/openless-all/app/src/pages/LocalAsr/index.tsx @@ -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(() => { + // allSidebarEntries = 全目录(下载弹窗用);sidebarEntries = 只列已下载 / + // 下载中的模型(看板用,未下载的走「+ 下载新模型」弹窗获取)。 + const allSidebarEntries = useMemo(() => { 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( + () => 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 = {}) { /> )} - {/* ─── 右上角下载进度浮层:所有引擎的下载进度聚合显示,完成即消失。 ─── */} - ({ - 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 = {}) { { + setSelectedModelId(id) + // 选中瞬间校验磁盘状态(模型文件可能已被外部删除), + // 立刻反映到列表与详情,不等 3s 轮询。 + void refresh() + }} onOpenDownload={() => setDownloadDialogOpen(true)} downloadDisabled={busyModelId !== null || sherpaBusy !== null} - /> -
{ - 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" diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index 42ca6bc29..148e22e79 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -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(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 =