Studio: llama.cpp update banner redesign, About tab license info, UI polish (#6196)

* Studio: llama.cpp update banner redesign, About tab license info, inline system prompt editing, naming cleanup

- Redesign the llama.cpp update banner to match the chat composer surface
  (borderless rounded card, composer shadow, Hellix Medium title), rename
  actions to Update and add a 15 minute Remind me later snooze
- Keep the banner up until the user explicitly acts on it; drop the
  outside click dismissal
- Add a Settings > General > Notifications toggle to disable the banner
  for training-only setups (on by default)
- Rename the Help settings tab to About and add a License section
  (Unsloth Studio AGPL-3.0, Unsloth Core Apache-2.0) linking to the
  license files in this repo
- Make the run settings system prompt box an inline editable textarea;
  the popup editor opens when the prompt overflows the box
- Pointer cursor on the preset dropdown chevron
- Dark mode toasts use the chat composer surface color
- Replace standalone Studio with Unsloth in user facing strings; keep
  Unsloth Studio, LM Studio, Fine-tuning Studio, Recipe Studio and CLI
  commands unchanged

* Studio: open the system prompt popup on box click, balance banner padding

- The system prompt box opens the Edit System Prompt dialog on click,
  matching the pencil action
- Slightly more bottom padding on the llama.cpp update banner so the
  spacing reads even next to the action pills

* Studio: replace unsloth studio update with the installer commands in update guidance

- The unsloth studio update command no longer works, so the About tab
  update section now shows the one-line installer (curl or irm) for
  PyPI and unknown installs, and git pull plus the local installer for
  checkouts
- Add a short note that unsloth studio update is no longer supported
- Link the Installation, Updating and Windows install docs pages
- The package update banner now copies the platform installer command
  instead of unsloth studio update

* Studio: rounder account menu, inline system prompt box with popup from the label

- Account menu corners go from 14px to 18px via a specific override,
  since list menus pin border-radius globally
- llama.cpp banner bottom padding 22px
- System prompt is an inline editable textarea again; clicking the
  System Prompt label opens the popup editor, and an overflowing
  prompt opens it on box click

* Studio: show the standard install commands in the About update section

- Both one-line install commands (MacOS/Linux/WSL and Windows
  PowerShell) are always shown, labeled like the docs, since running
  them again updates an existing install
- Drop the unsloth studio update deprecation note
- Add the Mac install guide to the docs links

* Studio: clearer platform toggle and layout in the About update section

- Section heading is Update
- Platform picker is a pair of pill buttons, MacOS / Linux and Windows,
  and only the selected platform's install command is shown
- Intro reads: To install or update Unsloth
- Local update heading separates checkout guidance from the standard
  install command

* Studio: report GitHub branch instead of dev for source checkouts

A source checkout not on an exact release tag now shows
GitHub <branch> (e.g. GitHub main) as the Studio version in About.
Detached or unusual HEADs still fall back to dev.

* Studio: tighten the About update section copy and toggle styling

- Platform toggle buttons are borderless pills
- Shorter local update wording and restart note
- Docs links read Mac and Windows

* Studio: tighten line spacing in the sidebar account button

* Studio: fix vanishing compact MCP icon on hover, single line pill tooltips

- Compact caret pills (MCP, RAG) keep their icon on hover for inactive
  pills too; the off switch hover rules hid the icon while compact mode
  hid the X, leaving an empty slot
- Compact icon tooltips and single line compact tooltips render as full
  pills; wrapped tooltips keep the 9px corners. TooltipContent measures
  line count in a ref callback since Radix mounts portal content
  without re-rendering the wrapper
- 1px gap between the name and Unsloth lines in the sidebar account
  button

* Studio: Projects hover plus button, align recents with the label

- Hovering the Projects nav item reveals a plus button that opens the
  New project dialog, with the same circular hover treatment as the
  chat row actions
- Recent chat titles start at the same x as the Recents label
- The system prompt overflow lock only engages for a non-empty prompt
  with a laid-out box, so a mis-measure cannot turn clicks into the
  popup

* Clip system prompt overflow inside the rounded box

Wrap the inline system prompt textarea in a rounded overflow-hidden
surface so scrolled text and the scrollbar stay inside the box. The
focus ring moves to the wrapper via focus-within.

* Add updating progress bar to llama banner and shorten settings copy

While an update is applying, the banner action row becomes an
indeterminate progress bar that keeps animating under reduced motion,
matching the other loading indicators. Settings descriptions across
General, Profile, Appearance, Chat, Connections, API, and About are
trimmed without losing meaning.

* Address review: desktop update note, server platform detection, zh-CN keys

The About tab no longer shows terminal install commands in the desktop
app, where the bundled backend updates through the built-in updater;
it shows a short note and the docs links instead.

fetchDeviceType now sends the auth token to /api/health, which only
reports the server platform to authed callers, and caches only a
server-reported value. Copied install commands then match the host
platform rather than the browser when they differ (WSL, SSH).

zh-CN gains translations for the new notification and license keys,
the renamed About tab title, and the desktop update note.

* Real download progress for llama.cpp updates, prompt and sidebar polish

The update worker now streams the installer output and parses its
download percent lines into job progress, exposed via the update-status
API. The installer emits finer non-tty milestones when
UNSLOTH_PROGRESS_PERCENT_STEP is set; the worker requests 5 percent
steps. The banner renders a determinate bar from the reported fraction
and falls back to the sweep until the first percent arrives.

Also removes the focus ring on the inline system prompt box and
slightly shrinks the Projects hover plus icon.
This commit is contained in:
Michael Han 2026-06-11 09:27:34 -07:00 committed by GitHub
commit b2b1dcd6ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 829 additions and 337 deletions

View file

@ -31,6 +31,7 @@ class LlamaUpdateJob(BaseModel):
from_tag: Optional[str] = None
to_tag: Optional[str] = None
error: Optional[str] = None
progress: Optional[float] = Field(None, description = "0..1 while running, 1 on success.")
started_at: Optional[str] = None
finished_at: Optional[str] = None

View file

@ -28,6 +28,55 @@ import utils.llama_cpp_update as upd # noqa: E402
MARKER = "UNSLOTH_PREBUILT_INFO.json"
class _FakeInstallerPopen:
"""Stands in for the streamed installer process in _run_update."""
def __init__(
self,
cmd,
*,
returncode = 0,
lines = None,
on_start = None,
captured_kwargs = None,
**kwargs,
):
if captured_kwargs is not None:
captured_kwargs.update(kwargs)
if on_start is not None:
on_start(list(cmd))
self.returncode = returncode
self.stdout = iter(lines or [])
def wait(self):
return self.returncode
def kill(self):
pass
def _patch_installer_popen(
monkeypatch,
*,
returncode = 0,
lines = None,
on_start = None,
captured_kwargs = None,
):
monkeypatch.setattr(
upd.subprocess,
"Popen",
lambda cmd, **kw: _FakeInstallerPopen(
cmd,
returncode = returncode,
lines = lines,
on_start = on_start,
captured_kwargs = captured_kwargs,
**kw,
),
)
def _write_install(
dir_: Path,
tag: str,
@ -260,14 +309,15 @@ def test_start_update_source_build_installs_prebuilt(monkeypatch, tmp_path):
def _fake_run(cmd, **kwargs):
cmd = list(cmd)
# Status polls probe `llama-server --version`; keep the installer argv.
if "--version" in cmd:
return _Proc()
captured["cmd"] = cmd
_write_install(install_dir, "b9585") # installer writes the marker
assert "--version" in cmd # only status polls still use run()
return _Proc()
def _on_start(cmd):
captured["cmd"] = cmd
_write_install(install_dir, "b9585") # installer writes the marker
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
_patch_installer_popen(monkeypatch, on_start = _on_start)
res = upd.start_update()
assert res["started"] is True, res
@ -298,21 +348,27 @@ def test_start_update_happy_path(monkeypatch, tmp_path):
stdout = "installed"
stderr = ""
def _fake_run(cmd, **kwargs):
cmd = list(cmd)
# Status polls probe `llama-server --version`; keep the installer argv.
if "--version" in cmd:
return _Proc()
def _on_start(cmd):
captured["cmd"] = cmd
# Simulate the installer writing a new marker with the latest tag.
_write_install(install_dir, "b9518")
return _Proc()
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
popen_kwargs: dict = {}
_patch_installer_popen(
monkeypatch,
lines = [
"[llama-prebuilt] resolving release\n",
"Downloading llama.zip: 35.0% (12.0 MiB/35.0 MiB) at 9.0 MiB/s\n",
"Downloading llama.zip: 80.0% (28.0 MiB/35.0 MiB) at 9.0 MiB/s\n",
],
on_start = _on_start,
captured_kwargs = popen_kwargs,
)
res = upd.start_update()
assert res["started"] is True
assert res["job"]["from_tag"] == "b9493"
assert res["job"]["progress"] == 0.0
# Wait for the background worker.
deadline = time.time() + 10
@ -328,6 +384,10 @@ def test_start_update_happy_path(monkeypatch, tmp_path):
assert str(install_dir) in captured["cmd"]
assert "--llama-tag" in captured["cmd"] and "latest" in captured["cmd"]
assert "unslothai/llama.cpp" in captured["cmd"]
# Progress lines were parsed and success pins progress at 1.0.
assert job["progress"] == 1.0
# The worker asks the installer for fine-grained progress milestones.
assert popen_kwargs["env"]["UNSLOTH_PROGRESS_PERCENT_STEP"] == "5"
def test_start_update_installer_failure_reports_error(monkeypatch, tmp_path):
@ -336,12 +396,7 @@ def test_start_update_installer_failure_reports_error(monkeypatch, tmp_path):
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
class _Proc:
returncode = 2
stdout = ""
stderr = "boom: network error"
monkeypatch.setattr(upd.subprocess, "run", lambda cmd, **kw: _Proc())
_patch_installer_popen(monkeypatch, returncode = 2, lines = ["boom: network error\n"])
res = upd.start_update()
assert res["started"] is True
@ -410,14 +465,15 @@ def _capture_install_cmd(
def _fake_run(cmd, **kwargs):
cmd = list(cmd)
# Status polls probe `llama-server --version`; keep the installer argv.
if "--version" in cmd:
return _Proc()
captured["cmd"] = cmd
_write_install(install_dir, latest, repo = repo, asset = asset)
assert "--version" in cmd # only status polls still use run()
return _Proc()
def _on_start(cmd):
captured["cmd"] = cmd
_write_install(install_dir, latest, repo = repo, asset = asset)
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
_patch_installer_popen(monkeypatch, on_start = _on_start)
res = upd.start_update()
assert res["started"] is True, res
@ -535,18 +591,12 @@ def test_update_sets_maintenance_flag_and_unloads(monkeypatch, tmp_path):
seen = {}
class _Proc:
returncode = 0
stdout = "ok"
stderr = ""
def _fake_run(cmd, **kwargs):
def _on_start(cmd):
# The maintenance flag must be set while the installer runs.
seen["flag_during_install"] = backend._llama_update_in_progress
_write_install(install_dir, "b9518")
return _Proc()
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
_patch_installer_popen(monkeypatch, on_start = _on_start)
res = upd.start_update()
assert res["started"] is True
@ -571,12 +621,7 @@ def test_update_clears_maintenance_flag_on_installer_failure(monkeypatch, tmp_pa
backend = _FakeBackend()
_inject_backend(monkeypatch, backend)
class _Proc:
returncode = 1
stdout = ""
stderr = "boom"
monkeypatch.setattr(upd.subprocess, "run", lambda cmd, **kw: _Proc())
_patch_installer_popen(monkeypatch, returncode = 1, lines = ["boom\n"])
res = upd.start_update()
assert res["started"] is True
@ -606,16 +651,7 @@ def test_update_fails_open_when_backend_unavailable(monkeypatch, tmp_path):
monkeypatch.setitem(sys.modules, "routes", routes_pkg)
monkeypatch.setitem(sys.modules, "routes.inference", inference_mod)
class _Proc:
returncode = 0
stdout = "ok"
stderr = ""
def _fake_run(cmd, **kwargs):
_write_install(install_dir, "b9518")
return _Proc()
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
_patch_installer_popen(monkeypatch, on_start = lambda cmd: _write_install(install_dir, "b9518"))
res = upd.start_update()
assert res["started"] is True

View file

@ -59,10 +59,17 @@ _job: dict = {
"from_tag": None,
"to_tag": None,
"error": None,
"progress": None,
"started_at": None,
"finished_at": None,
}
# Matches the installer's download progress lines, e.g.
# "Downloading x.zip: 35.0% (12.3 MiB/35.1 MiB) at 8.2 MiB/s".
_PROGRESS_LINE_RE = re.compile(r"(\d+(?:\.\d+)?)%\s*\(")
# The download dominates the update; extract/validate fill the last slice.
_DOWNLOAD_PROGRESS_CEILING = 0.95
def _utcnow() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
@ -357,15 +364,46 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path
]
cmd.extend(_rocm_install_args(asset))
logger.info("llama update: installing", cmd = " ".join(cmd))
proc = subprocess.run(
# Stream the installer output so download percent lines feed
# job["progress"]; finer milestones via UNSLOTH_PROGRESS_PERCENT_STEP.
env = dict(os.environ, UNSLOTH_PROGRESS_PERCENT_STEP = "5")
proc = subprocess.Popen(
cmd,
capture_output = True,
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
timeout = _INSTALL_TIMEOUT_SECONDS,
env = env,
)
if proc.returncode != 0:
tail = (proc.stderr or proc.stdout or "").strip()[-1500:]
raise RuntimeError(f"installer exited {proc.returncode}: {tail or 'no output'}")
timed_out = threading.Event()
def _kill_on_timeout() -> None:
timed_out.set()
proc.kill()
watchdog = threading.Timer(_INSTALL_TIMEOUT_SECONDS, _kill_on_timeout)
watchdog.daemon = True
watchdog.start()
tail_lines: list[str] = []
try:
assert proc.stdout is not None
for line in proc.stdout:
tail_lines.append(line)
if len(tail_lines) > 80:
del tail_lines[0]
m = _PROGRESS_LINE_RE.search(line)
if m is None:
continue
fraction = min(float(m.group(1)) / 100.0, 1.0) * _DOWNLOAD_PROGRESS_CEILING
with _job_lock:
_job["progress"] = max(_job.get("progress") or 0.0, fraction)
returncode = proc.wait()
finally:
watchdog.cancel()
if timed_out.is_set():
raise RuntimeError(f"installer timed out after {_INSTALL_TIMEOUT_SECONDS}s")
if returncode != 0:
tail = "".join(tail_lines).strip()[-1500:]
raise RuntimeError(f"installer exited {returncode}: {tail or 'no output'}")
# New UNSLOTH_PREBUILT_INFO.json is on disk; drop caches so the next
# status read reflects the freshly installed tag.
@ -382,6 +420,7 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path
),
to_tag = new_tag,
error = None,
progress = 1.0,
finished_at = _utcnow(),
)
logger.info("llama update: success", to_tag = new_tag)
@ -467,6 +506,7 @@ def start_update() -> dict:
from_tag = from_tag,
to_tag = None,
error = None,
progress = 0.0,
started_at = _utcnow(),
finished_at = None,
)
@ -491,6 +531,7 @@ def _reset_job_for_tests() -> None:
from_tag = None,
to_tag = None,
error = None,
progress = None,
started_at = None,
finished_at = None,
)

