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:
TRIPandsim authored and GitHub committed 2026-08-06 03:52:05 +08:00
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(());
}
+3
View File
@@ -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,
)
}
+79 -49
View File
@@ -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 =