Fix download progress bugs: false completion, stale UI, dedup

Three fixes on top of the download progress feature:

1. Backend: Replace broken "no .incomplete = done" completion check
   with a 95% byte threshold. HF downloads files sequentially, so
   between files there are briefly no .incomplete files even though
   the download is far from done (e.g. Kimi-K2.5 reported "done"
   after downloading 22KB of config files out of 595GB).

2. Frontend: Track hasShownProgress flag. Only show "Download
   complete. Loading into memory..." if we actually displayed
   download progress before. For already-cached models where the
   first poll returns progress=1.0, this avoids the misleading
   "Download complete" message.

3. Frontend: Deduplicate recommended vs downloaded -- filter out
   models already in the "Downloaded" section. Cache the fetched
   lists at module level so re-mounting the popover does not flash
   an empty "Downloaded" section.
This commit is contained in:
Daniel Han 2026-03-16 06:04:32 +00:00
commit 1471c63b96
3 changed files with 36 additions and 13 deletions

View file

@ -738,9 +738,12 @@ async def get_download_progress(
"progress": 0,
}
# No .incomplete files means download is done regardless of byte totals
# (blob deduplication can make completed_bytes differ from expected_bytes)
if in_progress_bytes == 0 and completed_bytes > 0:
# Use 95% threshold for completion (blob deduplication can make
# completed_bytes differ slightly from expected_bytes).
# Do NOT use "no .incomplete files" as a completion signal --
# HF downloads files sequentially, so between files there are
# no .incomplete files even though the download is far from done.
if completed_bytes >= expected_bytes * 0.95:
progress = 1.0
else:
progress = min(downloaded_bytes / expected_bytes, 0.99)

View file

@ -340,6 +340,10 @@ function isGgufRepo(id: string): boolean {
return id.toUpperCase().includes("-GGUF");
}
// Module-level caches so re-mounting the popover shows results instantly
let _cachedGgufCache: CachedGgufRepo[] = [];
let _cachedModelsCache: CachedModelRepo[] = [];
// ── Hub Model Picker ──────────────────────────────────────────
export function HubModelPicker({
@ -361,17 +365,27 @@ export function HubModelPicker({
// Track which GGUF repo is expanded for variant selection
const [expandedGguf, setExpandedGguf] = useState<string | null>(null);
// Cached (already downloaded) repos
const [cachedGguf, setCachedGguf] = useState<CachedGgufRepo[]>([]);
const [cachedModels, setCachedModels] = useState<CachedModelRepo[]>([]);
// Cached (already downloaded) repos -- use module-level cache so
// re-mounting the popover does not flash an empty "Downloaded" section.
const [cachedGguf, setCachedGguf] = useState<CachedGgufRepo[]>(_cachedGgufCache);
const [cachedModels, setCachedModels] = useState<CachedModelRepo[]>(_cachedModelsCache);
useEffect(() => {
listCachedGguf().then(setCachedGguf).catch(() => {});
listCachedModels().then(setCachedModels).catch(() => {});
listCachedGguf().then((v) => { _cachedGgufCache = v; setCachedGguf(v); }).catch(() => {});
listCachedModels().then((v) => { _cachedModelsCache = v; setCachedModels(v); }).catch(() => {});
}, []);
// Deduplicate: don't show downloaded models in the recommended list
const downloadedSet = useMemo(() => {
const s = new Set<string>();
for (const c of cachedGguf) s.add(c.repo_id);
for (const c of cachedModels) s.add(c.repo_id);
return s;
}, [cachedGguf, cachedModels]);
const recommendedIds = useMemo(
() => dedupe([...models.map((model) => model.id), value ?? ""]),
[models, value],
() => dedupe([...models.map((model) => model.id), value ?? ""])
.filter((id) => !downloadedSet.has(id)),
[models, value, downloadedSet],
);
const { paramCountById: recommendedParamCountById } =

View file

@ -347,6 +347,8 @@ export function useChatModelRuntime() {
},
};
let hasShownProgress = false;
const pollProgress = async () => {
// Stop if cancelled or if loading already finished
if (abortCtrl.signal.aborted || !loadingModelRef.current) {
@ -361,7 +363,8 @@ export function useChatModelRuntime() {
// Re-check after await -- load may have finished while polling
if (!loadingModelRef.current) return;
if (prog.downloaded_bytes > 0 && prog.progress > 0 && prog.progress < 1) {
if (prog.progress > 0 && prog.progress < 1) {
hasShownProgress = true;
const dlGb = prog.downloaded_bytes / (1024 ** 3);
const totalGb = prog.expected_bytes / (1024 ** 3);
const pct = Math.round(prog.progress * 100);
@ -376,7 +379,9 @@ export function useChatModelRuntime() {
action: cancelAction,
},
);
} else if (prog.downloaded_bytes > 0 && prog.expected_bytes === 0) {
} else if (prog.downloaded_bytes > 0 && prog.expected_bytes === 0 && prog.progress === 0) {
// Have bytes but no total size -- show bytes only
hasShownProgress = true;
const dlGb = prog.downloaded_bytes / (1024 ** 3);
toast.loading(
"Downloading model...",
@ -387,7 +392,8 @@ export function useChatModelRuntime() {
action: cancelAction,
},
);
} else if (prog.progress >= 1) {
} else if (prog.progress >= 1 && hasShownProgress) {
// Only show "download complete" if we actually showed progress
toast.loading("Loading model...", {
id: toastId,
description: "Download complete. Loading into memory...",