From 475ba417dc6194ca6667be7b2fe570140aff1f54 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 15 Mar 2026 07:47:43 +0000 Subject: [PATCH] studio: context-aware loading text + download progress bar 1. Loading text: shows "Loading model..." for cached models, "Downloading model..." for new downloads. Toast description adapts accordingly. 2. Download progress: polls /api/models/gguf-download-progress every 2s during downloads, updating the toast with percentage and GB downloaded. Progress is estimated by checking the HF cache folder size against the expected total bytes. 3. Passes isDownloaded and expectedBytes through the full chain from variant click to selectModel for accurate UI state. --- studio/backend/routes/models.py | 40 ++++++++ .../assistant-ui/model-selector/pickers.tsx | 6 +- .../assistant-ui/model-selector/types.ts | 2 + .../src/features/chat/api/chat-api.ts | 12 +++ .../frontend/src/features/chat/chat-page.tsx | 10 +- .../chat/hooks/use-chat-model-runtime.ts | 95 ++++++++++++++++--- 6 files changed, 145 insertions(+), 20 deletions(-) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 1dee85ed82..9d92280274 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -604,6 +604,46 @@ async def get_gguf_variants( ) +@router.get("/gguf-download-progress") +async def get_gguf_download_progress( + repo_id: str = Query(..., description = "HuggingFace repo ID"), + expected_bytes: int = Query(0, description = "Expected total download size in bytes"), + current_subject: str = Depends(get_current_subject), +): + """Return download progress by checking current size of cached GGUF files.""" + import re as _re + try: + if not _re.fullmatch(r"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+", repo_id): + return {"downloaded_bytes": 0, "expected_bytes": expected_bytes, "progress": 0} + + from huggingface_hub import constants as hf_constants + + cache_dir = Path(hf_constants.HF_HUB_CACHE) + target = f"models--{repo_id.replace('/', '--')}".lower() + downloaded_bytes = 0 + for entry in cache_dir.iterdir(): + if entry.name.lower() == target: + # Sum .gguf files in snapshots + incomplete downloads in blobs + for f in entry.rglob("*.gguf"): + downloaded_bytes += f.stat().st_size + # Also check incomplete downloads (blobs without extension) + blobs_dir = entry / "blobs" + if blobs_dir.is_dir(): + for f in blobs_dir.iterdir(): + if f.is_file(): + downloaded_bytes += f.stat().st_size + break + + progress = min(downloaded_bytes / expected_bytes, 1.0) if expected_bytes > 0 else 0 + return { + "downloaded_bytes": downloaded_bytes, + "expected_bytes": expected_bytes, + "progress": round(progress, 3), + } + except Exception: + return {"downloaded_bytes": 0, "expected_bytes": expected_bytes, "progress": 0} + + @router.get("/cached-gguf") async def list_cached_gguf( current_subject: str = Depends(get_current_subject), 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 a37532b8e2..1c52d53915 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -184,11 +184,13 @@ function GgufVariantExpander({ }, [repoId]); const handleVariantClick = useCallback( - (quant: string) => { + (quant: string, downloaded?: boolean, sizeBytes?: number) => { onSelect(repoId, { source: "hub", isLora: false, ggufVariant: quant, + isDownloaded: downloaded, + expectedBytes: sizeBytes, }); }, [repoId, onSelect], @@ -294,7 +296,7 @@ function GgufVariantExpander({