Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions openless-all/app/src-tauri/src/asr/local/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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,
Expand Down Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions openless-all/app/src-tauri/src/asr/local/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Expand Down
56 changes: 49 additions & 7 deletions openless-all/app/src/pages/LocalAsr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,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' ||
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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',
}}
Expand All @@ -507,8 +551,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>
Expand Down
Loading