关于模型设置-模型下载进度条的若干修复 (#293)
* fix: Various fixes for the model settings - model download progress bar * fix(local-asr): log partial-size errors and seed redownload progress
This commit is contained in:
1 parent
813d9ec2e9
commit
32133b3438
3 files changed
+118
-10
No files matched your search
@@ -470,6 +470,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<usize> = HashSet::new();
|
||||
let mut total: u64 = 0;
|
||||
for line in content.lines() {
|
||||
let Ok(idx) = line.trim().parse::<usize>() 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,
|
||||
@@ -524,6 +577,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
|
||||
|
||||
@@ -82,15 +82,22 @@ fn walk_files<F: FnMut(u64)>(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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +205,16 @@ export function LocalAsr() {
|
||||
const { listen } = await import('@tauri-apps/api/event');
|
||||
const off = await listen<LocalAsrDownloadProgress>('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' ||
|
||||
@@ -368,18 +377,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));
|
||||
}
|
||||
@@ -907,8 +954,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')
|
||||
@@ -946,8 +992,6 @@ function ModelRow({
|
||||
background:
|
||||
progress?.phase === 'failed'
|
||||
? '#d04545'
|
||||
: progress?.phase === 'cancelled'
|
||||
? 'var(--ol-ink-4)'
|
||||
: 'var(--ol-accent-blue, #2c5cff)',
|
||||
transition: 'width 120ms linear',
|
||||
}}
|
||||
@@ -956,8 +1000,6 @@ function ModelRow({
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 6 }}>
|
||||
{progress?.phase === 'failed'
|
||||
? `${t('localAsr.failed')}: ${progress.error ?? ''}`
|
||||
: progress?.phase === 'cancelled'
|
||||
? t('localAsr.cancelled')
|
||||
: `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` +
|
||||
(progress?.file ? ` · ${progress.file}` : '')}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user