Add Studio web update banner and release version display (#5308)
Some checks are pending
Core / Core (HF=default + TRL=default) (push) Waiting to run
Core / Core (HF=4.57.6 + TRL<1) (push) Waiting to run
Core / Core (HF=latest + TRL=latest) (push) Waiting to run
Core / llama.cpp build + smoke (push) Waiting to run
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Waiting to run
MLX CI on Mac M1 / dispatch (push) Waiting to run
Security audit / advisory audit (pip + npm + cargo) (push) Waiting to run
Security audit / pip scan-packages :: extras (push) Waiting to run
Security audit / pip scan-packages :: studio (push) Waiting to run
Security audit / pip scan-packages :: hf-stack (push) Waiting to run
Studio API CI / Studio API & Auth Tests (push) Waiting to run
Backend CI / Repo tests (CPU) (push) Waiting to run
Backend CI / (Python 3.10) (push) Waiting to run
Backend CI / (Python 3.11) (push) Waiting to run
Backend CI / (Python 3.12) (push) Waiting to run
Backend CI / (Python 3.13) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Studio GGUF CI / Tool calling Tests (push) Waiting to run
Studio GGUF CI / JSON, images (push) Waiting to run
Mac Studio API CI / Studio API & Auth Tests (push) Waiting to run
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Mac Studio GGUF CI / Tool calling Tests (push) Waiting to run
Mac Studio GGUF CI / JSON, images (push) Waiting to run
Mac Studio UI CI / Chat UI Tests (push) Waiting to run
Mac Studio Update CI / Studio Updating Tests (push) Waiting to run
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Studio UI CI / Chat UI Tests (push) Waiting to run
Studio Update CI / Studio Updating Tests (push) Waiting to run
Windows Studio API CI / Studio API & Auth Tests (push) Waiting to run
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Windows Studio GGUF CI / Tool calling Tests (push) Waiting to run
Windows Studio GGUF CI / JSON, images (push) Waiting to run
Windows Studio UI CI / Chat UI Tests (push) Waiting to run
Windows Studio Update CI / Studio Updating Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run

* Add Studio web update and release version display

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Show package version in Studio settings

* Break training unload guard barrel cycle

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
This commit is contained in:
Wasim Yousef Said 2026-05-11 16:24:01 +02:00 committed by GitHub
parent a6462876de
commit 23cebfaf98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1426 additions and 72 deletions

View file

@ -2,6 +2,10 @@
set -euo pipefail
# PyPI/Studio release publishing must use `./build.sh publish` (or an
# equivalent stamp -> build -> verify-dist -> upload flow) so packaged Studio
# artifacts include the display-only Studio release version.
# 1. Build frontend (Vite outputs to dist/)
cd studio/frontend
@ -70,10 +74,33 @@ cd ../..
# 2. Clean old artifacts
rm -rf build dist *.egg-info
# 3. Build wheel
# 3. Stamp display-only Studio release metadata for packaged builds.
_STUDIO_BUILD_INFO="studio/backend/utils/_studio_release_build.py"
_STUDIO_BUILD_INFO_BACKUP="$(mktemp)"
cp "$_STUDIO_BUILD_INFO" "$_STUDIO_BUILD_INFO_BACKUP"
_restore_studio_build_info() {
cp "$_STUDIO_BUILD_INFO_BACKUP" "$_STUDIO_BUILD_INFO" 2>/dev/null || true
rm -f "$_STUDIO_BUILD_INFO_BACKUP"
}
trap _restore_studio_build_info EXIT
if [ "${1:-}" = "publish" ]; then
STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py --require-release)"
else
STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py)"
fi
# 4. Build wheel/sdist
python -m build
# 4. Optionally publish
if [ "${1:-}" = "publish" ]; then
python scripts/stamp_studio_release.py --verify-dist dist --expected "$STUDIO_STAMPED_VERSION"
fi
_restore_studio_build_info
trap - EXIT
# 5. Optionally publish
if [ "${1:-}" = "publish" ]; then
python -m twine upload dist/*
fi

257
scripts/stamp_studio_release.py Executable file
View file

@ -0,0 +1,257 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Stamp and verify display-only Studio release metadata for builds."""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
import tarfile
import zipfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
BUILD_INFO_PATH = (
REPO_ROOT / "studio" / "backend" / "utils" / "_studio_release_build.py"
)
BUILD_INFO_SUFFIX = "studio/backend/utils/_studio_release_build.py"
VERSION_RE = re.compile(r"^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?$")
GIT_DESCRIBE_SUFFIX_RE = re.compile(r"-\d+-g[0-9A-Fa-f]+(?:-dirty)?$")
MAX_VERSION_LENGTH = 64
PLACEHOLDER = """# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
\"\"\"Build-stamped Studio release metadata.
Release builds may rewrite this module in the build workspace before creating
Python artifacts. Keep the committed value neutral so source checkouts do not
accidentally report a stale release tag.
\"\"\"
STUDIO_RELEASE_VERSION = None
"""
def is_valid_version(value: object) -> bool:
if not isinstance(value, str):
return False
version = value.strip()
if not version or len(version) > MAX_VERSION_LENGTH:
return False
if version.endswith("-dirty") or GIT_DESCRIBE_SUFFIX_RE.search(version):
return False
return VERSION_RE.fullmatch(version) is not None
def _exact_git_tag() -> str | None:
try:
result = subprocess.run(
[
"git",
"describe",
"--tags",
"--exact-match",
"--match",
"v[0-9]*",
"HEAD",
],
cwd = REPO_ROOT,
check = False,
stdout = subprocess.PIPE,
stderr = subprocess.DEVNULL,
text = True,
timeout = 2.0,
)
except (OSError, subprocess.TimeoutExpired):
return None
if result.returncode != 0:
return None
tag = result.stdout.strip()
return tag if is_valid_version(tag) else None
def _git_worktree_is_dirty() -> bool:
try:
result = subprocess.run(
["git", "status", "--porcelain"],
cwd = REPO_ROOT,
check = False,
stdout = subprocess.PIPE,
stderr = subprocess.DEVNULL,
text = True,
timeout = 2.0,
)
except (OSError, subprocess.TimeoutExpired):
return True
if result.returncode != 0:
return True
return bool(result.stdout.strip())
def _github_tag() -> str | None:
if os.environ.get("GITHUB_REF_TYPE") != "tag":
return None
github_ref = os.environ.get("GITHUB_REF_NAME", "").strip()
return github_ref or None
def resolve_version() -> tuple[str | None, str]:
env_version = os.environ.get("UNSLOTH_STUDIO_RELEASE_VERSION", "").strip()
if env_version:
return (env_version, "UNSLOTH_STUDIO_RELEASE_VERSION")
github_ref = _github_tag()
if github_ref:
return (github_ref, "GITHUB_REF_NAME")
git_tag = _exact_git_tag()
if git_tag:
return (git_tag, "exact git tag")
return (None, "none")
def build_info_source(version: str | None) -> str:
literal = repr(version) if version is not None else "None"
return f'''# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Build-stamped Studio release metadata."""
STUDIO_RELEASE_VERSION = {literal}
'''
def _env_version_conflicts(version: str) -> list[tuple[str, str]]:
conflicts: list[tuple[str, str]] = []
github_ref = _github_tag()
if github_ref and is_valid_version(github_ref) and github_ref != version:
conflicts.append(("GITHUB_REF_NAME", github_ref))
git_tag = _exact_git_tag()
if git_tag and git_tag != version:
conflicts.append(("exact git tag", git_tag))
return conflicts
def stamp(require_release: bool) -> int:
version, source = resolve_version()
if version is not None and not is_valid_version(version):
print(
f"Invalid Studio release version from {source}: {version!r}",
file = sys.stderr,
)
return 2
if version is not None and source == "UNSLOTH_STUDIO_RELEASE_VERSION":
conflicts = _env_version_conflicts(version)
if conflicts:
details = ", ".join(f"{name}={value!r}" for name, value in conflicts)
print(
"UNSLOTH_STUDIO_RELEASE_VERSION does not match available "
f"release tag metadata: {details}",
file = sys.stderr,
)
return 2
if require_release and source == "exact git tag" and _git_worktree_is_dirty():
print(
"Refusing to publish from a dirty exact-tag checkout. Set "
"UNSLOTH_STUDIO_RELEASE_VERSION explicitly from release automation "
"or publish from a clean tag checkout.",
file = sys.stderr,
)
return 2
if version is None:
if require_release:
print(
"No Studio release version available. Set "
"UNSLOTH_STUDIO_RELEASE_VERSION, build from a GitHub tag, "
"or run from an exact local Studio release tag.",
file = sys.stderr,
)
return 2
BUILD_INFO_PATH.write_text(PLACEHOLDER, encoding = "utf-8")
print("dev")
return 0
BUILD_INFO_PATH.write_text(build_info_source(version), encoding = "utf-8")
print(f"Stamping Studio release version {version} from {source}", file = sys.stderr)
print(version)
return 0
def _read_wheel_member(path: Path) -> str | None:
with zipfile.ZipFile(path) as archive:
for name in archive.namelist():
if name.endswith(BUILD_INFO_SUFFIX):
return archive.read(name).decode("utf-8")
return None
def _read_sdist_member(path: Path) -> str | None:
with tarfile.open(path) as archive:
for member in archive.getmembers():
if member.name.endswith(BUILD_INFO_SUFFIX):
extracted = archive.extractfile(member)
if extracted is None:
return None
return extracted.read().decode("utf-8")
return None
def verify_dist(expected: str, dist_dir: Path) -> int:
if not is_valid_version(expected):
print(f"Invalid expected Studio release version: {expected!r}", file = sys.stderr)
return 2
artifacts = list(dist_dir.glob("*.whl")) + list(dist_dir.glob("*.tar.gz"))
if not artifacts:
print(f"No wheel or sdist artifacts found in {dist_dir}", file = sys.stderr)
return 2
expected_line = f"STUDIO_RELEASE_VERSION = {expected!r}"
failures: list[str] = []
for artifact in artifacts:
if artifact.suffix == ".whl":
content = _read_wheel_member(artifact)
else:
content = _read_sdist_member(artifact)
if content is None:
failures.append(f"{artifact.name}: missing {BUILD_INFO_SUFFIX}")
elif expected_line not in content:
failures.append(f"{artifact.name}: Studio release version mismatch")
if failures:
for failure in failures:
print(failure, file = sys.stderr)
return 2
print(f"Verified Studio release version {expected} in {len(artifacts)} artifact(s)")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description = __doc__)
parser.add_argument("--require-release", action = "store_true")
parser.add_argument("--verify-dist", type = Path)
parser.add_argument("--expected")
args = parser.parse_args()
if args.verify_dist is not None:
if not args.expected:
parser.error("--verify-dist requires --expected")
return verify_dist(args.expected, args.verify_dist)
return stamp(require_release = args.require_release)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -134,6 +134,11 @@ import utils.hardware.hardware as _hw_module
from utils.cache_cleanup import clear_unsloth_compiled_cache
from utils.native_path_leases import native_path_leases_supported
from utils.update_status import (
get_studio_install_source_status,
get_studio_update_status,
)
from utils.studio_version import get_studio_version
def get_unsloth_version() -> str:
@ -155,6 +160,7 @@ def get_unsloth_version() -> str:
UNSLOTH_VERSION = get_unsloth_version()
STUDIO_VERSION = get_studio_version()
@asynccontextmanager
@ -296,6 +302,7 @@ async def health_check():
"timestamp": datetime.now().isoformat(),
"service": "Unsloth UI Backend",
"version": UNSLOTH_VERSION,
"studio_version": STUDIO_VERSION,
"device_type": device_type,
"chat_only": _hw_module.CHAT_ONLY,
"desktop_protocol_version": 1,
@ -308,6 +315,18 @@ async def health_check():
}
@app.get("/api/studio/install-source")
def studio_install_source(_current_subject: str = Depends(get_current_subject)):
"""Return source-aware install metadata without remote update checks."""
return get_studio_install_source_status(UNSLOTH_VERSION)
@app.get("/api/studio/update-status")
def studio_update_status(_current_subject: str = Depends(get_current_subject)):
"""Return source-aware manual update status for browser-served Studio."""
return get_studio_update_status(UNSLOTH_VERSION)
@app.post("/api/shutdown")
async def shutdown_server(
request: Request,

View file

@ -3,6 +3,7 @@ typer
fastapi
uvicorn
pydantic
packaging
matplotlib
pandas
nest_asyncio

View file

@ -0,0 +1,11 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Build-stamped Studio release metadata.
Release builds may rewrite this module in the build workspace before creating
Python artifacts. Keep the committed value neutral so source checkouts do not
accidentally report a stale release tag.
"""
STUDIO_RELEASE_VERSION = None

View file

@ -0,0 +1,92 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Network-free Studio release version resolution for display-only UI."""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from utils import _studio_release_build
_DEV_VERSION = "dev"
_GIT_TIMEOUT_SECONDS = 1.0
_STUDIO_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?$")
_GIT_DESCRIBE_SUFFIX_RE = re.compile(r"-\d+-g[0-9A-Fa-f]+(?:-dirty)?$")
_MAX_VERSION_LENGTH = 64
def is_valid_studio_release_version(value: object) -> bool:
"""Return True for Studio release tags such as ``v0.1.39-beta``."""
if not isinstance(value, str):
return False
version = value.strip()
if not version or len(version) > _MAX_VERSION_LENGTH:
return False
if version.endswith("-dirty") or _GIT_DESCRIBE_SUFFIX_RE.search(version):
return False
return _STUDIO_TAG_RE.fullmatch(version) is not None
def _repo_root() -> Path:
return Path(__file__).resolve().parents[3]
def _path_is_in_site_packages(path: Path) -> bool:
return any(part in {"site-packages", "dist-packages"} for part in path.parts)
def _is_source_checkout(repo_root: Path) -> bool:
return (repo_root / ".git").exists() and not _path_is_in_site_packages(
Path(__file__).resolve()
)
def _exact_git_studio_tag(repo_root: Path) -> str | None:
try:
result = subprocess.run(
[
"git",
"describe",
"--tags",
"--exact-match",
"--match",
"v[0-9]*",
"HEAD",
],
cwd = repo_root,
check = False,
stdout = subprocess.PIPE,
stderr = subprocess.DEVNULL,
text = True,
timeout = _GIT_TIMEOUT_SECONDS,
)
except (OSError, subprocess.TimeoutExpired):
return None
if result.returncode != 0:
return None
tag = result.stdout.strip()
return tag if is_valid_studio_release_version(tag) else None
def get_studio_version(repo_root: Path | None = None) -> str:
"""Return the installed Studio release tag for display, or ``dev``.
This value is intentionally separate from the PyPI ``unsloth`` package
version used by update checks. It never performs network requests.
"""
resolved_repo_root = repo_root or _repo_root()
if _is_source_checkout(resolved_repo_root):
git_tag = _exact_git_studio_tag(resolved_repo_root)
return git_tag if git_tag is not None else _DEV_VERSION
stamped_version = _studio_release_build.STUDIO_RELEASE_VERSION
if is_valid_studio_release_version(stamped_version):
return stamped_version.strip()
return _DEV_VERSION

View file

@ -0,0 +1,374 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Web update status helpers for browser-served Unsloth Studio.
This module is intentionally side-effect light: no network work happens at
import time or from /api/health. The PyPI check is lazy, cached, and only used
for normal PyPI-managed installs.
"""
from __future__ import annotations
import json
import os
import threading
import time
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError, distribution
from pathlib import Path
from typing import Any
from packaging.version import InvalidVersion, Version
PACKAGE_NAME = "unsloth"
PYPI_JSON_URL = "https://pypi.org/pypi/unsloth/json"
PYPI_TIMEOUT_SECONDS = 3
PYPI_RESPONSE_MAX_BYTES = 5 * 1024 * 1024
PYPI_SUCCESS_TTL_SECONDS = 12 * 60 * 60
PYPI_FAILURE_TTL_SECONDS = 60 * 60
RELEASE_NOTES_URL = "https://unsloth.ai/docs/new/changelog"
DISABLE_ENV_VAR = "UNSLOTH_DISABLE_UPDATE_CHECK"
LOCAL_INSTALL_SOURCES = {"editable", "local_path", "vcs", "local_repo"}
@dataclass(frozen = True)
class LatestVersionResult:
latest_version: str | None
checked_at: str
reason: str | None = None
error: str | None = None
@dataclass
class _LatestVersionCacheEntry:
result: LatestVersionResult
expires_at: float
_cache_condition = threading.Condition()
_latest_version_cache: _LatestVersionCacheEntry | None = None
_latest_version_fetching = False
def reset_update_status_cache() -> None:
"""Clear the in-process PyPI cache. Intended for tests."""
global _latest_version_cache, _latest_version_fetching
with _cache_condition:
_latest_version_cache = None
_latest_version_fetching = False
_cache_condition.notify_all()
def detect_install_source() -> str:
"""Return a coarse install source without exposing local paths.
Sources are intentionally conservative. PEP 610 local/vcs metadata wins.
Legacy source installs are treated as local only when package files resolve
outside site-packages/dist-packages and under a Git checkout.
"""
try:
dist = distribution(PACKAGE_NAME)
except PackageNotFoundError:
return (
"local_repo"
if _path_has_git_parent(_repo_root_from_this_file())
else "unknown"
)
try:
direct_url = dist.read_text("direct_url.json")
except Exception:
return "unknown"
if direct_url:
return _source_from_direct_url(direct_url)
for package_path in _distribution_package_paths(dist):
if not _path_is_under_python_package_dir(package_path) and _path_has_git_parent(
package_path
):
return "local_repo"
return "pypi"
def get_studio_install_source_status(current_version: str) -> dict[str, Any]:
"""Return install-source metadata without remote update checks."""
install_source = detect_install_source()
reason = None
if install_source in LOCAL_INSTALL_SOURCES:
reason = "local_source"
elif install_source == "unknown":
reason = "unknown_source"
return _status_response(
current_version = current_version,
latest_version = None,
install_source = install_source,
reason = reason,
)
def get_studio_update_status(current_version: str) -> dict[str, Any]:
"""Return public, read-only update status for the web UI."""
install_source = detect_install_source()
if os.environ.get(DISABLE_ENV_VAR) == "1":
return _status_response(
current_version = current_version,
latest_version = None,
install_source = install_source,
reason = "disabled",
)
if install_source in LOCAL_INSTALL_SOURCES:
return _status_response(
current_version = current_version,
latest_version = None,
install_source = install_source,
reason = "local_source",
)
if install_source != "pypi":
return _status_response(
current_version = current_version,
latest_version = None,
install_source = install_source,
reason = "unknown_source",
)
current = _parse_current_version(current_version)
if current is None:
return _status_response(
current_version = current_version,
latest_version = None,
install_source = install_source,
reason = "invalid_current_version"
if current_version != "dev"
else "dev_build",
)
latest_result = get_latest_pypi_version()
if latest_result.latest_version is None:
return _status_response(
current_version = current_version,
latest_version = None,
install_source = install_source,
reason = latest_result.reason or "offline",
error = latest_result.error,
checked_at = latest_result.checked_at,
)
try:
latest = Version(latest_result.latest_version)
except InvalidVersion:
return _status_response(
current_version = current_version,
latest_version = latest_result.latest_version,
install_source = install_source,
reason = "invalid_latest_version",
error = "PyPI returned an invalid version.",
checked_at = latest_result.checked_at,
)
if latest > current:
return _status_response(
current_version = current_version,
latest_version = latest_result.latest_version,
install_source = install_source,
update_available = True,
can_show_web_notification = True,
checked_at = latest_result.checked_at,
)
return _status_response(
current_version = current_version,
latest_version = latest_result.latest_version,
install_source = install_source,
reason = "current_not_older",
checked_at = latest_result.checked_at,
)
def get_latest_pypi_version() -> LatestVersionResult:
"""Return the latest PyPI version using a small in-process TTL cache."""
global _latest_version_cache, _latest_version_fetching
while True:
now = time.monotonic()
with _cache_condition:
if _latest_version_cache and _latest_version_cache.expires_at > now:
return _latest_version_cache.result
if not _latest_version_fetching:
_latest_version_fetching = True
break
_cache_condition.wait(timeout = PYPI_TIMEOUT_SECONDS + 1)
try:
result = _fetch_latest_pypi_version()
except Exception:
result = LatestVersionResult(
latest_version = None,
checked_at = _utc_now_iso(),
reason = "offline",
error = "Could not check PyPI update metadata.",
)
ttl = (
PYPI_SUCCESS_TTL_SECONDS if result.latest_version else PYPI_FAILURE_TTL_SECONDS
)
with _cache_condition:
_latest_version_cache = _LatestVersionCacheEntry(
result = result,
expires_at = time.monotonic() + ttl,
)
_latest_version_fetching = False
_cache_condition.notify_all()
return result
def _fetch_latest_pypi_version() -> LatestVersionResult:
checked_at = _utc_now_iso()
request = urllib.request.Request(
PYPI_JSON_URL,
headers = {"User-Agent": "unsloth-studio-update-check"},
)
try:
with urllib.request.urlopen(request, timeout = PYPI_TIMEOUT_SECONDS) as response:
body = response.read(PYPI_RESPONSE_MAX_BYTES + 1)
if len(body) > PYPI_RESPONSE_MAX_BYTES:
return LatestVersionResult(
latest_version = None,
checked_at = checked_at,
reason = "malformed_response",
error = "PyPI returned oversized update metadata.",
)
payload = json.loads(body.decode("utf-8"))
except json.JSONDecodeError:
return LatestVersionResult(
latest_version = None,
checked_at = checked_at,
reason = "malformed_response",
error = "PyPI returned malformed update metadata.",
)
except OSError:
return LatestVersionResult(
latest_version = None,
checked_at = checked_at,
reason = "offline",
error = "Could not reach PyPI for update metadata.",
)
latest = (
payload.get("info", {}).get("version") if isinstance(payload, dict) else None
)
if not isinstance(latest, str) or not latest.strip():
return LatestVersionResult(
latest_version = None,
checked_at = checked_at,
reason = "malformed_response",
error = "PyPI update metadata did not include a version.",
)
return LatestVersionResult(latest_version = latest.strip(), checked_at = checked_at)
def _status_response(
*,
current_version: str,
latest_version: str | None,
install_source: str,
reason: str | None = None,
error: str | None = None,
update_available: bool = False,
can_show_web_notification: bool = False,
checked_at: str | None = None,
) -> dict[str, Any]:
return {
"current_version": current_version,
"latest_version": latest_version,
"update_available": update_available,
"install_source": install_source,
"can_show_web_notification": can_show_web_notification,
"release_notes_url": RELEASE_NOTES_URL,
"checked_at": checked_at or _utc_now_iso(),
"reason": reason,
"error": error,
}
def _source_from_direct_url(direct_url: str) -> str:
try:
payload = json.loads(direct_url)
except json.JSONDecodeError:
return "unknown"
if not isinstance(payload, dict):
return "unknown"
dir_info = payload.get("dir_info")
if isinstance(dir_info, dict) and dir_info.get("editable") is True:
return "editable"
if isinstance(payload.get("vcs_info"), dict):
return "vcs"
url = payload.get("url")
if isinstance(url, str) and url.startswith("file:"):
return "local_path"
return "unknown"
def _distribution_package_paths(dist: Any) -> list[Path]:
paths: list[Path] = []
files = getattr(dist, "files", None) or []
for file in files:
text = str(file)
if not text.startswith(("unsloth/", "unsloth_cli/", "studio/")):
continue
try:
paths.append(Path(dist.locate_file(file)).resolve())
except OSError:
continue
return paths
def _path_is_under_python_package_dir(path: Path) -> bool:
return any(part in {"site-packages", "dist-packages"} for part in path.parts)
def _path_has_git_parent(path: Path) -> bool:
for candidate in (path, *path.parents):
if (candidate / ".git").exists():
return True
return False
def _repo_root_from_this_file() -> Path:
# update_status.py -> utils -> backend -> studio -> repo root
try:
return Path(__file__).resolve().parents[3]
except IndexError:
return Path(__file__).resolve().parent
def _parse_current_version(current_version: str) -> Version | None:
if current_version == "dev":
return None
try:
return Version(current_version)
except InvalidVersion:
return None
def _utc_now_iso() -> str:
return (
datetime.now(timezone.utc)
.replace(microsecond = 0)
.isoformat()
.replace("+00:00", "Z")
)

View file

@ -9,6 +9,7 @@ import {
shouldUseCustomWindowTitlebar,
} from "@/components/tauri/window-titlebar";
import { Toaster } from "@/components/ui/sonner";
import { WebUpdateBanner } from "@/components/web/update-banner";
import { getTauriAuthFailure, tauriAutoAuth } from "@/features/auth";
import { NativeIntentDrain } from "@/features/native-intents/native-intent-drain";
import { useTauriBackend, type BackendStatus } from "@/hooks/use-tauri-backend";
@ -154,6 +155,13 @@ const HIDDEN_TITLEBAR_SIDEBAR_ROUTES = new Set([
"/signup",
]);
const WEB_UPDATE_HIDDEN_ROUTES = new Set([
"/onboarding",
"/login",
"/change-password",
"/signup",
]);
function TauriWrapper({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const {
@ -234,7 +242,14 @@ function TauriWrapper({ children }: { children: ReactNode }) {
return () => { disposed = true; };
}, [status, desktopAuthRetry]);
if (!isTauri) return <>{children}</>;
if (!isTauri) {
return (
<>
{children}
<WebUpdateBanner enabled={!WEB_UPDATE_HIDDEN_ROUTES.has(pathname)} />
</>
);
}
const showApp = status === "running" && desktopAuthReady;
const startupStatus = status === "running" ? "starting" : status;

View file

@ -0,0 +1,136 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { useWebUpdateCheck } from "@/hooks/use-web-update-check";
import { isTauri } from "@/lib/api-base";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { AnimatePresence, motion } from "motion/react";
import { type ReactElement, useEffect, useRef, useState } from "react";
const STUDIO_UPDATE_CMD = "unsloth studio update";
const RELEASE_NOTES_URL = "https://unsloth.ai/docs/new/changelog";
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
interface WebUpdateBannerProps {
enabled?: boolean;
}
export function WebUpdateBanner({
enabled = true,
}: WebUpdateBannerProps): ReactElement | null {
const { status, dismiss } = useWebUpdateCheck({ enabled });
const [copiedVersion, setCopiedVersion] = useState<string | null>(null);
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current);
}
};
}, []);
if (isTauri) {
return null;
}
async function handleCopyCommand() {
if (!(await copyToClipboard(STUDIO_UPDATE_CMD))) {
return;
}
setCopiedVersion(status?.latestVersion ?? null);
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current);
}
dismissTimerRef.current = setTimeout(() => dismiss(), 900);
}
return (
<AnimatePresence>
{status ? (
<motion.div
initial={{ opacity: 0, y: -12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.97 }}
transition={{ duration: 0.35, ease: EASE_OUT_QUART }}
className="fixed top-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[380px]"
>
<div className="corner-squircle relative overflow-hidden border border-border/60 bg-background/95 px-5 py-4 shadow-lg backdrop-blur-md">
<button
type="button"
onClick={dismiss}
className="absolute top-3 right-3 flex size-6 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
aria-label="Dismiss update notification"
>
<svg
aria-hidden="true"
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 3L3 11M3 3l8 8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
<div className="flex items-start gap-2 pr-5">
<span className="text-lg" aria-hidden="true">
🦥
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">
Package update available: {status.latestVersion}
</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Installed package: {status.currentVersion}. To update Studio,
run this in your terminal, then restart Studio.
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
size="sm"
className="corner-squircle"
onClick={handleCopyCommand}
>
{copiedVersion === status.latestVersion
? "Copied"
: "Copy command"}
</Button>
<Button
size="sm"
variant="outline"
className="corner-squircle"
asChild={true}
>
<a
href={RELEASE_NOTES_URL}
target="_blank"
rel="noopener noreferrer"
>
Release notes
</a>
</Button>
<Button
size="sm"
variant="ghost"
className="corner-squircle"
onClick={dismiss}
>
Later
</Button>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
);
}

View file

@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { cn } from "@/lib/utils";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
@ -14,11 +14,42 @@ const STUDIO_UPDATE_FALLBACK_UNIX_CMD =
"curl -fsSL https://unsloth.ai/install.sh | sh";
const STUDIO_UPDATE_FALLBACK_WINDOWS_CMD =
"irm https://unsloth.ai/install.ps1 | iex";
const STUDIO_LOCAL_PULL_CMD = "git pull --ff-only";
const STUDIO_LOCAL_UPDATE_CMD = "unsloth studio update --local";
const STUDIO_LOCAL_FALLBACK_UNIX_CMD = "./install.sh --local";
const STUDIO_LOCAL_FALLBACK_WINDOWS_CMD = ".\\install.ps1 --local";
export type UpdateShell = "windows" | "unix";
export type UpdateInstallSource =
| "pypi"
| "editable"
| "local_path"
| "vcs"
| "local_repo"
| "unknown";
type UpdateInstallSourceState = UpdateInstallSource | "loading";
function getStudioUpdateInstructionLine(shell: UpdateShell): string {
return shell === "windows" ? "Open PowerShell and run:" : "Open Terminal and run:";
return shell === "windows"
? "Open PowerShell and run:"
: "Open Terminal and run:";
}
function isLocalInstallSource(
installSource?: UpdateInstallSourceState | null,
): boolean {
return Boolean(
installSource &&
installSource !== "pypi" &&
installSource !== "unknown" &&
installSource !== "loading",
);
}
function isUnknownInstallSource(
installSource?: UpdateInstallSourceState | null,
): boolean {
return installSource === "unknown";
}
function CopyableCommand({
@ -54,7 +85,7 @@ function CopyableCommand({
<div className="flex min-w-0 items-stretch overflow-hidden rounded-md border border-border bg-muted/40">
<input
type="text"
readOnly
readOnly={true}
value={command}
className="min-w-0 flex-1 bg-transparent px-2 py-1.5 font-mono text-[11px] text-foreground outline-none"
title={command}
@ -68,7 +99,10 @@ function CopyableCommand({
aria-label={copied ? `${copyLabel} copied` : `Copy ${copyLabel}`}
>
{copied ? (
<HugeiconsIcon icon={Tick02Icon} className="size-4 text-emerald-600" />
<HugeiconsIcon
icon={Tick02Icon}
className="size-4 text-emerald-600"
/>
) : (
<HugeiconsIcon icon={Copy01Icon} className="size-4" />
)}
@ -77,24 +111,38 @@ function CopyableCommand({
);
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: keep source-specific update guidance in one component so the command matrix stays visible.
export function UpdateStudioInstructions({
className,
defaultShell,
installSource,
showTitle = true,
}: {
className?: string;
defaultShell: UpdateShell;
installSource?: UpdateInstallSourceState | null;
showTitle?: boolean;
}): ReactElement {
const [shell, setShell] = useState<UpdateShell>(defaultShell);
const prefersReducedMotion = useReducedMotion();
const windows = shell === "windows";
const localInstallSource = isLocalInstallSource(installSource);
const checkoutInstallSource =
installSource === "editable" || installSource === "local_repo";
const packagedSourceInstall =
installSource === "vcs" || installSource === "local_path";
const loadingInstallSource = installSource === "loading";
const unknownInstallSource = isUnknownInstallSource(installSource);
const fadeTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.16, ease: [0.165, 0.84, 0.44, 1] as const };
const fadeInitial = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 2 };
const fadeInitial = prefersReducedMotion
? { opacity: 1 }
: { opacity: 0, y: 2 };
const fadeAnimate = { opacity: 1, y: 0 };
const fadeExit = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: -2 };
const fadeExit = prefersReducedMotion
? { opacity: 1 }
: { opacity: 0, y: -2 };
useEffect(() => {
setShell(defaultShell);
@ -133,9 +181,9 @@ export function UpdateStudioInstructions({
onClick={() => setShell("unix")}
className={cn(
"px-0.5 py-0.5 font-medium transition-colors",
!windows
? "text-foreground"
: "text-muted-foreground hover:text-emerald-600",
windows
? "text-muted-foreground hover:text-emerald-600"
: "text-foreground",
)}
aria-pressed={!windows}
>
@ -143,43 +191,157 @@ export function UpdateStudioInstructions({
</button>
</div>
</div>
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={`instruction-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
className="text-xs text-muted-foreground leading-relaxed"
>
{getStudioUpdateInstructionLine(shell)}
</motion.p>
</AnimatePresence>
<CopyableCommand command={STUDIO_UPDATE_CMD} copyLabel="update command" />
<p className="text-xs text-muted-foreground leading-relaxed">
If that fails or unsloth studio update is unavailable, run:
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`fallback-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
{loadingInstallSource ? (
<p className="text-xs text-muted-foreground leading-relaxed">
Checking how Studio was installed
</p>
) : localInstallSource ? (
<>
<p className="text-xs text-muted-foreground leading-relaxed">
Source or local install detected. To avoid replacing it with PyPI,
update from the checkout or source you originally installed from.
</p>
{checkoutInstallSource ? (
<>
<p className="text-xs text-muted-foreground leading-relaxed">
Pull latest changes from your Unsloth repo checkout, then update
Studio locally:
</p>
<CopyableCommand
command={STUDIO_LOCAL_PULL_CMD}
copyLabel="git pull command"
/>
<CopyableCommand
command={STUDIO_LOCAL_UPDATE_CMD}
copyLabel="local update command"
/>
<p className="text-xs text-muted-foreground leading-relaxed">
If the Studio update command is unavailable, run the local
installer from that checkout:
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`local-fallback-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
<CopyableCommand
command={
windows
? STUDIO_LOCAL_FALLBACK_WINDOWS_CMD
: STUDIO_LOCAL_FALLBACK_UNIX_CMD
}
copyLabel="local installer command"
/>
</motion.div>
</AnimatePresence>
</>
) : null}
{packagedSourceInstall ? (
<>
<p className="text-xs text-muted-foreground leading-relaxed">
This looks like a source or VCS package install. Reinstall from
the original local path or Git URL you used.
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
If you still have the Unsloth repo checkout, run the local
installer from that checkout:
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`source-fallback-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
<CopyableCommand
command={
windows
? STUDIO_LOCAL_FALLBACK_WINDOWS_CMD
: STUDIO_LOCAL_FALLBACK_UNIX_CMD
}
copyLabel="local installer command"
/>
</motion.div>
</AnimatePresence>
</>
) : null}
<p className="text-xs text-muted-foreground leading-relaxed">
Restart Studio after updating for changes to take effect.
</p>
</>
) : unknownInstallSource ? (
<>
<p className="text-xs text-muted-foreground leading-relaxed">
Studio could not detect how it was installed. Check how you
installed Studio first, then choose the matching update path.
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
For curl or PyPI installs, run:
</p>
<CopyableCommand
command={
windows
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
}
copyLabel="fallback command"
command={STUDIO_UPDATE_CMD}
copyLabel="update command"
/>
</motion.div>
</AnimatePresence>
<p className="text-xs text-muted-foreground leading-relaxed">
Restart Studio after updating for changes to take effect.
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
For local checkout installs, update from that checkout instead and
use the local update command:
</p>
<CopyableCommand
command={STUDIO_LOCAL_UPDATE_CMD}
copyLabel="local update command"
/>
<p className="text-xs text-muted-foreground leading-relaxed">
Restart Studio after updating for changes to take effect.
</p>
</>
) : (
<>
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={`instruction-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
className="text-xs text-muted-foreground leading-relaxed"
>
{getStudioUpdateInstructionLine(shell)}
</motion.p>
</AnimatePresence>
<CopyableCommand
command={STUDIO_UPDATE_CMD}
copyLabel="update command"
/>
<p className="text-xs text-muted-foreground leading-relaxed">
If that fails or unsloth studio update is unavailable, run:
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`fallback-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
<CopyableCommand
command={
windows
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
}
copyLabel="fallback command"
/>
</motion.div>
</AnimatePresence>
<p className="text-xs text-muted-foreground leading-relaxed">
Restart Studio after updating for changes to take effect.
</p>
</>
)}
</div>
);
}

View file

@ -1,12 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { ShutdownDialog } from "@/components/shutdown-dialog";
import { UpdateStudioInstructions } from "../components/update-studio-instructions";
import { Button } from "@/components/ui/button";
import { usePlatformStore } from "@/config/env";
import { apiUrl } from "@/lib/api-base";
import { removeTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
import { getAuthToken } from "@/features/auth";
import { removeTrainingUnloadGuard } from "@/features/training";
import { apiUrl, isTauri } from "@/lib/api-base";
import {
ArrowUpRight01Icon,
Book03Icon,
@ -18,28 +18,108 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useState } from "react";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
import {
type UpdateInstallSource,
UpdateStudioInstructions,
} from "../components/update-studio-instructions";
type ApiObject = Record<string, unknown>;
const INSTALL_SOURCE_KEY = "install_source";
const UPDATE_INSTALL_SOURCES = new Set<UpdateInstallSource>([
"pypi",
"editable",
"local_path",
"vcs",
"local_repo",
"unknown",
]);
function isUpdateInstallSource(value: unknown): value is UpdateInstallSource {
return (
typeof value === "string" &&
UPDATE_INSTALL_SOURCES.has(value as UpdateInstallSource)
);
}
async function fetchStudioVersions(): Promise<{
packageVersion: string | null;
studioVersion: string | null;
}> {
try {
const res = await fetch(apiUrl("/api/health"));
if (!res.ok) {
return { packageVersion: null, studioVersion: null };
}
const data = (await res.json()) as ApiObject;
const packageVersion = data.version;
const studioVersion = data.studio_version;
return {
packageVersion:
typeof packageVersion === "string" ? packageVersion : null,
studioVersion: typeof studioVersion === "string" ? studioVersion : null,
};
} catch {
return { packageVersion: null, studioVersion: null };
}
}
async function fetchInstallSource(): Promise<UpdateInstallSource> {
if (isTauri) {
return "unknown";
}
const token = getAuthToken();
if (!token) {
return "unknown";
}
try {
const headers = new Headers();
headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(apiUrl("/api/studio/install-source"), { headers });
if (!res.ok) {
return "unknown";
}
const data = (await res.json()) as ApiObject;
const installSource = data[INSTALL_SOURCE_KEY];
return isUpdateInstallSource(installSource) ? installSource : "unknown";
} catch {
return "unknown";
}
}
export function AboutTab() {
const deviceType = usePlatformStore((s) => s.deviceType);
const defaultShell = deviceType === "windows" ? "windows" : "unix";
const [shutdownOpen, setShutdownOpen] = useState(false);
const [version, setVersion] = useState("dev");
const [packageVersion, setPackageVersion] = useState("dev");
const [studioVersion, setStudioVersion] = useState("dev");
const [installSource, setInstallSource] = useState<
UpdateInstallSource | "loading"
>("loading");
useEffect(() => {
let canceled = false;
(async () => {
try {
const res = await fetch(apiUrl("/api/health"));
if (!res.ok) return;
const data = (await res.json()) as { version?: string };
if (!canceled && data.version) {
setVersion(data.version);
}
} catch {
// fall back to dev label
fetchStudioVersions().then((nextVersions) => {
if (canceled) {
return;
}
})();
if (nextVersions.packageVersion) {
setPackageVersion(nextVersions.packageVersion);
}
if (nextVersions.studioVersion) {
setStudioVersion(nextVersions.studioVersion);
}
});
fetchInstallSource().then((nextInstallSource) => {
if (!canceled) {
setInstallSource(nextInstallSource);
}
});
return () => {
canceled = true;
@ -56,14 +136,25 @@ export function AboutTab() {
</header>
<SettingsSection title="Studio">
<SettingsRow label="Version">
<code className="font-mono text-xs text-muted-foreground">{version}</code>
<SettingsRow label="Studio Version">
<code className="font-mono text-xs text-muted-foreground">
{studioVersion}
</code>
</SettingsRow>
<SettingsRow label="Package Version">
<code className="font-mono text-xs text-muted-foreground">
{packageVersion}
</code>
</SettingsRow>
</SettingsSection>
<SettingsSection title="Updates">
<div className="py-2">
<UpdateStudioInstructions defaultShell={defaultShell} showTitle={false} />
<UpdateStudioInstructions
defaultShell={defaultShell}
installSource={isTauri ? null : installSource}
showTitle={false}
/>
</div>
</SettingsSection>
@ -99,7 +190,10 @@ export function AboutTab() {
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<HugeiconsIcon icon={MessageNotification01Icon} className="size-3.5" />
<HugeiconsIcon
icon={MessageNotification01Icon}
className="size-3.5"
/>
Report an issue
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
</a>
@ -108,7 +202,7 @@ export function AboutTab() {
<SettingsSection title="Danger zone">
<SettingsRow
destructive
destructive={true}
label="Shut down Unsloth Studio"
description="Stops the Studio server process and ends your session."
>

View file

@ -2,7 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useEffect } from "react";
import { useTrainingRuntimeStore } from "@/features/training";
import { useTrainingRuntimeStore } from "../stores/training-runtime-store";
let currentHandler: ((e: BeforeUnloadEvent) => void) | null = null;
@ -13,14 +13,18 @@ let currentHandler: ((e: BeforeUnloadEvent) => void) | null = null;
export function useTrainingUnloadGuard() {
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (!useTrainingRuntimeStore.getState().isTrainingRunning) return;
if (!useTrainingRuntimeStore.getState().isTrainingRunning) {
return;
}
e.preventDefault();
e.returnValue = "";
};
currentHandler = handler;
window.addEventListener("beforeunload", handler);
return () => {
if (currentHandler === handler) currentHandler = null;
if (currentHandler === handler) {
currentHandler = null;
}
window.removeEventListener("beforeunload", handler);
};
}, []);

View file

@ -16,7 +16,11 @@ export { useDatasetPreviewDialogStore } from "./stores/dataset-preview-dialog-st
export { uploadTrainingDataset } from "./api/datasets-api";
export { listLocalModels } from "./api/models-api";
export type { LocalModelInfo } from "./api/models-api";
export type { TrainingPhase, TrainingViewData, TrainingSeriesPoint } from "./types/runtime";
export type {
TrainingPhase,
TrainingViewData,
TrainingSeriesPoint,
} from "./types/runtime";
export type {
TrainingRunSummary,
TrainingRunListResponse,

View file

@ -0,0 +1,158 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { getAuthToken } from "@/features/auth";
import { apiUrl, isTauri } from "@/lib/api-base";
import { useCallback, useEffect, useState } from "react";
const WEB_UPDATE_CHECK_DELAY_MS = 5000;
const DISMISS_PREFIX = "unsloth_web_update_dismissed";
const CAN_SHOW_KEY = "can_show_web_notification";
const UPDATE_AVAILABLE_KEY = "update_available";
const INSTALL_SOURCE_KEY = "install_source";
const LATEST_VERSION_KEY = "latest_version";
const CURRENT_VERSION_KEY = "current_version";
const CHECKED_AT_KEY = "checked_at";
type ApiObject = Record<string, unknown>;
export type WebUpdateInstallSource =
| "pypi"
| "editable"
| "local_path"
| "vcs"
| "local_repo"
| "unknown";
export interface WebUpdateStatus {
currentVersion: string;
latestVersion: string;
installSource: "pypi";
checkedAt: string;
}
interface UseWebUpdateCheckOptions {
enabled?: boolean;
delayMs?: number;
}
function stringField(value: ApiObject, key: string): string | null {
const field = value[key];
return typeof field === "string" ? field : null;
}
function toDisplayableUpdateStatus(value: unknown): WebUpdateStatus | null {
if (!value || typeof value !== "object") {
return null;
}
const status = value as ApiObject;
const latestVersion = stringField(status, LATEST_VERSION_KEY);
const currentVersion = stringField(status, CURRENT_VERSION_KEY);
const checkedAt = stringField(status, CHECKED_AT_KEY);
if (
status[CAN_SHOW_KEY] !== true ||
status[UPDATE_AVAILABLE_KEY] !== true ||
status[INSTALL_SOURCE_KEY] !== "pypi" ||
!latestVersion ||
!currentVersion ||
!checkedAt
) {
return null;
}
return {
currentVersion,
latestVersion,
installSource: "pypi",
checkedAt,
};
}
function dismissalKey(status: WebUpdateStatus): string {
return `${DISMISS_PREFIX}:${status.installSource}:${status.latestVersion}`;
}
function isDismissed(status: WebUpdateStatus): boolean {
if (typeof window === "undefined") {
return true;
}
try {
return window.localStorage.getItem(dismissalKey(status)) !== null;
} catch {
return false;
}
}
function markDismissed(status: WebUpdateStatus): void {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(dismissalKey(status), String(Date.now()));
} catch {
// Ignore storage failures; the banner can still be dismissed in-memory.
}
}
async function fetchDisplayableUpdateStatus(): Promise<WebUpdateStatus | null> {
const token = getAuthToken();
if (!token) {
return null;
}
const headers = new Headers();
headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(apiUrl("/api/studio/update-status"), { headers });
if (!res.ok) {
return null;
}
return toDisplayableUpdateStatus(await res.json());
}
export function useWebUpdateCheck({
enabled = true,
delayMs = WEB_UPDATE_CHECK_DELAY_MS,
}: UseWebUpdateCheckOptions = {}) {
const [status, setStatus] = useState<WebUpdateStatus | null>(null);
useEffect(() => {
if (isTauri || !enabled || !getAuthToken()) {
const clearTimer = window.setTimeout(() => setStatus(null), 0);
return () => window.clearTimeout(clearTimer);
}
let canceled = false;
const timer = window.setTimeout(() => {
fetchDisplayableUpdateStatus()
.then((nextStatus) => {
if (canceled) {
return;
}
setStatus(nextStatus && !isDismissed(nextStatus) ? nextStatus : null);
})
.catch(() => {
if (!canceled) {
setStatus(null);
}
});
}, delayMs);
return () => {
canceled = true;
window.clearTimeout(timer);
};
}, [delayMs, enabled]);
const dismiss = useCallback(() => {
setStatus((current) => {
if (current) {
markDismissed(current);
}
return null;
});
}, []);
return { status: enabled && !isTauri ? status : null, dismiss };
}