View file

@ -15,6 +15,7 @@ _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)?$")
_GIT_BRANCH_RE = re.compile(r"^[0-9A-Za-z._/-]+$")
_MAX_VERSION_LENGTH = 64
@ -71,6 +72,35 @@ def _exact_git_studio_tag(repo_root: Path) -> str | None:
return tag if is_valid_studio_release_version(tag) else None
def _git_branch(repo_root: Path) -> str | None:
try:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "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
branch = result.stdout.strip()
# "HEAD" means detached, e.g. a tag or commit checkout.
if (
not branch
or branch == "HEAD"
or len(branch) > _MAX_VERSION_LENGTH
or _GIT_BRANCH_RE.fullmatch(branch) is None
):
return None
return branch
def get_studio_version(repo_root: Path | None = None) -> str:
"""Return the installed Studio release tag for display, or ``dev``.
@ -81,7 +111,10 @@ def get_studio_version(repo_root: Path | None = None) -> str:
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
if git_tag is not None:
return git_tag
branch = _git_branch(resolved_repo_root)
return f"GitHub {branch}" if branch is not None else _DEV_VERSION
stamped_version = _studio_release_build.STUDIO_RELEASE_VERSION
if is_valid_studio_release_version(stamped_version):

View file

@ -60,6 +60,7 @@ import {
Logout05Icon,
MoreVerticalIcon,
Search01Icon,
PlusSignIcon,
PowerIcon,
PencilEdit02Icon,
LayoutAlignLeftIcon,
@ -558,7 +559,8 @@ export function AppSidebar() {
: "sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto";
const buttonClass = cn(
"sidebar-nav-btn h-[33px] cursor-pointer rounded-full pr-4 text-[14.5px] leading-[19px] tracking-nav font-medium",
variant === "project" ? "pl-[39px]" : "pl-3",
// pl-3.5 starts the title at the same x as the Recents label text.
variant === "project" ? "pl-[39px]" : "pl-3.5",
variant === "project"
? "group-hover/project-chat-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/project-chat-item:pr-8"
: "group-hover/recent-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-8",
@ -837,7 +839,28 @@ export function AppSidebar() {
navigate({ to: "/projects" });
closeMobileIfOpen();
}}
/>
className="group/projects-item relative"
>
<button
type="button"
aria-label="New project"
onClick={(e) => {
e.stopPropagation();
setProjectCreateMoveTarget(null);
setProjectNameDraft("");
setCreatingProject(true);
}}
className="sidebar-row-action group-hover/projects-item:opacity-100 group-hover/projects-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto group-data-[collapsible=icon]:hidden"
>
<span className="sidebar-row-action-glyph">
<HugeiconsIcon
icon={PlusSignIcon}
strokeWidth={1.75}
className="size-4"
/>
</span>
</button>
</NavItem>
<NavItem
icon={DashboardCircleIcon}
label={t("shell.navigation.hub")}
@ -1065,7 +1088,7 @@ export function AppSidebar() {
className="!size-[32px]"
/>
</div>
<div className="flex flex-col gap-0.5 leading-tight group-data-[collapsible=icon]:hidden">
<div className="flex flex-col gap-px leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-heading text-[13.5px] tracking-[0.025em] dark:tracking-[0.04em] font-semibold text-nav-fg">{displayTitle}</span>
<span className="truncate text-[11.5px] tracking-nav text-muted-foreground">Unsloth</span>
</div>

View file

@ -3,9 +3,10 @@
import { Button } from "@/components/ui/button";
import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check";
import { useShowLlamaUpdateBanner } from "@/hooks/use-llama-update-pref";
import { toast } from "@/lib/toast";
import { AnimatePresence, motion } from "motion/react";
import { type ReactElement, useEffect, useRef } from "react";
import type { ReactElement } from "react";
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
@ -15,15 +16,19 @@ interface LlamaUpdateBannerProps {
/**
* Non-invasive "Update llama.cpp" affordance. Appears bottom-right ~1s after a
* newer prebuilt is detected and stays up until dismissed (click outside / X)
* or updated. Clicking Update swaps the prebuilt in place via POST /api/llama/update.
* newer prebuilt is detected and stays up until the user explicitly acts on it
* (X, Update, or Remind me later). Clicking Update swaps the prebuilt in place
* via POST /api/llama/update. Can be turned off entirely in Settings ->
* General -> Notifications (on by default).
*/
export function LlamaUpdateBanner({
enabled = true,
}: LlamaUpdateBannerProps): ReactElement | null {
const { status, visible, applying, apply, dismiss } = useLlamaUpdateCheck({
enabled,
});
const showBannerPref = useShowLlamaUpdateBanner();
const { status, visible, applying, apply, dismiss, snooze } =
useLlamaUpdateCheck({
enabled: enabled && showBannerPref,
});
async function handleUpdate() {
const result = await apply();
@ -40,30 +45,12 @@ export function LlamaUpdateBanner({
const show =
visible && status != null && (status.update_available || applying);
const bannerRef = useRef<HTMLDivElement>(null);
// Dismiss when the user clicks anything outside the banner. Kept off while an
// update is applying so the progress stays visible.
useEffect(() => {
if (!show || applying) return;
function onPointerDown(event: PointerEvent) {
if (
bannerRef.current &&
!bannerRef.current.contains(event.target as Node)
) {
dismiss();
}
}
document.addEventListener("pointerdown", onPointerDown, true);
return () =>
document.removeEventListener("pointerdown", onPointerDown, true);
}, [show, applying, dismiss]);
const updateProgress = status?.job.progress ?? null;
return (
<AnimatePresence>
{show ? (
<motion.div
ref={bannerRef}
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.97 }}
@ -71,12 +58,12 @@ export function LlamaUpdateBanner({
className="fixed bottom-4 right-4 z-[9998] w-[calc(100vw-2rem)] max-w-[340px]"
data-testid="llama-update-banner"
>
<div className="corner-squircle relative overflow-hidden border border-border/60 bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="relative overflow-hidden rounded-[24px] bg-white px-4 pb-[22px] pl-6 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]">
{applying ? null : (
<button
type="button"
onClick={dismiss}
className="absolute top-2.5 right-2.5 flex size-5 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
className="absolute top-2.5 right-3 flex size-6 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
aria-label="Dismiss llama.cpp update notification"
>
<svg
@ -97,32 +84,64 @@ export function LlamaUpdateBanner({
</button>
)}
<div className="flex items-center gap-2 pr-5">
<span className="text-base" aria-hidden="true">
🦥
</span>
<p className="text-sm font-medium text-foreground">
{applying ? "Updating llama.cpp..." : "New llama.cpp prebuilt"}
<div className="min-w-0 pr-6">
<p className="font-heading text-base font-medium text-foreground">
{applying ? "Updating llama.cpp..." : "New llama.cpp version"}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{status?.installed_tag ?? "unknown"} &rarr;{" "}
<span className="font-medium text-foreground">
{status?.latest_tag ?? ""}
</span>
</p>
</div>
<p className="mt-0.5 pl-7 text-xs text-muted-foreground">
{status?.installed_tag ?? "unknown"} &rarr;{" "}
<span className="font-medium text-foreground">
{status?.latest_tag ?? ""}
</span>
</p>
<div className="mt-2.5 pl-7">
<Button
size="sm"
className="corner-squircle"
onClick={handleUpdate}
disabled={applying}
data-testid="llama-update-button"
{applying ? (
<div
className="mb-1.5 mt-4 h-1 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-label="Updating llama.cpp"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={
updateProgress != null
? Math.round(updateProgress * 100)
: undefined
}
data-testid="llama-update-progress"
>
{applying ? "Updating..." : "Update llama.cpp"}
</Button>
</div>
{updateProgress != null && updateProgress > 0 ? (
<div
className="h-full rounded-full bg-primary transition-[width] duration-700 ease-out"
style={{ width: `${Math.round(updateProgress * 100)}%` }}
/>
) : (
// No percent yet (resolving the release): sweep until the
// first download progress arrives.
<div className="loading-bar-slide h-full w-1/3 rounded-full bg-primary" />
)}
</div>
) : (
<div className="mt-3 flex items-center gap-2">
<Button
size="sm"
className="h-auto rounded-full px-3.5 py-2 text-[13px]"
onClick={handleUpdate}
data-testid="llama-update-button"
>
Update
</Button>
<Button
size="sm"
variant="ghost"
className="h-auto rounded-full px-2.5 py-2 text-[13px] text-muted-foreground hover:text-foreground"
onClick={snooze}
data-testid="llama-update-snooze-button"
>
Remind me later
</Button>
</div>
)}
</div>
</motion.div>
) : null}

View file

@ -228,7 +228,7 @@ function RepairingContent({
</div>
<div className="mb-10 flex flex-col items-center gap-2">
<TealSpinner />
<p className="text-sm font-bold text-foreground">Updating existing Studio install...</p>
<p className="text-sm font-bold text-foreground">Updating existing Unsloth install...</p>
{latest && (
<p className="max-w-xs text-center text-xs text-muted-foreground">{latest}</p>
)}

View file

@ -98,9 +98,27 @@ function TooltipContent({
}: React.ComponentProps<typeof TooltipPrimitive.Content> & {
variant?: TooltipVariant;
}) {
// Single-line compact tooltips render as a full pill; wrapped ones keep
// the squarer corners so tall pills do not look like capsules. A ref
// callback measures on mount: Radix mounts the portal content without
// re-rendering this wrapper, so an effect here would never see the node.
const measureRef = useCallback(
(el: HTMLDivElement | null) => {
if (!el || variant !== "default") return;
const cs = getComputedStyle(el);
const lineHeight = Number.parseFloat(cs.lineHeight) || 16;
const innerHeight =
el.clientHeight -
Number.parseFloat(cs.paddingTop) -
Number.parseFloat(cs.paddingBottom);
el.classList.toggle("rounded-full!", innerHeight < lineHeight * 1.5);
},
[variant],
);
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={measureRef}
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(

View file

@ -2,13 +2,16 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { usePlatformStore } from "@/config/env";
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 STUDIO_INSTALL_UNIX_CMD =
"curl -fsSL https://unsloth.ai/install.sh | sh";
const STUDIO_INSTALL_WINDOWS_CMD = "irm https://unsloth.ai/install.ps1 | iex";
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];
@ -20,6 +23,11 @@ export function WebUpdateBanner({
enabled = true,
}: WebUpdateBannerProps): ReactElement | null {
const { status, dismiss } = useWebUpdateCheck({ enabled });
const deviceType = usePlatformStore((s) => s.deviceType);
const installCmd =
deviceType === "windows"
? STUDIO_INSTALL_WINDOWS_CMD
: STUDIO_INSTALL_UNIX_CMD;
const [copiedVersion, setCopiedVersion] = useState<string | null>(null);
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -36,7 +44,7 @@ export function WebUpdateBanner({
}
async function handleCopyCommand() {
if (!(await copyToClipboard(STUDIO_UPDATE_CMD))) {
if (!(await copyToClipboard(installCmd))) {
return;
}
setCopiedVersion(status?.latestVersion ?? null);
@ -89,8 +97,8 @@ export function WebUpdateBanner({
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.
Installed package: {status.currentVersion}. To update Unsloth,
run this in your terminal, then restart Unsloth.
</p>
</div>
</div>

View file

@ -46,12 +46,28 @@ export async function fetchDeviceType(): Promise<DeviceType> {
if (fetched) return usePlatformStore.getState().deviceType;
try {
const res = await fetch(apiUrl("/api/health"));
// /api/health only reports the server's device_type to authed callers.
// Read the token from storage directly: importing features/auth here
// would be an import cycle (auth/session imports this store).
const token =
typeof window === "undefined"
? null
: localStorage.getItem("unsloth_auth_token");
const res = await fetch(apiUrl("/api/health"), {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
if (res.ok) {
const data = (await res.json()) as { device_type?: string; chat_only?: boolean };
const deviceType = data.device_type ?? detectLocalPlatform();
const chatOnly = data.chat_only ?? false;
usePlatformStore.setState({ deviceType, chatOnly, fetched: true });
// Cache only a server-reported platform. Unauthenticated responses fall
// back to the browser platform, which can differ from the host (WSL,
// SSH); keeping fetched=false retries once a token exists.
usePlatformStore.setState({
deviceType,
chatOnly,
fetched: data.device_type !== undefined,
});
return deviceType;
}
} catch {

View file

@ -175,7 +175,7 @@ export async function authFetch(
"You appear to be offline. Check your network connection and try again.",
);
}
throw new Error("Studio isn't running -- please relaunch it.");
throw new Error("Unsloth isn't running -- please relaunch it.");
}
throw err;
}

View file

@ -26,7 +26,7 @@ let pending: { promise: Promise<boolean>; force: boolean } | null = null;
let lastTauriAuthFailure: string | null = null;
const TAURI_AUTH_FAILURE_FALLBACK =
"Desktop authentication failed. Update or repair the managed Studio install, then restart Studio.";
"Desktop authentication failed. Update or repair the managed Unsloth install, then restart Unsloth.";
const BACKEND_NOT_READY_MESSAGE = "Backend is not ready";
function authFailureMessage(error: unknown): string {

View file

@ -18,7 +18,7 @@ const describeMediaError = (error: unknown): string => {
return "Dictation could not access the microphone.";
}
if (error.name === "NotAllowedError") {
return "Microphone access is blocked. Allow microphone access for this Studio page, then try again.";
return "Microphone access is blocked. Allow microphone access for this Unsloth page, then try again.";
}
if (error.name === "NotFoundError") {
return "No microphone was found for dictation.";
@ -31,7 +31,7 @@ const describeMediaError = (error: unknown): string => {
const describeSpeechError = (error: string, message?: string): string => {
if (error === "not-allowed") {
return "Speech recognition was blocked by the browser. Check microphone permissions for this Studio page.";
return "Speech recognition was blocked by the browser. Check microphone permissions for this Unsloth page.";
}
if (error === "service-not-allowed") {
return "Speech recognition is blocked by the browser speech service.";

View file

@ -1511,7 +1511,7 @@ export function ChatProvidersSettings({
<div className="flex min-w-0 flex-col gap-1">
<h1 className="font-heading text-lg font-semibold">Connections</h1>
<p className="text-xs leading-relaxed text-muted-foreground">
Manage model connections for chat through the Studio proxy.
Manage model connections for chat.
</p>
</div>
</header>

View file

@ -325,6 +325,7 @@ function CollapsibleSection({
label,
labelHref,
headerAction,
onLabelClick,
children,
defaultOpen = false,
first = false,
@ -342,6 +343,8 @@ function CollapsibleSection({
* nested in a button.
*/
headerAction?: ReactNode;
/** When set, clicking the label runs this instead of toggling collapse. */
onLabelClick?: () => void;
children?: ReactNode;
defaultOpen?: boolean;
first?: boolean;
@ -395,7 +398,7 @@ function CollapsibleSection({
<div className={headerClasses}>
<button
type="button"
onClick={toggle}
onClick={onLabelClick ?? toggle}
className="flex min-w-0 flex-1 cursor-pointer items-center text-left leading-none transition-colors hover:text-nav-fg"
>
<span className="leading-none">{label}</span>
@ -558,6 +561,9 @@ export function ChatSettingsPanel({
const [presetNameInput, setPresetNameInput] = useState(activePreset);
const [systemPromptEditorOpen, setSystemPromptEditorOpen] = useState(false);
const [systemPromptDraft, setSystemPromptDraft] = useState("");
// When the prompt overflows the inline box, clicking opens the popup editor.
const systemPromptBoxRef = useRef<HTMLTextAreaElement>(null);
const [systemPromptOverflows, setSystemPromptOverflows] = useState(false);
const [activePresetBaseline, setActivePresetBaseline] = useState(params);
const presets = useMemo(() => {
return getOrderedPresets(customPresets);
@ -762,6 +768,16 @@ export function ChatSettingsPanel({
}
}, [open]);
useEffect(() => {
const el = systemPromptBoxRef.current;
setSystemPromptOverflows(
params.systemPrompt.length > 0 &&
el != null &&
el.clientHeight > 0 &&
el.scrollHeight > el.clientHeight + 1,
);
}, [params.systemPrompt, open]);
const settingsScrollRef = useRef<HTMLDivElement>(null);
const settingsContent = (
@ -1119,7 +1135,7 @@ export function ChatSettingsPanel({
/>
<InputGroupAddon
align="inline-end"
className="min-h-0 shrink-0 gap-0 self-stretch border-0 py-0 pl-0 !pr-1 has-[>button]:mr-0"
className="min-h-0 shrink-0 gap-0 self-stretch border-0 py-0 pl-0 !pr-1 has-[>button]:mr-0 !cursor-pointer"
>
<span
className="!h-7 min-h-7 !w-7 min-w-7 shrink-0 self-center inline-flex items-center justify-center rounded-full border-0 px-0 text-[#a0a097] dark:text-nav-fg pointer-events-none"
@ -1300,6 +1316,7 @@ export function ChatSettingsPanel({
<CollapsibleSection
label="System Prompt"
defaultOpen={true}
onLabelClick={openSystemPromptEditor}
headerAction={
<Tooltip>
<TooltipPrimitive.Trigger asChild>
@ -1326,22 +1343,36 @@ export function ChatSettingsPanel({
</Tooltip>
}
>
<button
type="button"
onClick={openSystemPromptEditor}
aria-label="Edit system prompt"
{/* Rounded wrapper clips overflowing text and the scrollbar. */}
<div
className={cn(
"panel-text-surface -mt-1 flex w-full h-20 overflow-hidden cursor-pointer items-start px-3.5 py-2.5 text-left text-[13px] font-medium leading-relaxed corner-squircle focus-visible:outline-none focus-visible:border-ring focus-visible:ring-[1px] focus-visible:ring-ring/40",
params.systemPrompt
? "text-nav-fg"
: "text-muted-foreground",
"panel-text-surface -mt-1 h-20 w-full overflow-hidden corner-squircle",
systemPromptOverflows && "cursor-pointer",
)}
>
<span className="block line-clamp-3 whitespace-pre-wrap break-words">
{params.systemPrompt ||
"Example: You are a helpful assistant..."}
</span>
</button>
<textarea
ref={systemPromptBoxRef}
value={params.systemPrompt}
onChange={(e) => set("systemPrompt")(e.target.value)}
onMouseDown={(e) => {
// Overflowing prompt: click opens the popup editor instead.
// While focused, clicks still move the caret normally.
if (
systemPromptOverflows &&
document.activeElement !== e.currentTarget
) {
e.preventDefault();
openSystemPromptEditor();
}
}}
placeholder="Example: You are a helpful assistant..."
aria-label="System prompt"
className={cn(
"block size-full resize-none bg-transparent px-3.5 py-2.5 text-left text-[13px] font-medium leading-relaxed text-nav-fg outline-none placeholder:text-muted-foreground",
systemPromptOverflows && "cursor-pointer",
)}
/>
</div>
</CollapsibleSection>
<CollapsibleSection label="Sampling" defaultOpen={true}>

View file

@ -28,7 +28,7 @@ export function buildChatTourSteps({
body: (
<>
This selects whats loaded for inference. Hub = base models. Fine-tuned
= trained Studio outputs, including LoRA adapters and full finetunes.
= trained Unsloth outputs, including LoRA adapters and full finetunes.
</>
),
},
@ -38,7 +38,7 @@ export function buildChatTourSteps({
title: "Two tabs",
body: (
<>
Hub: search Hugging Face models. Fine-tuned: local Studio outputs youve
Hub: search Hugging Face models. Fine-tuned: local Unsloth outputs youve
trained or exported. If results look off, compare base vs fine-tuned
outputs to see what changed.
</>

View file

@ -134,7 +134,7 @@ export function isExpectedBackgroundChatStorageError(error: unknown): boolean {
(error.message === "Invalid or expired token" ||
error.message === "Not authenticated" ||
error.message === "Request failed (401)" ||
error.message === "Studio isn't running -- please relaunch it.")
error.message === "Unsloth isn't running -- please relaunch it.")
);
}

View file

@ -12,14 +12,14 @@ function parseErrorText(status: number, body: unknown): string {
const detail = (body as { detail?: unknown }).detail;
const formatted = formatFastApiDetail(detail);
if (status === 405) {
return `${formatted || "Method Not Allowed"} - the Studio backend did not accept this API method. Restart Studio so the frontend and backend are on the same build.`;
return `${formatted || "Method Not Allowed"} - the Unsloth backend did not accept this API method. Restart Unsloth so the frontend and backend are on the same build.`;
}
if (formatted) return formatted;
const message = (body as { message?: unknown }).message;
if (typeof message === "string" && message) return message;
}
if (status === 405) {
return "Method Not Allowed - the Studio backend did not accept this API method. Restart Studio so the frontend and backend are on the same build.";
return "Method Not Allowed - the Unsloth backend did not accept this API method. Restart Unsloth so the frontend and backend are on the same build.";
}
return `Request failed (${status})`;
}
@ -137,7 +137,7 @@ const DOWNLOAD_TRANSPORT_CAPABILITIES_FALLBACK: DownloadTransportCapabilities =
http: { available: true, reason: null },
xet: {
available: null,
reason: "Couldn't verify Xet support with the Studio backend.",
reason: "Couldn't verify Xet support with the Unsloth backend.",
},
};
let downloadTransportCapabilitiesCache: DownloadTransportCapabilities | null =

View file

@ -184,7 +184,7 @@ export async function effectiveTransportMode(
return preferred;
}
const reason =
capabilities.xet.reason ?? "Studio will use HTTP downloads instead.";
capabilities.xet.reason ?? "Unsloth will use HTTP downloads instead.";
if (lastXetUnavailableWarningReason !== reason) {
lastXetUnavailableWarningReason = reason;
toast.warning("Xet download transport unavailable", {

View file

@ -45,7 +45,7 @@ function formatGitHubSourceMessage(execution: RecipeExecutionRecord): string {
return "Collecting repository threads before rows are available.";
}
if (source.status === "rate_limited") {
return source.message ?? "Waiting for GitHub rate limit. Studio will resume automatically.";
return source.message ?? "Waiting for GitHub rate limit. Unsloth will resume automatically.";
}
return source.message ?? "Collecting repository threads before rows are available.";
}

View file

@ -41,7 +41,7 @@ function formatSourceMessage(execution: RecipeExecutionRecord): string {
typeof source.retry_after_sec === "number" && source.retry_after_sec > 0
? ` Waiting ~${formatMetricValue(source.retry_after_sec)}s.`
: "";
return `Waiting for GitHub rate limit. Studio will resume automatically.${wait}`;
return `Waiting for GitHub rate limit. Unsloth will resume automatically.${wait}`;
}
return source.message ?? "Crawling GitHub source.";
}

View file

@ -68,7 +68,7 @@ function formatGitHubSourceSummary(
typeof source.retry_after_sec === "number" && source.retry_after_sec > 0
? ` ~${formatMetricValue(source.retry_after_sec)}s`
: "";
return `Waiting for GitHub rate limit${wait}. Studio will resume automatically.`;
return `Waiting for GitHub rate limit${wait}. Unsloth will resume automatically.`;
}
if (source.status === "retrying") {
return source.message ?? "GitHub request failed; retrying automatically.";

View file

@ -334,8 +334,8 @@ export function GithubRepoSeedForm({
/>
<p id={tokenHelpId} className="text-xs text-muted-foreground">
{usingEnvToken
? "Studio detected a server env token, so saved/shared recipes can leave this blank."
: "Blank is safest for saved/shared recipes because Studio will read the server environment at run time."}
? "Unsloth detected a server env token, so saved/shared recipes can leave this blank."
: "Blank is safest for saved/shared recipes because Unsloth will read the server environment at run time."}
</p>
{hasToken && (
<p className="rounded-md bg-amber-500/10 px-2 py-1.5 text-xs text-amber-700 dark:text-amber-300">
@ -450,7 +450,7 @@ export function GithubRepoSeedForm({
</fieldset>
<p className="text-xs text-muted-foreground">
Backed by Studio's built-in <code>github_repo</code> seed reader. Large
Backed by Unsloth's built-in <code>github_repo</code> seed reader. Large
repos can take minutes, so start with small limits for previews.
</p>
</div>

View file

@ -4,21 +4,29 @@
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { useT } from "@/i18n";
import { cn } from "@/lib/utils";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import {
ArrowUpRight01Icon,
Copy01Icon,
Tick02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ReactElement } from "react";
import { useEffect, useRef, useState } from "react";
const STUDIO_UPDATE_CMD = "unsloth studio update";
const STUDIO_UPDATE_FALLBACK_UNIX_CMD =
const STUDIO_INSTALL_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_INSTALL_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";
const STUDIO_LOCAL_INSTALL_UNIX_CMD = "./install.sh --local";
const STUDIO_LOCAL_INSTALL_WINDOWS_CMD = ".\\install.ps1 --local";
const DOCS_INSTALL_URL = "https://unsloth.ai/docs/get-started/install";
const DOCS_UPDATING_URL =
"https://unsloth.ai/docs/get-started/install/updating";
const DOCS_MAC_URL = "https://unsloth.ai/docs/get-started/install/mac";
const DOCS_WINDOWS_URL =
"https://unsloth.ai/docs/get-started/install/windows-installation";
export type UpdateShell = "windows" | "unix";
export type UpdateInstallSource =
@ -30,15 +38,6 @@ export type UpdateInstallSource =
| "unknown";
type UpdateInstallSourceState = UpdateInstallSource | "loading";
function getStudioUpdateInstructionLine(
shell: UpdateShell,
t: ReturnType<typeof useT>,
): string {
return shell === "windows"
? t("settings.about.update.openPowerShell")
: t("settings.about.update.openTerminal");
}
function isLocalInstallSource(
installSource?: UpdateInstallSourceState | null,
): boolean {
@ -128,6 +127,77 @@ function CopyableCommand({
);
}
function DocsLink({
href,
label,
}: {
href: string;
label: string;
}): ReactElement {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-muted-foreground hover:text-foreground"
>
{label}
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
</a>
);
}
function UpdateDocsLinks(): ReactElement {
const t = useT();
return (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.docs")}
<DocsLink
href={DOCS_INSTALL_URL}
label={t("settings.about.update.docsInstall")}
/>
<DocsLink
href={DOCS_UPDATING_URL}
label={t("settings.about.update.docsUpdating")}
/>
<DocsLink
href={DOCS_MAC_URL}
label={t("settings.about.update.docsMac")}
/>
<DocsLink
href={DOCS_WINDOWS_URL}
label={t("settings.about.update.docsWindows")}
/>
</p>
);
}
function ShellToggleButton({
active,
label,
onClick,
}: {
active: boolean;
label: string;
onClick: () => void;
}): ReactElement {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
"rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors",
active
? "bg-foreground/[0.08] text-foreground dark:bg-white/[0.12]"
: "text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
);
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: keep source-specific update guidance in one component so the command matrix stays visible.
export function UpdateStudioInstructions({
className,
@ -145,6 +215,9 @@ export function UpdateStudioInstructions({
const shell = shellOverride ?? defaultShell;
const prefersReducedMotion = useReducedMotion();
const windows = shell === "windows";
// null means the desktop app: its bundled backend updates through the
// built-in updater, so terminal commands would target the wrong install.
const desktopManaged = installSource === null;
const localInstallSource = isLocalInstallSource(installSource);
const checkoutInstallSource =
installSource === "editable" || installSource === "local_repo";
@ -163,6 +236,22 @@ export function UpdateStudioInstructions({
? { opacity: 1 }
: { opacity: 0, y: -2 };
if (desktopManaged) {
return (
<div className={cn("flex flex-col gap-3", className)}>
{showTitle ? (
<p className="shrink-0 whitespace-nowrap text-sm font-semibold font-heading">
{t("settings.about.update.title")}
</p>
) : null}
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.desktopManaged")}
</p>
<UpdateDocsLinks />
</div>
);
}
return (
<div className={cn("flex flex-col gap-3", className)}>
<div
@ -176,42 +265,55 @@ export function UpdateStudioInstructions({
{t("settings.about.update.title")}
</p>
) : null}
<div className="flex shrink-0 items-center gap-0.5 text-[11px]">
<button
type="button"
onClick={() => setShellOverride("windows")}
className={cn(
"px-0.5 py-0.5 font-medium transition-colors",
windows
? "text-foreground"
: "text-muted-foreground hover:text-emerald-600",
)}
aria-pressed={windows}
>
Windows
</button>
<span className="text-border">/</span>
<button
type="button"
<div className="flex shrink-0 items-center gap-1.5">
<ShellToggleButton
active={!windows}
label="MacOS / Linux"
onClick={() => setShellOverride("unix")}
className={cn(
"px-0.5 py-0.5 font-medium transition-colors",
windows
? "text-muted-foreground hover:text-emerald-600"
: "text-foreground",
)}
aria-pressed={!windows}
>
macOS/Linux
</button>
/>
<ShellToggleButton
active={windows}
label="Windows"
onClick={() => setShellOverride("windows")}
/>
</div>
</div>
{loadingInstallSource ? (
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.checkingInstall")}
</p>
) : localInstallSource ? (
) : (
<>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.installIntro")}
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`install-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
<CopyableCommand
command={
windows ? STUDIO_INSTALL_WINDOWS_CMD : STUDIO_INSTALL_UNIX_CMD
}
copyLabel={
windows
? t("settings.about.update.installCommandWindows")
: t("settings.about.update.installCommandUnix")
}
/>
</motion.div>
</AnimatePresence>
</>
)}
{loadingInstallSource ? null : localInstallSource ? (
<>
<p className="text-xs font-semibold text-foreground">
{t("settings.about.update.localUpdateHeading")}
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.localInstallDetected")}
</p>
@ -224,16 +326,9 @@ export function UpdateStudioInstructions({
command={STUDIO_LOCAL_PULL_CMD}
copyLabel={t("settings.about.update.gitPullCommand")}
/>
<CopyableCommand
command={STUDIO_LOCAL_UPDATE_CMD}
copyLabel={t("settings.about.update.localUpdateCommand")}
/>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.localInstallerFallback")}
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`local-fallback-${shell}`}
key={`local-installer-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
@ -242,8 +337,8 @@ export function UpdateStudioInstructions({
<CopyableCommand
command={
windows
? STUDIO_LOCAL_FALLBACK_WINDOWS_CMD
: STUDIO_LOCAL_FALLBACK_UNIX_CMD
? STUDIO_LOCAL_INSTALL_WINDOWS_CMD
: STUDIO_LOCAL_INSTALL_UNIX_CMD
}
copyLabel={t("settings.about.update.localInstallerCommand")}
/>
@ -261,7 +356,7 @@ export function UpdateStudioInstructions({
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`source-fallback-${shell}`}
key={`source-installer-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
@ -270,8 +365,8 @@ export function UpdateStudioInstructions({
<CopyableCommand
command={
windows
? STUDIO_LOCAL_FALLBACK_WINDOWS_CMD
: STUDIO_LOCAL_FALLBACK_UNIX_CMD
? STUDIO_LOCAL_INSTALL_WINDOWS_CMD
: STUDIO_LOCAL_INSTALL_UNIX_CMD
}
copyLabel={t("settings.about.update.localInstallerCommand")}
/>
@ -282,54 +377,22 @@ export function UpdateStudioInstructions({
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.restartAfterUpdate")}
</p>
<UpdateDocsLinks />
</>
) : unknownInstallSource ? (
<>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.unknownInstall")}
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.curlOrPypi")}
<p className="text-xs font-semibold text-foreground">
{t("settings.about.update.localUpdateHeading")}
</p>
<CopyableCommand
command={STUDIO_UPDATE_CMD}
copyLabel={t("settings.about.update.updateCommand")}
/>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.localCheckout")}
</p>
<CopyableCommand
command={STUDIO_LOCAL_UPDATE_CMD}
copyLabel={t("settings.about.update.localUpdateCommand")}
/>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.restartAfterUpdate")}
</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, t)}
</motion.p>
</AnimatePresence>
<CopyableCommand
command={STUDIO_UPDATE_CMD}
copyLabel={t("settings.about.update.updateCommand")}
/>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.fallbackInstruction")}
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`fallback-${shell}`}
key={`local-installer-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
@ -338,16 +401,24 @@ export function UpdateStudioInstructions({
<CopyableCommand
command={
windows
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
? STUDIO_LOCAL_INSTALL_WINDOWS_CMD
: STUDIO_LOCAL_INSTALL_UNIX_CMD
}
copyLabel={t("settings.about.update.fallbackCommand")}
copyLabel={t("settings.about.update.localInstallerCommand")}
/>
</motion.div>
</AnimatePresence>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.restartAfterUpdate")}
</p>
<UpdateDocsLinks />
</>
) : (
<>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.about.update.restartAfterUpdate")}
</p>
<UpdateDocsLinks />
</>
)}
</div>

View file

@ -142,7 +142,7 @@ export function AboutTab() {
</p>
</header>
<SettingsSection title="Studio">
<SettingsSection title="Unsloth">
<SettingsRow label={t("settings.about.studioVersion")}>
<code className="font-mono text-xs text-muted-foreground">
{studioVersion}
@ -207,6 +207,37 @@ export function AboutTab() {
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.about.license.sectionTitle")}>
<SettingsRow
label={t("settings.about.license.studioLabel")}
description={t("settings.about.license.studioDescription")}
>
<a
href="https://github.com/unslothai/unsloth/blob/main/studio/LICENSE.AGPL-3.0"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-mono text-xs font-medium text-muted-foreground hover:text-foreground"
>
{t("settings.about.license.studioLicense")}
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
</a>
</SettingsRow>
<SettingsRow
label={t("settings.about.license.libraryLabel")}
description={t("settings.about.license.libraryDescription")}
>
<a
href="https://github.com/unslothai/unsloth/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-mono text-xs font-medium text-muted-foreground hover:text-foreground"
>
{t("settings.about.license.libraryLicense")}
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
</a>
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.about.dangerZone")}>
<SettingsRow
destructive={true}

View file

@ -15,6 +15,10 @@ import { Switch } from "@/components/ui/switch";
import { usePlatformStore } from "@/config/env";
import { resetOnboardingDone } from "@/features/auth";
import { useChatRuntimeStore } from "@/features/chat";
import {
setShowLlamaUpdateBanner,
useShowLlamaUpdateBanner,
} from "@/hooks/use-llama-update-pref";
import {
loadHelperPrecacheSettings,
updateHelperPrecacheSettings,
@ -68,6 +72,8 @@ const PREFS_KEYS: string[] = [
"unsloth_user_profile",
// Guided tour flags
"tour:studio:v1",
// Update notifications
"unsloth_show_llama_update_banner",
];
// Set by resetAllPrefs so the unmount-commit effect skips writing back the
@ -106,6 +112,7 @@ export function GeneralTab() {
const autoTitle = useChatRuntimeStore((s) => s.autoTitle);
const setAutoTitle = useChatRuntimeStore((s) => s.setAutoTitle);
const chatOnly = usePlatformStore((s) => s.chatOnly);
const showLlamaUpdates = useShowLlamaUpdateBanner();
const redirectTo = `${pathname}${search}`;
const [draftToken, setDraftToken] = useState(hfToken ?? "");
@ -315,6 +322,20 @@ export function GeneralTab() {
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.general.notifications.sectionTitle")}>
<SettingsRow
label={t("settings.general.notifications.showLlamaUpdates")}
description={t(
"settings.general.notifications.showLlamaUpdatesDescription",
)}
>
<Switch
checked={showLlamaUpdates}
onCheckedChange={setShowLlamaUpdateBanner}
/>
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.general.uploads.sectionTitle")}>
<SettingsRow
label={t("settings.general.uploads.maxUploadSize")}

View file

@ -9,7 +9,7 @@ export const studioNavStep: TourStep = {
title: "Quick orientation",
body: (
<>
Studio: pick base model, dataset, hyperparams, then start training. After
Unsloth: pick base model, dataset, hyperparams, then start training. After
you start, youll see a Training view with live loss/metrics. Chat is for
testing base vs LoRA adapters. Export packages checkpoints for deployment.{" "}
<ReadMore href="https://unsloth.ai/docs/get-started/fine-tuning-for-beginners" />

View file

@ -5,11 +5,14 @@ import { authFetch, getAuthToken } from "@/features/auth";
import { useCallback, useEffect, useRef, useState } from "react";
// First check shortly after load, then re-surface as an hourly reminder. The
// banner stays up until the user dismisses it (click outside / X) or updates.
// banner stays up until the user explicitly acts on it (X, Update, or
// Remind me later).
const FIRST_CHECK_DELAY_MS = 1000;
const REMINDER_INTERVAL_MS = 60 * 60 * 1000; // ~1 hour
// "Remind me later" re-surfaces sooner than the hourly reminder.
const SNOOZE_DELAY_MS = 15 * 60 * 1000; // ~15 minutes
// While an update is applying, poll the job state at this cadence.
const JOB_POLL_INTERVAL_MS = 3000;
const JOB_POLL_INTERVAL_MS = 1500;
export interface LlamaUpdateJob {
state: "idle" | "running" | "success" | "error";
@ -17,6 +20,8 @@ export interface LlamaUpdateJob {
from_tag: string | null;
to_tag: string | null;
error: string | null;
// Download fraction (0..1) while running, 1 on success, null when unknown.
progress: number | null;
}
export interface LlamaUpdateStatus {
@ -42,6 +47,7 @@ function parseStatus(value: unknown): LlamaUpdateStatus | null {
from_tag: typeof job.from_tag === "string" ? job.from_tag : null,
to_tag: typeof job.to_tag === "string" ? job.to_tag : null,
error: typeof job.error === "string" ? job.error : null,
progress: typeof job.progress === "number" ? job.progress : null,
},
};
}
@ -73,9 +79,10 @@ export interface LlamaApplyResult {
/**
* Polls the backend for a newer llama.cpp prebuilt. When one exists, `visible`
* becomes true ~1s after load and stays up until the user dismisses it (click
* outside / X) or updates; it re-surfaces every ~hour as a reminder. `apply()`
* triggers the in-place swap and tracks the job.
* becomes true ~1s after load and stays up until the user dismisses it (X),
* snoozes it ("Remind me later", ~15 min), or updates; it re-surfaces every
* ~hour as a reminder. `apply()` triggers the in-place swap and tracks the
* job.
*/
export function useLlamaUpdateCheck({
enabled = true,
@ -84,6 +91,7 @@ export function useLlamaUpdateCheck({
const [visible, setVisible] = useState(false);
const [applying, setApplying] = useState(false);
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const snoozeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearPollTimer = useCallback(() => {
if (pollTimer.current) {
@ -161,6 +169,10 @@ export function useLlamaUpdateCheck({
clearTimeout(firstTimer);
clearInterval(reminder);
clearPollTimer();
if (snoozeTimer.current) {
clearTimeout(snoozeTimer.current);
snoozeTimer.current = null;
}
};
}, [enabled, surfaceIfAvailable, clearPollTimer]);
@ -168,6 +180,16 @@ export function useLlamaUpdateCheck({
setVisible(false);
}, []);
// Hide now, re-check and re-surface after SNOOZE_DELAY_MS.
const snooze = useCallback(() => {
setVisible(false);
if (snoozeTimer.current) clearTimeout(snoozeTimer.current);
snoozeTimer.current = setTimeout(() => {
snoozeTimer.current = null;
fetchStatus().then(surfaceIfAvailable);
}, SNOOZE_DELAY_MS);
}, [surfaceIfAvailable]);
const apply = useCallback(async (): Promise<LlamaApplyResult> => {
if (applying) return { ok: false, error: "already running" };
setApplying(true);
@ -219,5 +241,6 @@ export function useLlamaUpdateCheck({
applying,
apply,
dismiss,
snooze,
};
}

View file

@ -0,0 +1,49 @@
// 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 { useSyncExternalStore } from "react";
// Whether the llama.cpp update banner may appear. On by default; only an
// explicit "false" (Settings -> General -> Notifications) disables it.
const STORAGE_KEY = "unsloth_show_llama_update_banner";
const listeners = new Set<() => void>();
export function getShowLlamaUpdateBanner(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) !== "false";
} catch {
return true;
}
}
export function setShowLlamaUpdateBanner(show: boolean): void {
try {
if (show) {
// Remove rather than store "true" so the default stays on.
localStorage.removeItem(STORAGE_KEY);
} else {
localStorage.setItem(STORAGE_KEY, "false");
}
} catch {
// storage unavailable
}
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
// Sync toggles made in another tab.
const onStorage = (event: StorageEvent) => {
if (event.key === STORAGE_KEY) listener();
};
window.addEventListener("storage", onStorage);
return () => {
listeners.delete(listener);
window.removeEventListener("storage", onStorage);
};
}
export function useShowLlamaUpdateBanner(): boolean {
return useSyncExternalStore(subscribe, getShowLlamaUpdateBanner);
}

View file

@ -56,23 +56,23 @@ function wait(ms: number) {
function externalConflictMessage(preflight: DesktopPreflightResult) {
if (preflight.reason === "desktop_owned_backend_active") {
return preflight.port
? `A desktop-owned Studio server for this install is already running on port ${preflight.port}. Quit the other desktop app instance, then try again.`
: "A desktop-owned Studio server for this install is already running. Quit the other desktop app instance, then try again.";
? `A desktop-owned Unsloth server for this install is already running on port ${preflight.port}. Quit the other desktop app instance, then try again.`
: "A desktop-owned Unsloth server for this install is already running. Quit the other desktop app instance, then try again.";
}
if (preflight.reason === "desktop_owned_backend_starting") {
return "The desktop-owned Studio backend is still starting. Wait a moment, then try again.";
return "The desktop-owned Unsloth backend is still starting. Wait a moment, then try again.";
}
if (preflight.reason?.startsWith("desktop_owned_backend_unmanageable:")) {
return preflight.port
? `A desktop-owned Studio backend on port ${preflight.port} cannot be safely controlled by this desktop app. Stop that backend, then reopen Studio.`
: "A desktop-owned Studio backend cannot be safely controlled by this desktop app. Stop that backend, then reopen Studio.";
? `A desktop-owned Unsloth backend on port ${preflight.port} cannot be safely controlled by this desktop app. Stop that backend, then reopen Unsloth.`
: "A desktop-owned Unsloth backend cannot be safely controlled by this desktop app. Stop that backend, then reopen Unsloth.";
}
return preflight.port
? `A Studio server for this install is already running from a terminal on port ${preflight.port}. Stop that server, or run \`unsloth studio update\` from that terminal before using the desktop app.`
: "A Studio server for this install is already running from a terminal. Stop that server, or run `unsloth studio update` from that terminal before using the desktop app.";
? `A Unsloth server for this install is already running from a terminal on port ${preflight.port}. Stop that server, or run \`unsloth studio update\` from that terminal before using the desktop app.`
: "A Unsloth server for this install is already running from a terminal. Stop that server, or run `unsloth studio update` from that terminal before using the desktop app.";
}
async function waitForManagedServerReady(
@ -248,8 +248,8 @@ export function useTauriBackend() {
} else {
setBackendError(
preflight.disposition === "owned_stale"
? "Desktop-owned Studio backend is too old for this desktop app. Run `unsloth studio update`, then restart Studio."
: "Managed Studio install is too old. Run `unsloth studio update`.",
? "Desktop-owned Unsloth backend is too old for this desktop app. Run `unsloth studio update`, then restart Unsloth."
: "Managed Unsloth install is too old. Run `unsloth studio update`.",
);
}
return;
@ -304,7 +304,7 @@ export function useTauriBackend() {
if (msg.includes("already running")) {
startingRef.current = false;
setBackendError(
"Managed server is already running but did not report a port. Restart Studio and try again.",
"Managed server is already running but did not report a port. Restart Unsloth and try again.",
);
return;
}
@ -596,7 +596,7 @@ export function useTauriBackend() {
const detail =
event instanceof CustomEvent && typeof event.detail === "string"
? event.detail
: "Desktop authentication failed. Update or repair the managed Studio install, then restart Studio.";
: "Desktop authentication failed. Update or repair the managed Unsloth install, then restart Unsloth.";
setAuthFailure(detail);
};
window.addEventListener("tauri-auth-failed", onAuthFailed);

View file

@ -83,7 +83,7 @@ export const en = {
title: "Settings",
dialog: {
title: "Settings",
description: "Manage your Unsloth Studio preferences.",
description: "Manage your Unsloth preferences.",
closeAriaLabel: "Close settings",
},
tabs: {
@ -93,11 +93,11 @@ export const en = {
chat: "Chat",
connections: "Connections",
apiKeys: "API",
about: "Help",
about: "About",
},
general: {
title: "General",
description: "Global preferences for Unsloth Studio.",
description: "Global preferences for Unsloth.",
account: "Account",
huggingFaceToken: "Hugging Face token",
huggingFaceTokenDescription:
@ -112,38 +112,44 @@ export const en = {
sectionTitle: "Helper LLM",
preloadOnStartup: "Pre-cache Helper LLM on startup",
preloadOnStartupDescription:
"Download and cache the AI Assist helper model in the background when Studio starts. Off by default; AI Assist can still download it on demand when clicked.",
"Download the AI Assist helper model in the background on startup. Off by default; AI Assist can still fetch it on demand.",
disabledByEnv:
"Disabled by UNSLOTH_HELPER_MODEL_DISABLE in the backend environment.",
loadError: "Failed to load Helper LLM settings.",
saveError: "Failed to save Helper LLM settings.",
},
notifications: {
sectionTitle: "Notifications",
showLlamaUpdates: "llama.cpp update notifications",
showLlamaUpdatesDescription:
"Notify when a newer llama.cpp build is available. Turn off if you only train.",
},
gettingStarted: "Getting started",
startOnboarding: "Start onboarding",
startOnboardingDescription:
"Open the setup wizard again without changing your account.",
"Reopen the setup wizard without changing your account.",
startOnboardingAction: "Start onboarding",
uploads: {
sectionTitle: "Uploads",
maxUploadSize: "Training dataset upload cap",
maxUploadSizeDescription:
"Applies to training dataset uploads. Default is {defaultSize} MB.",
"Default is {defaultSize} MB.",
},
resetPreferences: {
sectionTitle: "Danger zone",
label: "Reset all local preferences",
description:
"Clears local-only preferences. Chats, API access, and DB-backed settings are not affected.",
"Clears local-only preferences. Chats, API access, and DB-backed settings are kept.",
action: "Reset preferences",
confirmTitle: "Reset all local preferences?",
confirmDescription:
"This clears local-only preferences, then reloads Studio. Chats, API access, and DB-backed settings are not affected.",
"Clears local-only preferences and reloads Unsloth. Chats, API access, and DB-backed settings are kept.",
confirmAction: "Reset and reload",
},
},
profile: {
title: "Profile",
description: "Update how your profile appears in Studio.",
description: "How your profile appears in Unsloth.",
changePicture: "Change profile picture",
displayName: "Display name",
nameSaved: "Profile name saved",
@ -163,7 +169,7 @@ export const en = {
theme: {
title: "Theme",
label: "Color scheme",
description: "Choose light, dark, or follow your system.",
description: "Light, dark, or follow your system.",
system: "System",
light: "Light",
dark: "Dark",
@ -171,7 +177,7 @@ export const en = {
language: {
title: "Language",
label: "Display language",
description: "Choose the language used by Studio.",
description: "The language used by Unsloth.",
},
layout: {
title: "Layout",
@ -182,25 +188,25 @@ export const en = {
},
chat: {
title: "Chat",
description: "Manage your chat history stored on this device.",
description: "Manage chat history stored on this device.",
artifacts: {
title: "Artifacts",
collapseHtmlBlocks: "Collapse HTML blocks",
collapseHtmlBlocksDescription:
"Artifacts mode collapses full HTML fallback automatically. Turn this on to also collapse full fenced HTML documents when Artifacts is off.",
"Artifacts mode collapses full HTML automatically. Turn on to also collapse fenced HTML documents when Artifacts is off.",
allowNetworkAccess: "Allow artifact network access",
allowNetworkAccessDescription:
"Let artifact previews load scripts, styles, fonts, media, fetch, and WebSocket resources from HTTP(S) CDNs. Keep off for fully offline previews.",
"Let artifact previews load scripts, styles, fonts, media, and network resources from CDNs. Keep off for fully offline previews.",
},
data: "Data",
exportHistory: "Export chat history",
exportHistoryDescription:
"Download all chats and messages as a JSON file.",
"Download all chats and messages as JSON.",
exportAction: "Export",
exportingAction: "Exporting...",
exportConversations: "Export Recents and Projects",
exportConversationsDescription:
"Download Recents only, or Recents plus project chats, as Raw JSONL, CSV, or ShareGPT JSONL, combined or one file per chat.",
"Download Recents or Recents plus project chats as Raw JSONL, CSV, or ShareGPT JSONL, combined or per chat.",
exportConversationsAction: "Export",
exportScopeRecents: "Recents",
exportScopeAll: "Recents + Projects",
@ -208,14 +214,14 @@ export const en = {
exportPerChatSuffix: "(per chat)",
importChats: "Import chats",
importChatsDescription:
"Add conversations from a JSONL, NDJSON, or CSV export to Recents.",
"Import a JSONL, NDJSON, or CSV export into Recents.",
importChatsAction: "Import",
importNoConversations: "No conversations found in file.",
importedOneChat: "Imported 1 conversation to Recents.",
importedChatCount: "Imported {count} conversations to Recents.",
importFailed: "Import failed.",
clearHistory: "Clear chat history",
clearHistoryDescription: "Delete local chat history from this device.",
clearHistoryDescription: "Delete chat history from this device.",
clearAction: "Clear",
clearAllChats: "Clear all chats",
clearAllChatsDescription: "Permanently delete every chat on this device.",
@ -228,7 +234,7 @@ export const en = {
clearOneChatTitle: "Clear 1 chat?",
clearChatsTitle: "Clear {count} chats?",
clearChatsConfirmDescription:
"This permanently deletes every chat and message stored on this device. This cannot be undone.",
"Permanently deletes every chat on this device. This cannot be undone.",
clearingAction: "Clearing...",
clearOneChatAction: "Clear 1 chat",
clearChatCountAction: "Clear {count} chats",
@ -251,12 +257,12 @@ export const en = {
},
connections: {
title: "Connections",
description: "Manage providers and external service connections.",
description: "Manage providers and external connections.",
},
apiKeys: {
title: "API",
description:
"Access Unsloth programmatically via the OpenAI-compatible API.",
"Access Unsloth via the OpenAI-compatible API.",
readDocs: "Read the API docs",
noAccess: "No API access yet.",
newBadge: "New",
@ -296,62 +302,71 @@ export const en = {
revokeToken: "Revoke token",
revokeTitle: 'Revoke access token "{name}"?',
revokeDescription:
"Applications using this token will immediately lose access. This cannot be undone.",
"Apps using this token immediately lose access. This cannot be undone.",
revokeAction: 'Revoke "{name}"',
revoking: "Revoking...",
},
about: {
title: "About",
description:
"Documentation, release notes, feedback, and Studio build info.",
studioVersion: "Studio Version",
"Docs, release notes, feedback, and build info.",
studioVersion: "Unsloth Version",
packageVersion: "Package Version",
updates: "Updates",
updates: "Update",
help: "Help",
documentation: "Documentation",
releaseNotes: "Release notes",
whatsNew: "What's new",
feedback: "Feedback",
reportIssue: "Report an issue",
license: {
sectionTitle: "License",
studioLabel: "Unsloth Studio",
studioLicense: "AGPL-3.0",
studioDescription:
"Open source under the GNU AGPL v3.0.",
libraryLabel: "Unsloth Core",
libraryLicense: "Apache-2.0",
libraryDescription: "Licensed under Apache 2.0.",
},
dangerZone: "Danger zone",
shutDownStudio: "Shut down Unsloth Studio",
shutDownStudioDescription:
"Stops the Studio server process and ends your session.",
"Stops the Unsloth server and ends your session.",
shutDown: "Shut down",
update: {
title: "Update Unsloth Studio",
openPowerShell: "Open PowerShell and run:",
openTerminal: "Open Terminal and run:",
commandText: "{label} text",
copied: "Copied",
copyCommand: "Copy command",
commandCopied: "{label} copied",
copyNamedCommand: "Copy {label}",
checkingInstall: "Checking how Studio was installed...",
checkingInstall: "Checking how Unsloth was installed...",
installIntro: "To install or update Unsloth:",
localUpdateHeading: "Local update",
installCommandUnix: "macOS/Linux install command",
installCommandWindows: "Windows install command",
localInstallDetected:
"Source or local install detected. To avoid replacing it with PyPI, update from the checkout or source you originally installed from.",
pullThenUpdate:
"Pull latest changes from your Unsloth repo checkout, then update Studio locally:",
"Local install detected. Update from your original checkout to avoid replacing it with PyPI.",
pullThenUpdate: "Pull the latest changes, then run the local installer:",
gitPullCommand: "git pull command",
localUpdateCommand: "local update command",
localInstallerFallback:
"If the Studio update command is unavailable, run the local installer from that checkout:",
localInstallerCommand: "local installer command",
sourceInstallDetected:
"This looks like a source or VCS package install. Reinstall from the original local path or Git URL you used.",
"Source or VCS package install detected. Reinstall from the original local path or Git URL.",
repoCheckoutFallback:
"If you still have the Unsloth repo checkout, run the local installer from that checkout:",
restartAfterUpdate:
"Restart Studio after updating for changes to take effect.",
"If you still have the repo checkout, run the local installer from it:",
restartAfterUpdate: "Restart Unsloth after updating.",
desktopManaged:
"The desktop app keeps its bundled backend updated and will prompt when a new version is available.",
unknownInstall:
"Studio could not detect how it was installed. Check how you installed Studio first, then choose the matching update path.",
curlOrPypi: "For curl or PyPI installs, run:",
updateCommand: "update command",
"Could not detect how Unsloth was installed. For installer or PyPI installs, use the commands above.",
localCheckout:
"For local checkout installs, update from that checkout instead and use the local update command:",
fallbackInstruction:
"If that fails or unsloth studio update is unavailable, run:",
fallbackCommand: "fallback command",
"For local checkout installs, run the local installer from that checkout:",
docs: "Install docs:",
docsInstall: "Installation",
docsUpdating: "Updating",
docsMac: "Mac",
docsWindows: "Windows",
},
},
},

View file

@ -93,7 +93,7 @@ export const zhCN = {
chat: "聊天",
connections: "连接",
apiKeys: "API",
about: "帮助",
about: "关于",
},
general: {
title: "通用",
@ -106,6 +106,12 @@ export const zhCN = {
chatDefaults: "聊天默认设置",
autoTitleNewChats: "自动为新聊天命名",
autoTitleNewChatsDescription: "根据第一条消息生成简短标题。",
notifications: {
sectionTitle: "通知",
showLlamaUpdates: "llama.cpp 更新通知",
showLlamaUpdatesDescription:
"有新的 llama.cpp 构建时提醒。如果只用于训练可以关闭。",
},
gettingStarted: "入门",
startOnboarding: "开始引导",
startOnboardingDescription: "重新打开设置向导,不会更改你的账号。",
@ -124,13 +130,13 @@ export const zhCN = {
action: "重置偏好设置",
confirmTitle: "重置所有本地偏好设置?",
confirmDescription:
"这会清除仅保存在本地的偏好设置,然后重新加载 Studio。聊天、API 访问权限和数据库中的设置不会受到影响。",
"这会清除仅保存在本地的偏好设置,然后重新加载 Unsloth。聊天、API 访问权限和数据库中的设置不会受到影响。",
confirmAction: "重置并重新加载",
},
},
profile: {
title: "个人资料",
description: "更新你在 Studio 中显示的个人资料。",
description: "更新你在 Unsloth 中显示的个人资料。",
changePicture: "更换头像",
displayName: "显示名称",
nameSaved: "个人资料名称已保存",
@ -150,7 +156,7 @@ export const zhCN = {
language: {
title: "语言",
label: "显示语言",
description: "选择 Studio 使用的语言。",
description: "选择 Unsloth 使用的语言。",
},
theme: {
title: "主题",
@ -274,9 +280,9 @@ export const zhCN = {
revoking: "撤销中...",
},
about: {
title: "帮助",
description: "文档、发布说明、反馈和 Studio 构建信息。",
studioVersion: "Studio 版本",
title: "关于",
description: "文档、发布说明、反馈和 Unsloth 构建信息。",
studioVersion: "Unsloth 版本",
packageVersion: "包版本",
updates: "更新",
help: "帮助",
@ -285,43 +291,52 @@ export const zhCN = {
whatsNew: "最新内容",
feedback: "反馈",
reportIssue: "报告问题",
license: {
sectionTitle: "许可证",
studioLabel: "Unsloth Studio",
studioLicense: "AGPL-3.0",
studioDescription: "基于 GNU AGPL v3.0 开源。",
libraryLabel: "Unsloth Core",
libraryLicense: "Apache-2.0",
libraryDescription: "基于 Apache License 2.0 许可。",
},
dangerZone: "危险区域",
shutDownStudio: "关闭 Unsloth Studio",
shutDownStudioDescription: "停止 Studio 服务进程并结束你的会话。",
shutDownStudioDescription: "停止 Unsloth 服务进程并结束你的会话。",
shutDown: "关闭",
update: {
title: "更新 Unsloth Studio",
openPowerShell: "打开 PowerShell 并运行:",
openTerminal: "打开终端并运行:",
commandText: "{label} 文本",
copied: "已复制",
copyCommand: "复制命令",
commandCopied: "{label} 已复制",
copyNamedCommand: "复制 {label}",
checkingInstall: "正在检查 Studio 的安装方式...",
checkingInstall: "正在检查 Unsloth 的安装方式...",
installIntro: "安装或更新 Unsloth",
localUpdateHeading: "本地更新",
installCommandUnix: "macOS/Linux 安装命令",
installCommandWindows: "Windows 安装命令",
localInstallDetected:
"检测到源码或本地安装。为避免替换为 PyPI 版本,请从最初安装时使用的 checkout 或源码位置更新。",
pullThenUpdate:
"从你的 Unsloth 仓库 checkout 拉取最新变更,然后本地更新 Studio",
"检测到本地安装。请从最初的 checkout 更新,以免被 PyPI 版本替换。",
pullThenUpdate: "拉取最新变更,然后运行本地安装器:",
gitPullCommand: "git pull 命令",
localUpdateCommand: "本地更新命令",
localInstallerFallback:
"如果 Studio 更新命令不可用,请从该 checkout 运行本地安装器:",
localInstallerCommand: "本地安装器命令",
sourceInstallDetected:
"这看起来是源码或 VCS 包安装。请从最初使用的本地路径或 Git URL 重新安装。",
repoCheckoutFallback:
"如果你仍保留 Unsloth 仓库 checkout请从该 checkout 运行本地安装器:",
restartAfterUpdate: "更新后重启 Studio使变更生效。",
restartAfterUpdate: "更新后请重启 Unsloth。",
desktopManaged:
"桌面应用会自动更新其内置后端,有新版本时会提示。",
unknownInstall:
"Studio 无法检测安装方式。请先确认你如何安装 Studio然后选择匹配的更新方式。",
curlOrPypi: "对于 curl 或 PyPI 安装,请运行:",
updateCommand: "更新命令",
"Unsloth 无法检测安装方式。如果你使用一键安装器或 PyPI 安装,请使用上面的命令。",
localCheckout:
"对于本地 checkout 安装,请改为从该 checkout 更新并使用本地更新命令:",
fallbackInstruction:
"如果失败,或 unsloth studio update 不可用,请运行:",
fallbackCommand: "备用命令",
"对于本地 checkout 安装,请改为从该 checkout 运行本地安装器:",
docs: "安装文档:",
docsInstall: "安装",
docsUpdating: "更新",
docsMac: "Mac",
docsWindows: "Windows",
},
},
},

View file

@ -552,6 +552,7 @@
.sidebar-nav-btn[data-state="open"],
.group\/project-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.group\/project-chat-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.group\/projects-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.group\/recent-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.group\/run-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn {
background-color: var(--nav-surface-hover) !important;
@ -562,6 +563,7 @@
.dark .sidebar-nav-btn[data-state="open"],
.dark .group\/project-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.dark .group\/project-chat-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.dark .group\/projects-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.dark .group\/recent-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.dark .group\/run-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn {
color: #fff !important;
@ -957,6 +959,11 @@
border-radius: 14px !important;
}
/* Account menu: a touch rounder than standard list menus. */
[data-slot="dropdown-menu-content"].app-user-menu.menu-soft-surface-up {
border-radius: 18px !important;
}
/* Every dropdown/menu/select/popover: borderless, chatbox shadow in light,
none in dark. !important so it also overrides the bespoke menu shadows. */
[data-slot="dropdown-menu-content"],
@ -1078,13 +1085,16 @@
[data-pill-compact="true"]
.composer-pill-btn:not([data-keep-label])[data-pill-label]:hover::after {
content: attr(data-pill-label);
@apply pointer-events-none absolute bottom-[calc(100%+6px)] left-1/2 z-50 -translate-x-1/2 rounded-[9px] bg-black px-2.5 py-1.5 text-[11px] font-medium leading-snug whitespace-nowrap text-white shadow-md;
/* Always one nowrap line, so always a full pill. */
@apply pointer-events-none absolute bottom-[calc(100%+6px)] left-1/2 z-50 -translate-x-1/2 rounded-full bg-black px-2.5 py-1.5 text-[11px] font-medium leading-snug whitespace-nowrap text-white shadow-md;
}
/* Compact caret pills (RAG, MCP) open their menu on click instead of
toggling off, so keep the icon and skip the X swap. */
toggling off, so keep the icon and skip the X swap. No data-active
requirement: the off-switch glyph rules below hide the icon on hover
even for inactive pills, which left a blank slot in compact mode. */
[data-pill-compact="true"]
.composer-pill-btn:not([data-keep-label]):has(.composer-pill-caret)[data-active="true"]:hover
.composer-pill-btn:not([data-keep-label]):has(.composer-pill-caret):hover
.composer-pill-glyph
> :not(.composer-pill-x) {
opacity: 1;
@ -1512,6 +1522,20 @@
}
}
/* Indeterminate loading bar: a 1/3-width segment sweeping the track. */
.loading-bar-slide {
animation: loading-bar-slide 1.3s ease-in-out infinite;
}
@keyframes loading-bar-slide {
from {
transform: translateX(-110%);
}
to {
transform: translateX(420%);
}
}
@keyframes artifact-loading-line {
0% {
transform: translate3d(-125%, 0, 0) scaleX(0.78);
@ -1750,10 +1774,10 @@
margin: 0 !important;
}
/* Composer shadow on the dark background color so toasts
do not merge into card-colored surfaces behind them. */
/* Dark toasts share the chatbox surface color (.chat-composer-surface
uses var(--card) in dark); the composer shadow lifts them off the page. */
.dark [data-sonner-toast][data-styled='true'] {
background-color: var(--background) !important;
background-color: var(--card) !important;
box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.16) !important;
}
@ -2099,6 +2123,11 @@
animation-iteration-count: infinite !important;
}
.loading-bar-slide {
animation-duration: 1.3s !important;
animation-iteration-count: infinite !important;
}
/* Keep the plus/x morph animating under reduced motion (small rotation,
like the spinners above). */
.unsloth-composer-plus svg {

View file

@ -856,6 +856,16 @@ def format_byte_count(num_bytes: float) -> str:
return f"{num_bytes:.1f} B"
def _progress_percent_step() -> int:
"""Non-tty milestone granularity. The in-app updater sets
UNSLOTH_PROGRESS_PERCENT_STEP=5 to stream finer progress lines."""
try:
step = int(os.environ.get("UNSLOTH_PROGRESS_PERCENT_STEP", "25"))
except ValueError:
return 25
return min(max(step, 1), 50)
class DownloadProgress:
def __init__(self, label: str, total_bytes: int | None) -> None:
self.label = label
@ -868,6 +878,7 @@ class DownloadProgress:
)
self.is_tty = term_ok and self.stream.isatty()
self.completed = False
self.milestone_step = _progress_percent_step()
self.last_milestone_percent = -1
self.last_milestone_bytes = 0
self.has_rendered_tty_progress = False
@ -918,7 +929,8 @@ class DownloadProgress:
should_emit = False
if self.total_bytes is not None:
percent = int((downloaded_bytes * 100) / max(self.total_bytes, 1))
milestone_percent = min((percent // 25) * 25, 100)
step = self.milestone_step
milestone_percent = min((percent // step) * step, 100)
if milestone_percent > self.last_milestone_percent and milestone_percent < 100:
self.last_milestone_percent = milestone_percent
should_emit = True