From 3aca6f7243a9b8c1fd1508f70e4204cd80460e66 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Tue, 16 Jun 2026 11:00:49 +0000 Subject: [PATCH 1/4] Studio: stop the llama.cpp update banner flickering and show the download size The banner animated in and out with a motion opacity + scale + translate transition. That transform/opacity transition promotes a GPU compositing layer whose first and last frame can flash for a moment on real displays, which reads as a flicker on appear and again on dismiss/snooze. Drop the animation and render the banner as a plain conditional mount: it appears and leaves cleanly with nothing to flash. Also surface the download size. update-status now reports the size of the prebuilt that Update would fetch (the latest-release asset matching this host's bundle), and the banner shows it as whole MB next to the no-restart note, so the cost of the update is clear before clicking. --- studio/backend/routes/llama.py | 3 + studio/backend/utils/llama_cpp_freshness.py | 103 +++++++++ studio/backend/utils/llama_cpp_update.py | 14 ++ .../src/components/llama-update-banner.tsx | 218 +++++++++--------- .../src/hooks/use-llama-update-check.ts | 4 + 5 files changed, 233 insertions(+), 109 deletions(-) diff --git a/studio/backend/routes/llama.py b/studio/backend/routes/llama.py index 3aae6f42092..5559cc04044 100644 --- a/studio/backend/routes/llama.py +++ b/studio/backend/routes/llama.py @@ -55,6 +55,9 @@ class LlamaUpdateStatusResponse(BaseModel): source_build: bool = Field( False, description = "True when there is no marker (source build) but a prebuilt is offered." ) + update_size_bytes: Optional[int] = Field( + None, description = "Download size of the prebuilt Update would fetch, in bytes." + ) job: LlamaUpdateJob = Field(default_factory = LlamaUpdateJob) diff --git a/studio/backend/utils/llama_cpp_freshness.py b/studio/backend/utils/llama_cpp_freshness.py index 87d0d2ec01e..79063eb0b01 100644 --- a/studio/backend/utils/llama_cpp_freshness.py +++ b/studio/backend/utils/llama_cpp_freshness.py @@ -33,6 +33,8 @@ _marker_cache: dict[str, Optional[dict]] = {} _release_memo: dict[str, tuple[float, Optional[str]]] = {} +# Newest-release asset sizes (name -> bytes), memoized like the tag (24h TTL). +_assets_memo: dict[str, tuple[float, dict[str, int]]] = {} def _cache_dir() -> Path: @@ -180,6 +182,106 @@ def latest_published_release(repo: str, *, force_refresh: bool = False) -> Optio return latest +def _fetch_latest_release_assets(repo: str, timeout: float = 5.0) -> Optional[dict[str, int]]: + """Asset name -> size (bytes) for the newest published release of `repo`, + selected exactly like _fetch_latest_release_tag. None on any failure.""" + import urllib.error + import urllib.request + + url = f"https://api.github.com/repos/{repo}/releases?per_page=30" + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "unsloth-studio-freshness-check", + } + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request(url, headers = headers) + try: + with urllib.request.urlopen(req, timeout = timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + except ( + urllib.error.URLError, + urllib.error.HTTPError, + OSError, + json.JSONDecodeError, + ) as exc: + logger.debug("freshness asset fetch failed", repo = repo, error = str(exc)) + return None + if not isinstance(data, list): + return None + published = [ + r + for r in data + if isinstance(r, dict) + and not r.get("draft") + and not r.get("prerelease") + and isinstance(r.get("tag_name"), str) + and r.get("tag_name") + ] + if not published: + return None + newest = max(published, key = lambda r: r.get("published_at") or "") + assets: dict[str, int] = {} + for a in newest.get("assets") or []: + name, size = a.get("name"), a.get("size") + if isinstance(name, str) and isinstance(size, int): + assets[name] = size + return assets + + +def latest_release_assets(repo: str, *, force_refresh: bool = False) -> Optional[dict[str, int]]: + """Newest-release asset sizes for `repo`, memoized (24h TTL). None when + offline and never fetched. In-memory only -- a restart simply re-fetches.""" + if not repo: + return None + now = time.time() + if not force_refresh: + memo = _assets_memo.get(repo) + if memo and now - memo[0] < _RELEASE_CACHE_TTL_SECONDS: + return memo[1] + assets = _fetch_latest_release_assets(repo) + if assets is None: + memo = _assets_memo.get(repo) + return memo[1] if memo else None + _assets_memo[repo] = (now, assets) + return assets + + +def update_download_size_bytes( + marker: Optional[dict], + latest_tag: Optional[str], + repo: Optional[str], + *, + force_refresh: bool = False, +) -> Optional[int]: + """Download size of the latest-release asset matching this host's installed + bundle (same platform/arch/runtime suffix as the installed asset). None when + there is no marker asset, the latest assets can't be read, or no match.""" + if not marker or not latest_tag or not repo: + return None + installed_asset = marker.get("asset") + if not isinstance(installed_asset, str): + return None + # The platform suffix is tag-independent (e.g. "linux-x64-cuda13-newer.tar.gz"), + # so derive it from the first platform token rather than stripping the tag. + m = re.search(r"-((?:linux|windows|macos|darwin)-.*)$", installed_asset) + if not m: + return None + suffix = m.group(1) + assets = latest_release_assets(repo, force_refresh = force_refresh) + if not assets: + return None + want = f"app-{latest_tag}-{suffix}" + if want in assets: + return assets[want] + # Tag formatting can vary (mix suffixes); fall back to the platform suffix. + for name, size in assets.items(): + if name.endswith(suffix): + return size + return None + + def _parse_installed_at(value: object) -> Optional[datetime]: if not isinstance(value, str) or not value: return None @@ -313,6 +415,7 @@ def reset_caches(*, drop_disk: bool = False) -> None: open (off) instead of pointing at the just-replaced build.""" _marker_cache.clear() _release_memo.clear() + _assets_memo.clear() if drop_disk: import shutil diff --git a/studio/backend/utils/llama_cpp_update.py b/studio/backend/utils/llama_cpp_update.py index c2c6c674326..96727b1e0b9 100644 --- a/studio/backend/utils/llama_cpp_update.py +++ b/studio/backend/utils/llama_cpp_update.py @@ -40,6 +40,7 @@ parse_base_build, read_install_marker, reset_caches, + update_download_size_bytes, ) logger = structlog.get_logger(__name__) @@ -345,6 +346,18 @@ def get_update_status(*, force_refresh: bool = False) -> dict: # (see llama_cpp_freshness.is_behind). update_available = bool(freshness.get("has_marker") and freshness.get("behind")) + # Size of the prebuilt that Update would download, for the banner. Only when + # an update is offered; fails open to None (offline / no matching asset). + update_size_bytes = None + if update_available: + try: + update_size_bytes = update_download_size_bytes( + marker, latest, freshness.get("published_repo") or repo, + force_refresh = force_refresh, + ) + except Exception as exc: # pragma: no cover - network defensive + logger.debug("llama update: size lookup failed", error = str(exc)) + with _job_lock: job = dict(_job) @@ -358,6 +371,7 @@ def get_update_status(*, force_refresh: bool = False) -> dict: "installed_at_utc": freshness.get("installed_at_utc"), "age_days": freshness.get("age_days"), "source_build": False, + "update_size_bytes": update_size_bytes, "job": job, } diff --git a/studio/frontend/src/components/llama-update-banner.tsx b/studio/frontend/src/components/llama-update-banner.tsx index 8c8fcc36462..0383d8a1500 100644 --- a/studio/frontend/src/components/llama-update-banner.tsx +++ b/studio/frontend/src/components/llama-update-banner.tsx @@ -7,10 +7,7 @@ import { useShowLlamaUpdateBanner } from "@/hooks/use-llama-update-pref"; import { toast } from "@/lib/toast"; import { cn } from "@/lib/utils"; import { Download } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; import { type ReactElement, useEffect, useRef, useState } from "react"; - -const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1]; // Backend progress is coarse (5% steps, ~0.9 max) and the extract tail emits no // signal. Creep toward this cap so the bar keeps moving rather than freezing. const RUNNING_CAP = 0.95; @@ -114,6 +111,12 @@ export function LlamaUpdateBanner({ const show = visible && status != null && (status.update_available || applying); + const sizeBytes = status?.update_size_bytes ?? null; + // Round to whole MB; these prebuilts are hundreds of MB. + const sizeLabel = + sizeBytes && sizeBytes > 0 + ? `${Math.round(sizeBytes / (1024 * 1024))} MB` + : null; const updateProgress = status?.job.progress ?? null; const jobSucceeded = status?.job.state === "success"; // Drives the bar so it animates continuously; aria reports the real value. @@ -123,113 +126,110 @@ export function LlamaUpdateBanner({ jobSucceeded, ); - return ( - - {show ? ( - -
- {applying ? null : ( - - )} - -
-