* checkpoint preview endpoint
* harden new preview endpoints
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* address review
* Studio preview: pin adapter, guard streaming submit, robust copy-link
Harden the public per-checkpoint preview surface:
- Pin use_adapter=True in the preview payload sanitizer. Otherwise an
unauthenticated /p caller can POST use_adapter=false, which calls
disable_adapter_layers() on the shared in-memory model without restoring
it; since load_model skips reloads for the same checkpoint, every later
visitor (the page never sends the field) keeps getting base-model output
instead of the fine-tuned checkpoint. Forcing it on also re-enables a
previously disabled adapter and no-ops on merged checkpoints.
- Ignore preview-page submits while a response is streaming. The send
button was disabled but the Enter handler still called requestSubmit(),
so a second request could start before the first reply landed in msgs and
reorder the chat history. Both the keydown and submit handlers now honor
the disabled button.
- Keep the cloudflare-URL polling loop alive across transient startup fetch
errors instead of letting one rejection halt it.
- Build the copy-link from a backend preview_ref (output dir relative to
outputs_root, gated on previewability and the two-segment /p route limit)
so a nested output dir no longer copies a basename-only link that 404s.
Expose preview_ref on training run summaries.
Add route-level security tests (path traversal, payload sanitization,
asset containment, CSP header, HTML title escaping, streaming lock held
until drained) and preview_ref unit tests.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio preview: Safari-safe submit and adapter pin only for LoRA
Follow-ups from cross-browser and route simulations:
- Preview page: send the message from a shared send() helper called by both
the form submit and the Enter key, instead of form.requestSubmit(). The
latter throws on Safari < 16 and older iOS, which broke Enter-to-send there.
Verified across Chromium, Firefox and WebKit with Playwright.
- Only pin use_adapter=True when the resolved checkpoint is a LoRA adapter
(adapter_config.json present); for a merged checkpoint strip it to None.
A merged model has no adapter to toggle, so forcing it on only produced a
per-request "not a PeftModel" warning. The cross-request base-model
contamination fix still holds for LoRA previews.
Add a merged-checkpoint test asserting use_adapter is stripped to None.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio preview: trim verbose comments
Tighten comments across the preview routes, page, checkpoint helpers, and tests
to short single-line notes; drop ones that just restate the code. No behavior
change (verified comment/docstring-only with comment_tools.py check).
* Harden preview routes for PR #6486
- Return a generic 400 detail on a rejected preview path so the public /p
route never echoes the absolute install path (the real reason is logged
server-side instead).
- Strip confirm_tool_calls, session_id and rag_scope in the preview payload
sanitizer so the public surface stays inert regardless of the tool gate.
- Use Path.is_relative_to for the asset containment check, matching the rest
of the codebase.
- Add img-src 'self' and font-src 'self' to the preview page CSP.
- Preview page: on a mid-stream error keep the streamed text, flag the break,
and restore the prompt so the user can retry; drop the unused --font-sans var.
---------
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>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
The fade is recomputed on a group's open/close state flip, but the groups
animate their height, so it measured scrollHeight mid-animation and the
fade could vanish at random. Re-measure on the collapsible animationend,
and add the missing pinnedOpen dep to the recompute effect.
Footer padding moves from pt-3 pb-4 to pb-3 with a conditional top: pt-1.5
when the update card is shown so the fade hugs it, pt-2.5 for the profile
on its own. When the update card is shown, shorten the fade above it
(h-10 -> h-3) so the list reads closer to the card.
* Studio: group the project export menu by Combined and Per chat
Replace the repeated (combined)/(per chat) suffix on every export row with a
section subheading, so the rows read Raw JSONL / CSV / ShareGPT JSONL under
Combined and Per chat headings.
* Studio: group export sections with DropdownMenuGroup
Wrap each Combined and Per chat section in DropdownMenuGroup for screen-reader
semantics, and drop the redundant px-3 already set by DropdownMenuLabel.
* Studio: confirm saved Hugging Face token with a tick
Pasting a token and clicking away saved it silently with no feedback.
Show a green tick in the field once a non-empty token is committed and
the input still matches the stored value, so the save is visible. Adds
the tokenSaved label to the en and zh-CN locales.
* Studio: let clicks pass through the saved-token tick
The decorative tick sat over the input and swallowed clicks, so clicking
it would not focus the field. Add pointer-events-none so clicks reach the
input; drop the now-unreachable title and keep an aria-label via role=img.
* Studio: slim the GLM-5.2 thinking menu width
The high|max effort menu has short labels, so drop it from min-w-44 to
min-w-40 for GLM-5.2 only. Other models keep the wider dropdown.
* Studio: keep Preserve thinking menus at full width
Only narrow the effort menu when it has no Preserve thinking row. That
row is longer than the high|max labels and wraps to two lines at the
min-w-40 floor, so gate the skinnier width on no preserve-thinking.
* studio: add sidebar update button (static design only)
Adds a clock-icon update card above the account button in the sidebar footer. Visual/layout only; update detection and click behavior are wired in follow-ups.
* studio: show installed version in sidebar update card + collapse to icon
Replaces the placeholder version with the real installed app version via @tauri-apps/api/app getVersion() (Tauri-only; hidden in browser). Collapsed sidebar now shows just the clock icon instead of hiding the card.
* studio: open Settings About (update section) when the sidebar update card is clicked
* studio: i18n the sidebar update button label and add aria-label
Address Gemini Code Assist review on #6545:
- wrap the hardcoded "Update available" label in t() (shell.updateAvailable,
en + zh-CN), matching the rest of the sidebar
- add aria-label so the collapsed icon-only button has an accessible name
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* studio: hide sidebar update card unless an update is available
Gates the card on useWebUpdateCheck so it stays hidden by default on both web and desktop, appearing only when the installed PyPI version is behind the latest release. Includes a TEMP localStorage dev override (devForceUpdateCard) to preview the card where there is no real update; remove before merge. Keeps the i18n label/aria-label; desktop (Tauri updater) detection not wired yet.
* Polish Studio sidebar update affordance
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: imagineer99 <samleejackson0@gmail.com>
* studio: report the true reasoning duration and fix the Stop button for thinking models
For a local GGUF the "Thought for N" label was timed entirely on the client by a
brittle edge-detector, so an always-think model (Qwen3 MTP) that buffers its whole
reasoning and flushes it in one chunk showed "1 second" instead of the real
minute-plus. The client cannot time reasoning it receives atomically, so make the
timing backend-authoritative.
Backend: generate_chat_completion_with_tools measures wall-clock reasoning and
emits a Studio reasoning_summary event (duration_ms) at the moment reasoning ends
-- the first answer token, or end-of-stream for a reasoning-only reply -- for both
the tool-detection pass and the final-answer pass. Timing resets per tool
iteration so the final answer's thinking time wins on the client (which takes the
latest reasoning_summary). routes/inference.py forwards the event in the GGUF tool
stream.
Frontend: parse the reasoning_summary SSE into a _reasoningDurationMs chunk and
use it as the authoritative reasoning duration (last write wins), clamped to >= 0
and guarded to a finite number so a malformed or proxied chunk cannot produce a
NaN label; the persisted value wins for the final "Thought for N" label, with the
previous live timer kept only as a fallback when no metadata arrives.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
PR #6364 added a branch that overrode the unsloth owner avatar with the
bundled circle-logo-small.png sticker. Revert it so unsloth uploads use
the live Hugging Face org avatar again, falling back to the colored
initial tile. Upstream re-uploads are unaffected since they still
resolve through provider logos.
Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
* Chat: match reasoning thinking icon to the composer bulb
The reasoning "Thinking..." indicator used lucide's LightbulbIcon while
the composer thinking toggle used a custom bulb glyph, so the two did not
match. Move that glyph into lib/bulb-icon.tsx and use it in both places
so they render the same icon.
* Let BulbIcon take and override svg props
---------
Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
* Studio: fall back to anonymous HF browsing on a malformed token
The Discover/Recommended feeds call the Hugging Face JS client
(`listModels`/`listDatasets`) directly from the browser. That client
throws `Your access token must start with 'hf_'` when handed a non-empty
token that isn't a well-formed HF token, instead of falling back to
anonymous access. A single bad value left in the HF token field (e.g. a
placeholder someone typed) therefore takes down the entire discovery feed
even though it works fine with no token at all.
Add `hfApiToken()` to the HF token store, which returns the token only
when it looks like a real `hf_...` credential and `undefined` otherwise,
and route hub-page's four HF call sites through it. Malformed tokens now
degrade to anonymous public browsing rather than erroring. The raw token
is still stored and shown in the settings field unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Studio: trim hfApiToken comments
Collapse the 11-line JSDoc to a 2-line note and drop the redundant
call-site comment in hub-page. AST signature check confirms code is
unchanged (comments only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Studio: refresh chat tour for the redesigned model picker
- Pick a model step describes the Recommended and On Device tabs instead of the old Hub and Fine-tuned split
- Find a model step (was Two tabs) covers Unsloth search vs Search Hub, the format and sort filters, and the OOM tag
- Settings step now anchors to the run settings panel on the right. The old anchor sat on the open settings button, which unmounts when settings opens, so the tooltip lost its target and drifted left
* Add guided tour step for the composer + menu
Polish for the in-chat model picker popover and its guided-tour step.
- Search box placeholder reads Search Unsloth models, matching the Unsloth-only listing.
- Search Hub button shows a Search all models tooltip on hover.
- Floating Eject pill moves 1px lower so it sits closer to the bottom edge.
- Results list max height trimmed by 1px (21rem to 335px) from the bottom only.
- Chat guided tour Two tabs step updated to describe Unsloth-scoped search plus Search Hub for all of Hugging Face.
- Discover defaults to the whole Hub instead of the unsloth org; an explicit
Unsloth choice is still remembered
- Discover models placeholder reads Search all models to match
- Give the Unsloth/All scope pill a min width so it stays readable
* Studio: keep model downloads running across navigation and loads
Downloads started from the chat model selector were tied to the staged
pick lifecycle, so they were cancelled in cases where Hub downloads keep
going. This makes the chat download flow behave like the Hub.
- Leaving the chat route or switching thread/project/new chat now detaches
the staging UI but keeps the in-flight transfer running in the global
download manager (new keepDownload option on abandonStagedModel).
- Staging a second pick no longer cancels the previous pick's download, so
multiple models/variants can download at once.
- Picking a model to download while another model is loading now starts the
download in the background instead of refusing, since a download is
independent of a load.
* Studio: also background-download remote GGUF quants while a model loads
isDownloadableHubRepo (wantManagerDownload) excludes GGUF sources, so an
uncached remote GGUF quant picked from the chat selector while another model
was loading fell through to the 'Another model is already loading' toast
instead of downloading in the background. Treat an uncached remote hub GGUF as
a background download too, matching the staged-pick download path.
Addresses review feedback from gemini-code-assist and codex on PR #6573.
* Studio: only toast a background download once it actually starts
The chat background-download path (used when a model is already loading)
fired the "Downloading in the background" toast unconditionally, but
requestStart can return without starting a job: a cross-transport partial
records a conflict that is only resolvable from the Hub download card, and
a busy sibling variant returns after its own toast. So the user could be
told a download started when none did, with no way to resolve the conflict
from chat.
requestStart now reports an outcome (started/conflict/busy/error). The
chat path only shows the success toast on an actual start and points the
user to the Hub when a transport conflict needs resolving. The Hub card
surface keeps its existing behavior (it renders the conflict resolver, so
it ignores the outcome).
* Studio: report background-download outcome from real job state
The chat background-download toast trusted requestStart's optimistic
"started", but a start can no-op without throwing: startJob finalizes the
job as "error" when the backend refuses or fails apiStart, its peer guard
skips a fresh start, and hasActiveOrPendingStart trips on a snapshot, peer
variant, or pending preflight that is not this request. So the user could
be told a download started when none did.
Derive the outcome from the actual job state of the exact key
(running/cancelling = started, otherwise error/busy), so the toast only
fires for a transfer that is really live.
Also guard against re-downloading the model that is already loading: the
/load flow downloads before it sets the checkpoint, and that fetch is not
a download-manager job, so picking the same id+variant again would start a
second transfer against the same cache. Detect that pick and surface a
"this model is already loading" toast instead.
---------
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
* Studio: hide RAG embedder from the On Device list
The bge-small-en-v1.5 RAG embedder (and other infra models) were already
hidden from Discover but still showed up in the On Device browse list,
cluttering the user's downloaded models. They are now filtered out of On
Device the same way, while a search that matches still reveals the row so
the user can confirm it is already downloaded.
* Studio: also check path/title when hiding infra models from On Device
isHiddenModelId only saw row.id and row.repoId, but local inventory rows can
have a null repoId and an id that is a hash rather than the file path/name, so
the llama.cpp validation probe (stories260K.gguf) could slip into the On Device
list. Pass the local row's path and title too, mirroring the backend's
_is_hidden_model(m.id, m.path).
Addresses review feedback from gemini-code-assist on PR #6572.
* Studio: exclude infra models from On Device count and dataset list
The On Device hidden-model filter was applied to datasets too, so a
dataset whose id/title/path contained an infra needle (bge-small-en-v1.5,
stories260k.gguf) was wrongly hidden. Bypass the filter for datasets, the
same way Discover and the format filter already do.
The On Device header count and the Cache/Local stat pills still used the
unfiltered row counts, so a fresh install with only the bge embedder
cached read 1 over an empty list. Count visible (non-infra) rows instead,
keeping full counts for datasets.
* Studio: count search-revealed infra rows in the On Device tally
The visible-row counts excluded every hidden row unconditionally, but the
On Device list reveals a hidden row when the search query matches it. So
with only the bge embedder cached and a "bge" search, the list showed one
row while the header and Cache stat stayed 0. Reuse isVisibleInventoryRow
for the counts so a query-revealed row is counted, keeping them in step
with the list.
---------
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
* studio: let users change their password from Settings
The only day-to-day way to change credentials was the destructive console command
'unsloth studio reset-password' (it deletes auth.db); the in-app change-password
page is the forced first-login flow and bounces non-forced users to /login.
Add a Change password control to Settings > General > Account: a small dialog
that takes the current and new password and calls the existing
POST /api/auth/change-password, then stores the rotated tokens it returns.
Username changes remain out of scope.
The dialog uses authFetch, so an expired access token is refreshed and the
request retried instead of failing with a spurious expired-token error for a user
who left Studio open past the token lifetime. The row is hidden in the Tauri
desktop app, which authenticates via desktop auto-auth with a generated secret:
there is no user-entered password to change there, and changing it would clear
the desktop secret.
* studio: harden settings password change
* studio: harden settings password dialog UX
---------
Co-authored-by: wasimysaid <wasimysdev@gmail.com>