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 5a6fc91d..9ab23d01 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -461,6 +461,59 @@ const PARALLEL: usize = 8; const PER_CHUNK_ATTEMPTS: u32 = 4; const PARALLEL_FILES: usize = 3; +/// `.partial` 文件的真实已下字节(不是 sparse 逻辑大小)。 +/// 有 `.partial.idx` → chunked 模式,按 idx 里 chunk 数还原; +/// 没有 → append/single-stream 模式,partial 是 dense,meta.len() 即真实字节。 +pub fn partial_actual_size(partial: &Path) -> u64 { + let total_size = match std::fs::metadata(partial) { + Ok(m) => m.len(), + Err(e) => { + eprintln!( + "[local-asr] partial_actual_size: stat partial failed ({}): {}", + partial.display(), + e + ); + return 0; + } + }; + if total_size == 0 { + return 0; + } + let idx_path = partial.with_extension("partial.idx"); + if !idx_path.exists() { + return total_size; + } + let content = match std::fs::read_to_string(&idx_path) { + Ok(s) => s, + Err(e) => { + // idx 不可读 → 不知道哪些 chunk 已落盘,sparse 全长不可信,只能回 0。 + // 但日志要留,否则进度条无故归零没法排查。 + eprintln!( + "[local-asr] partial_actual_size: read idx failed ({}): {}", + idx_path.display(), + e + ); + return 0; + } + }; + let mut seen: HashSet = HashSet::new(); + let mut total: u64 = 0; + for line in content.lines() { + let Ok(idx) = line.trim().parse::() else { continue }; + if !seen.insert(idx) { + continue; + } + let start = (idx as u64).saturating_mul(CHUNK_SIZE); + if start >= total_size { + continue; + } + // 最后一块可能不到 CHUNK_SIZE + let end = (start + CHUNK_SIZE).min(total_size); + total += end - start; + } + total +} + async fn download_one( client: &reqwest::Client, url: &str, @@ -508,6 +561,12 @@ async fn download_one( f.set_len(total_size) .with_context(|| format!("set_len partial failed: {}", partial.display()))?; } + // 模式标记:sparse partial 必须配对 .partial.idx(哪怕空), + // 否则 walk_files 看到 partial 有但 idx 无,会把 sparse 全长当成已下完。 + if !idx_path.exists() { + std::fs::write(&idx_path, b"") + .with_context(|| format!("touch partial.idx failed: {}", idx_path.display()))?; + } // 4. 总计已下字节(用于初始化进度) let initial_done: u64 = chunks diff --git a/openless-all/app/src-tauri/src/asr/local/models.rs b/openless-all/app/src-tauri/src/asr/local/models.rs index a22df5e7..9ae76f16 100644 --- a/openless-all/app/src-tauri/src/asr/local/models.rs +++ b/openless-all/app/src-tauri/src/asr/local/models.rs @@ -82,15 +82,22 @@ fn walk_files(dir: &std::path::Path, on_size: &mut F) { }; for entry in entries.flatten() { let path = entry.path(); - let name = entry.file_name(); - // 哨兵文件本身体积忽略不计,但路径过滤更直白:保留所有非空文件。 + let name_os = entry.file_name(); + let name = name_os.to_string_lossy(); if name == READY_SENTINEL { continue; } + // .partial.idx 是 chunk 完成索引,不算下载字节 + if name.ends_with(".partial.idx") { + continue; + } match entry.file_type() { Ok(ft) if ft.is_dir() => walk_files(&path, on_size), Ok(ft) if ft.is_file() => { - if let Ok(meta) = entry.metadata() { + // .partial 在 chunked 模式下是 sparse 全长,meta.len() 不是真实字节 + if name.ends_with(".partial") { + on_size(super::download::partial_actual_size(&path)); + } else if let Ok(meta) = entry.metadata() { on_size(meta.len()); } } diff --git a/openless-all/app/src/pages/LocalAsr.tsx b/openless-all/app/src/pages/LocalAsr.tsx index 9b166a40..35cada25 100644 --- a/openless-all/app/src/pages/LocalAsr.tsx +++ b/openless-all/app/src/pages/LocalAsr.tsx @@ -143,7 +143,16 @@ export function LocalAsr() { const { listen } = await import('@tauri-apps/api/event'); const off = await listen('local-asr-download-progress', e => { const payload = e.payload; - setProgress(prev => ({ ...prev, [payload.modelId]: payload })); + if (payload.phase === 'cancelled') { + // 取消时清条目,bar 是否还显示交给 hasPartial 判断 + setProgress(prev => { + const next = { ...prev }; + delete next[payload.modelId]; + return next; + }); + } else { + setProgress(prev => ({ ...prev, [payload.modelId]: payload })); + } if ( payload.phase === 'finished' || payload.phase === 'cancelled' || @@ -185,18 +194,56 @@ export function LocalAsr() { const handleDownload = async (modelId: string) => { setBusyModelId(modelId); + // 重下载时,第一个后端事件到达前先用本地已知值占位,避免进度条从 0% 跳到真实位置。 + // 优先级:上一次 progress(取消后已删,通常没有)→ models 里的 downloadedBytes(cancel 时乐观写入) + const model = models.find(m => m.id === modelId); + const initialDownloaded = + progress[modelId]?.bytesDownloaded ?? model?.downloadedBytes ?? 0; + setProgress(prev => ({ + ...prev, + [modelId]: { + modelId, + file: '', + fileIndex: 0, + fileCount: remoteSizes[modelId]?.fileCount ?? 0, + bytesDownloaded: initialDownloaded, + bytesTotal: remoteSizes[modelId]?.totalBytes ?? 0, + phase: 'started', + error: null, + }, + })); try { await downloadLocalAsrModel(modelId, settings?.mirror); } catch (e) { setError(e instanceof Error ? e.message : String(e)); + setProgress(prev => { + const cur = prev[modelId]; + if (cur?.phase === 'started') { + return { ...prev, [modelId]: { ...cur, phase: 'failed', error: e instanceof Error ? e.message : String(e) } }; + } + return prev; + }); } finally { setBusyModelId(null); } }; const handleCancel = async (modelId: string) => { + // Progress 事件里的 bytesDownloaded 是后端 in_flight + already_done,是真实字节 + const lastBytes = progress[modelId]?.bytesDownloaded ?? 0; try { await cancelLocalAsrDownload(modelId); + setProgress(prev => { + const next = { ...prev }; + delete next[modelId]; + return next; + }); + // 乐观更新:让 hasPartial 立刻翻 true,不等 listener 200ms 后的 refresh + if (lastBytes > 0) { + setModels(prev => + prev.map(m => (m.id === modelId ? { ...m, downloadedBytes: lastBytes } : m)), + ); + } } catch (e) { setError(e instanceof Error ? e.message : String(e)); } @@ -458,8 +505,7 @@ function ModelRow({ // 进度条要保留:有 partial 残留(downloadedBytes>0 但未完整)就一直显示, // 让用户看到上次下到哪里了,再点下载会从那里续。 const hasPartial = !model.isDownloaded && model.downloadedBytes > 0; - const showProgress = - isDownloading || progress?.phase === 'failed' || progress?.phase === 'cancelled' || hasPartial; + const showProgress = isDownloading || progress?.phase === 'failed' || hasPartial; const sizeLabel = remoteSize?.loading ? t('localAsr.sizeLoading') @@ -497,8 +543,6 @@ function ModelRow({ background: progress?.phase === 'failed' ? '#d04545' - : progress?.phase === 'cancelled' - ? 'var(--ol-ink-4)' : 'var(--ol-accent-blue, #2c5cff)', transition: 'width 120ms linear', }} @@ -507,8 +551,6 @@ function ModelRow({
{progress?.phase === 'failed' ? `${t('localAsr.failed')}: ${progress.error ?? ''}` - : progress?.phase === 'cancelled' - ? t('localAsr.cancelled') : `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` + (progress?.file ? ` · ${progress.file}` : '')}