From 1471c63b9653d5910da04d82e9c1ab5502eca3cb Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 16 Mar 2026 06:04:32 +0000 Subject: [PATCH] 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. --- studio/backend/routes/models.py | 9 ++++-- .../assistant-ui/model-selector/pickers.tsx | 28 ++++++++++++++----- .../chat/hooks/use-chat-model-runtime.ts | 12 ++++++-- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 77bb1cf329..02a810997a 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -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) diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index 9ad33936c3..b8b464c56b 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -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(null); - // Cached (already downloaded) repos - const [cachedGguf, setCachedGguf] = useState([]); - const [cachedModels, setCachedModels] = useState([]); + // Cached (already downloaded) repos -- use module-level cache so + // re-mounting the popover does not flash an empty "Downloaded" section. + const [cachedGguf, setCachedGguf] = useState(_cachedGgufCache); + const [cachedModels, setCachedModels] = useState(_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(); + 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 } = diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index ecdd901983..f1d0e16d0c 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -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...",