Merge main into PR #5711: resolve Gemini-provider conflicts
Conflicts came from #5720 (native Gemini provider). All resolved keeping both branches' functionality: - provider-capabilities.ts: gemini bucket now uses #5720's narrow capability shape (temperature/topP/topK/presencePenalty true) plus the 27 extended-sampler fields from this PR (all false on gemini since Google's API doesn't accept them). stop=true added so the new generationConfig.stopSequences forwarding lights up the UI. - chat-adapter.ts: kept all 27-field forwarding from this PR; used the tighter comments from main. - routes/inference.py: pass both this PR's sampling kwargs (frequency_penalty/seed/stop/service_tier/parallel_tool_calls) and main's tools/tool_choice through to stream_chat_completion. - external_provider.py: same. Every dispatcher (anthropic/openai/ gemini) now takes both branches' new args. Added stop forwarding to _stream_gemini as generationConfig.stopSequences (capped at 5 per native API docs); updated test_gemini_stop_sequences_capped_to_5 to assert the native shape instead of the OAI-compat shape. 256/256 backend tests pass (test_sampling_params_routing 65 + anthropic/openai/gemini integration suites 191); frontend type-check plus vite build clean.
This commit is contained in:
commit
afed5fb791
108 changed files with 15570 additions and 1707 deletions
10
.github/workflows/notebooks-ci.yml
vendored
10
.github/workflows/notebooks-ci.yml
vendored
|
|
@ -285,7 +285,15 @@ jobs:
|
|||
# The PR-time CI must validate the code in this PR; PyPI unsloth
|
||||
# may lag the in-repo CPU-torch fallback in unsloth/kernels/utils.py
|
||||
# (lines 162-170) that handles missing torch._C._cuda_getCurrentRawStream.
|
||||
pip install --no-deps unsloth_zoo
|
||||
# unsloth_zoo from git main mirrors every other CI (Core / MLX /
|
||||
# install.sh) so PR-time validation sees the same zoo HEAD.
|
||||
for attempt in 1 2 3; do
|
||||
if pip install --no-deps "unsloth_zoo @ git+https://github.com/unslothai/unsloth-zoo"; then
|
||||
break
|
||||
fi
|
||||
[ "$attempt" -eq 3 ] && { echo "::error::unsloth_zoo install failed after 3 attempts"; exit 1; }
|
||||
sleep $((5 * attempt))
|
||||
done
|
||||
pip install --no-deps -e ./unsloth
|
||||
|
||||
- name: Convert notebooks for AST scan
|
||||
|
|
|
|||
16
.github/workflows/studio-backend-ci.yml
vendored
16
.github/workflows/studio-backend-ci.yml
vendored
|
|
@ -144,9 +144,19 @@ jobs:
|
|||
# versions ship a CPU build that imports cleanly on Linux.
|
||||
pip install 'bitsandbytes>=0.45'
|
||||
# unsloth.device_type imports unsloth_zoo.utils.Version at module
|
||||
# scope, so the conftest preload needs unsloth_zoo even though
|
||||
# it is an optional dep of unsloth.
|
||||
pip install 'unsloth_zoo>=2026.5.1'
|
||||
# scope, so the conftest preload needs unsloth_zoo. Pull from
|
||||
# git main so this job sees the same zoo HEAD as Core / MLX /
|
||||
# install.sh do (otherwise a fix on zoo main hides until release).
|
||||
# No --no-deps: matches prior `pip install 'unsloth_zoo>=2026.5.1'`
|
||||
# behaviour so triton etc. still come in for the Repo tests CPU
|
||||
# collection imports.
|
||||
for attempt in 1 2 3; do
|
||||
if pip install "unsloth_zoo @ git+https://github.com/unslothai/unsloth-zoo"; then
|
||||
break
|
||||
fi
|
||||
[ "$attempt" -eq 3 ] && { echo "::error::unsloth_zoo install failed after 3 attempts"; exit 1; }
|
||||
sleep $((5 * attempt))
|
||||
done
|
||||
pip install -e . --no-deps
|
||||
|
||||
- name: Repo tests (CPU, auto-discovered)
|
||||
|
|
|
|||
|
|
@ -215,6 +215,9 @@ Then to launch every time:
|
|||
unsloth studio -p 8888
|
||||
```
|
||||
|
||||
#### Advanced launch options
|
||||
Cap Studio's native CPU thread pools on high-core hosts: `UNSLOTH_CPU_THREADS=8 unsloth studio -p 8888`. Explicit `OMP_NUM_THREADS` / `MKL_NUM_THREADS` / `OPENBLAS_NUM_THREADS` / `NUMEXPR_NUM_THREADS` still take precedence.
|
||||
|
||||
#### Uninstall
|
||||
The recommended way to fully remove Unsloth Studio is the matching uninstall script for your OS. It stops any running servers, removes the install dir, the launcher data dir, the desktop shortcut, and any platform-specific entries (macOS `.app` bundle + Launch Services on Mac; Start Menu, `HKCU\Software\Unsloth` registry key and user `PATH` entries on Windows):
|
||||
|
||||
|
|
|
|||
52
install.ps1
52
install.ps1
|
|
@ -887,12 +887,19 @@ shell.Run cmd, 0, False
|
|||
}
|
||||
|
||||
# ── Check winget ──
|
||||
# winget is only needed to install Python or uv. If both are
|
||||
# already on PATH (Windows ARM64 GitHub-hosted runners, manual
|
||||
# python.org + Astral uv installs, corporate locked-down hosts
|
||||
# without the Store, etc.) the script can proceed without it.
|
||||
# We defer the hard failure to the Python / uv install branches
|
||||
# below, where winget is actually invoked.
|
||||
Write-TauriLog "STEP" "Checking system dependencies"
|
||||
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
|
||||
step "winget" "not available" "Red"
|
||||
substep "Install it from https://aka.ms/getwinget" "Yellow"
|
||||
substep "or install Python $PythonVersion and uv manually, then re-run." "Yellow"
|
||||
return (Exit-InstallFailure "winget is not available")
|
||||
$script:WingetAvailable = [bool](Get-Command winget -ErrorAction SilentlyContinue)
|
||||
if ($script:WingetAvailable) {
|
||||
step "winget" "available"
|
||||
} else {
|
||||
step "winget" "not available -- will require Python + uv to be already installed" "Yellow"
|
||||
substep "Get it from https://aka.ms/getwinget if Python / uv are not already on PATH." "Yellow"
|
||||
}
|
||||
|
||||
# ── Helper: detect a working Python 3.11-3.13 on the system ──
|
||||
|
|
@ -973,6 +980,12 @@ shell.Run cmd, 0, False
|
|||
step "python" "Python $($DetectedPython.Version) already installed"
|
||||
}
|
||||
if (-not $DetectedPython) {
|
||||
if (-not $script:WingetAvailable) {
|
||||
Write-Host "[ERROR] No compatible Python (3.11-3.13) found and winget is unavailable on this host." -ForegroundColor Red
|
||||
Write-Host " Install Python $PythonVersion from https://www.python.org/downloads/" -ForegroundColor Yellow
|
||||
Write-Host " and re-run this installer (make sure 'Add Python to PATH' is checked)." -ForegroundColor Yellow
|
||||
return (Exit-InstallFailure "winget required to install Python on this host")
|
||||
}
|
||||
substep "installing Python ${PythonVersion}..."
|
||||
$pythonPackageId = "Python.Python.$PythonVersion"
|
||||
# Temporarily lower ErrorActionPreference so that winget stderr
|
||||
|
|
@ -1024,14 +1037,19 @@ shell.Run cmd, 0, False
|
|||
Write-TauriLog "STEP" "Installing uv package manager"
|
||||
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
||||
substep "installing uv package manager..."
|
||||
$prevEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try { winget install --id=astral-sh.uv -e --accept-package-agreements --accept-source-agreements } catch {}
|
||||
$ErrorActionPreference = $prevEAP
|
||||
Refresh-SessionPath
|
||||
# Fallback: if winget didn't put uv on PATH, try the PowerShell installer
|
||||
if ($script:WingetAvailable) {
|
||||
$prevEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try { winget install --id=astral-sh.uv -e --accept-package-agreements --accept-source-agreements } catch {}
|
||||
$ErrorActionPreference = $prevEAP
|
||||
Refresh-SessionPath
|
||||
}
|
||||
# Fallback: if winget is unavailable or didn't put uv on PATH,
|
||||
# use Astral's official PowerShell installer. This is the only
|
||||
# supported path on hosts without winget (Windows ARM64 runners,
|
||||
# corporate machines without the Store, etc.).
|
||||
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
||||
substep "trying alternative uv installer..." "Yellow"
|
||||
substep "installing uv via https://astral.sh/uv/install.ps1..." "Yellow"
|
||||
Invoke-Expression (Invoke-RestMethod -Uri "https://astral.sh/uv/install.ps1")
|
||||
Refresh-SessionPath
|
||||
}
|
||||
|
|
@ -1300,7 +1318,7 @@ shell.Run cmd, 0, False
|
|||
if ($SkipTorch) {
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.7" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.8" unsloth-zoo }
|
||||
if ($baseInstallExit -eq 0) {
|
||||
# Resolve pydantic WITH deps so pip pins pydantic-core
|
||||
# to the matching version (no-torch-runtime.txt below
|
||||
|
|
@ -1314,7 +1332,7 @@ shell.Run cmd, 0, False
|
|||
}
|
||||
}
|
||||
} else {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.7" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.8" unsloth-zoo }
|
||||
}
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
|
|
@ -1352,7 +1370,7 @@ shell.Run cmd, 0, False
|
|||
if ($SkipTorch) {
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.7" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.8" unsloth-zoo }
|
||||
if ($baseInstallExit -eq 0) {
|
||||
# Same pydantic-with-deps trick as the migrated branch.
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython pydantic }
|
||||
|
|
@ -1364,7 +1382,7 @@ shell.Run cmd, 0, False
|
|||
}
|
||||
}
|
||||
} elseif ($StudioLocalInstall) {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.7" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.8" unsloth-zoo }
|
||||
} else {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
|
||||
}
|
||||
|
|
@ -1392,7 +1410,7 @@ shell.Run cmd, 0, False
|
|||
Write-TauriLog "STEP" "Installing unsloth"
|
||||
substep "installing unsloth (this may take a few minutes)..."
|
||||
if ($StudioLocalInstall) {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.7" --torch-backend=auto }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.8" --torch-backend=auto }
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
||||
|
|
|
|||
10
install.sh
10
install.sh
|
|
@ -1873,7 +1873,7 @@ if [ "$_MIGRATED" = true ]; then
|
|||
# to prevent transitive torch resolution.
|
||||
run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
|
||||
--reinstall-package unsloth --reinstall-package unsloth-zoo \
|
||||
"unsloth>=2026.5.7" unsloth-zoo
|
||||
"unsloth>=2026.5.8" unsloth-zoo
|
||||
# Resolve pydantic WITH deps so pip pins pydantic-core to the
|
||||
# matching version (no-torch-runtime.txt below is --no-deps).
|
||||
# All transitive deps are torch-free.
|
||||
|
|
@ -1886,7 +1886,7 @@ if [ "$_MIGRATED" = true ]; then
|
|||
else
|
||||
run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
|
||||
--reinstall-package unsloth --reinstall-package unsloth-zoo \
|
||||
"unsloth>=2026.5.7" unsloth-zoo
|
||||
"unsloth>=2026.5.8" unsloth-zoo
|
||||
fi
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
substep "overlaying local repo (editable)..."
|
||||
|
|
@ -2054,7 +2054,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
|
||||
--upgrade-package unsloth --upgrade-package unsloth-zoo \
|
||||
"unsloth>=2026.5.7" unsloth-zoo
|
||||
"unsloth>=2026.5.8" unsloth-zoo
|
||||
# Same pydantic-with-deps trick as the migrated branch.
|
||||
run_install_cmd "install pydantic (with deps for compatible core)" \
|
||||
uv pip install --python "$_VENV_PY" pydantic
|
||||
|
|
@ -2072,7 +2072,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
fi
|
||||
elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \
|
||||
--upgrade-package unsloth "unsloth>=2026.5.7" unsloth-zoo
|
||||
--upgrade-package unsloth "unsloth>=2026.5.8" unsloth-zoo
|
||||
substep "overlaying local repo (editable)..."
|
||||
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
|
||||
substep "overlaying unsloth-zoo from git main..."
|
||||
|
|
@ -2104,7 +2104,7 @@ else
|
|||
tauri_log "STEP" "Installing Unsloth"
|
||||
substep "installing unsloth (this may take a few minutes)..."
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.7" --torch-backend=auto
|
||||
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.8" --torch-backend=auto
|
||||
substep "overlaying local repo (editable)..."
|
||||
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
|
||||
substep "overlaying unsloth-zoo from git main..."
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,46 +1,29 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Validator for user-supplied llama-server pass-through args.
|
||||
"""Boundary validator for user-supplied llama-server pass-through args.
|
||||
|
||||
Studio runs llama-server as a managed subprocess and lets callers pass
|
||||
extra flags directly (CLI: ``unsloth run ... --top-k 20``; HTTP:
|
||||
``LoadRequest.llama_extra_args``). This module is the boundary that
|
||||
rejects only flags Studio fundamentally cannot share with the user --
|
||||
model identity, the auth key, and the network endpoint Studio's HTTP
|
||||
proxy targets. Anything else passes through.
|
||||
Reject only flags Studio manages (model identity, auth, network,
|
||||
parallel slots). Everything else (sampling, ``-c``, ``-ngl``,
|
||||
``--flash-attn``, ``--cache-type-*``, ``--spec-*``, ``--jinja``, ...)
|
||||
is appended after Studio's auto-set flags so llama.cpp's last-wins
|
||||
parser lets the user override.
|
||||
|
||||
User-supplied args are appended to ``cmd`` after Studio's auto-set
|
||||
flags, so llama.cpp's last-wins CLI parsing makes the user's value
|
||||
override the auto-set one. That covers tunable knobs the user might
|
||||
reasonably want to override -- ``-c``/``--ctx-size``,
|
||||
``-np``/``--parallel``, ``-fa``/``--flash-attn``,
|
||||
``-ngl``/``--gpu-layers``, ``-t``/``--threads``, ``-fit``/``--fit*``,
|
||||
``--cache-type-k/v``, ``--chat-template-file/-kwargs``,
|
||||
``--spec-*``, ``--jinja``/``--no-jinja``,
|
||||
``--no-context-shift``/``--context-shift``, sampling params, etc.
|
||||
|
||||
Reference: https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md
|
||||
Ref: https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Optional
|
||||
|
||||
# Each group is the full set of aliases (short + long) for one
|
||||
# hard-denied flag, taken from the llama-server README. If llama.cpp
|
||||
# adds a new alias for an existing denied flag, extend the relevant
|
||||
# group.
|
||||
#
|
||||
# Flags NOT in this list (e.g. -c, --parallel, --flash-attn, -ngl,
|
||||
# -t/--threads, --jinja, --no-context-shift, --fit*, --cache-type-*,
|
||||
# --chat-template-*, --spec-*) pass through and override Studio's
|
||||
# auto-set version via llama.cpp's last-wins CLI parsing.
|
||||
# Each group = every alias (short + long) of one hard-denied flag.
|
||||
# Extend the matching group when llama.cpp adds a new alias.
|
||||
_DENYLIST_GROUPS: tuple[frozenset[str], ...] = (
|
||||
# Model identity -- Studio resolves the model from LoadRequest and
|
||||
# passes -m / mmproj after downloading from HF if needed. A second
|
||||
# -m would point at a different model than the one Studio thinks
|
||||
# is loaded.
|
||||
# Parallel slots: owned by typer --parallel; a pass-through would
|
||||
# desync app.state.llama_parallel_slots from llama-server.
|
||||
frozenset({"-np", "--parallel", "--n-parallel"}),
|
||||
# Model identity: Studio resolves it from LoadRequest; a second
|
||||
# -m would load a different model than Studio thinks it loaded.
|
||||
frozenset({"-m", "--model"}),
|
||||
frozenset({"-mu", "--model-url"}),
|
||||
frozenset({"-dr", "--docker-repo"}),
|
||||
|
|
@ -51,28 +34,21 @@ _DENYLIST_GROUPS: tuple[frozenset[str], ...] = (
|
|||
frozenset({"-hft", "--hf-token"}),
|
||||
frozenset({"-mm", "--mmproj"}),
|
||||
frozenset({"-mmu", "--mmproj-url"}),
|
||||
# Networking -- Studio binds llama-server's port and reverse-proxies
|
||||
# HTTP traffic to it. Retargeting host/port/path/prefix would
|
||||
# orphan Studio's proxy and the UI would lose the server.
|
||||
# Networking: Studio binds + proxies; retargeting orphans the proxy.
|
||||
frozenset({"--host"}),
|
||||
frozenset({"--port"}),
|
||||
frozenset({"--path"}),
|
||||
frozenset({"--api-prefix"}),
|
||||
frozenset({"--reuse-port"}),
|
||||
# Auth / TLS -- Studio terminates auth at its own layer; an
|
||||
# upstream --api-key would shadow Studio's UNSLOTH_DIRECT_STREAM
|
||||
# key, and TLS on llama-server would break the local proxy hop.
|
||||
# Auth / TLS: Studio terminates auth; upstream --api-key / TLS
|
||||
# shadows Studio's key and breaks the proxy hop.
|
||||
frozenset({"--api-key"}),
|
||||
frozenset({"--api-key-file"}),
|
||||
frozenset({"--ssl-key-file"}),
|
||||
frozenset({"--ssl-cert-file"}),
|
||||
# Single-model server -- Studio runs one model per llama-server
|
||||
# process and serves its own UI. Enabling multi-model loading or
|
||||
# llama-server's built-in web UI changes the surface clients see.
|
||||
# ``--webui``/``--no-webui`` are the legacy spelling; current
|
||||
# upstream uses ``--ui``/``--no-ui`` + ``--ui-*`` companions.
|
||||
# Keep both so the denylist matches old and new llama-server
|
||||
# binaries (Studio's prebuilt vs system-llama.cpp).
|
||||
# Built-in web UI. --webui/--no-webui is the legacy spelling;
|
||||
# upstream renamed to --ui/--no-ui + --ui-*. Keep both so prebuilt
|
||||
# and system llama.cpp binaries both match.
|
||||
frozenset({"--webui", "--no-webui"}),
|
||||
frozenset({"--ui", "--no-ui"}),
|
||||
frozenset({"--ui-config"}),
|
||||
|
|
@ -82,32 +58,46 @@ _DENYLIST_GROUPS: tuple[frozenset[str], ...] = (
|
|||
frozenset({"--models-preset"}),
|
||||
frozenset({"--models-max"}),
|
||||
frozenset({"--models-autoload", "--no-models-autoload"}),
|
||||
# Server-mode flips: --embedding / --rerank restrict llama-server to
|
||||
# those endpoints, breaking Studio's /v1/chat/completions hop.
|
||||
frozenset({"--embedding", "--embeddings"}),
|
||||
frozenset({"--rerank", "--reranking"}),
|
||||
# llama-server's own built-in tools flag would silently stack on top
|
||||
# of Studio's --enable-tools / --disable-tools policy resolver.
|
||||
frozenset({"--tools"}),
|
||||
)
|
||||
|
||||
_DENYLIST: frozenset[str] = frozenset().union(*_DENYLIST_GROUPS)
|
||||
|
||||
|
||||
def _flag_name(token: str) -> Optional[str]:
|
||||
"""Return the flag name for a token, or None if it isn't a flag.
|
||||
"""Flag name for ``token``, or None if it isn't a flag.
|
||||
|
||||
Peels ``--key=value`` to the bare ``--key``. Plain numeric values
|
||||
like ``-1`` or ``-0.5`` (e.g. ``--seed -1``) are values, not flags;
|
||||
llama-server short-form flags always start with a letter.
|
||||
Peels `--key=value` to `--key`, treats `-1` / `-0.5` as values
|
||||
(llama-server shorts always start with a letter), strips
|
||||
whitespace, and normalises attached `-np8` / signed `-np-1` /
|
||||
digit-prefix-junk `-np8x` to `-np`. Mirrors the CLI's
|
||||
`_expand_attached_np_short`.
|
||||
"""
|
||||
token = token.strip()
|
||||
if not token.startswith("-") or token in {"-", "--"}:
|
||||
return None
|
||||
if len(token) >= 2 and (token[1].isdigit() or token[1] == "."):
|
||||
return None
|
||||
return token.split("=", 1)[0]
|
||||
name = token.split("=", 1)[0]
|
||||
if len(name) > 3 and name.startswith("-np"):
|
||||
suffix = name[3:]
|
||||
if suffix[0].isdigit() or (
|
||||
len(suffix) > 1 and suffix[0] in {"-", "+"} and suffix[1].isdigit()
|
||||
):
|
||||
return "-np"
|
||||
return name
|
||||
|
||||
|
||||
def validate_extra_args(args: Optional[Iterable[str]]) -> list[str]:
|
||||
"""Validate user-supplied llama-server args.
|
||||
|
||||
Returns the args as a flat list ready to extend the llama-server
|
||||
command. Raises ``ValueError`` (with the offending flag in the
|
||||
message) the moment a token resolves to a Studio-managed flag.
|
||||
"""
|
||||
"""Validate user-supplied llama-server args. Returns a flat list
|
||||
ready to extend the llama-server command; raises ``ValueError``
|
||||
naming the offending flag on the first managed token."""
|
||||
if not args:
|
||||
return []
|
||||
out: list[str] = []
|
||||
|
|
@ -124,15 +114,15 @@ def validate_extra_args(args: Optional[Iterable[str]]) -> list[str]:
|
|||
|
||||
|
||||
def is_managed_flag(flag: str) -> bool:
|
||||
"""True if ``flag`` is a Studio-managed llama-server flag."""
|
||||
return flag in _DENYLIST
|
||||
"""True if ``flag`` is Studio-managed. Normalises via ``_flag_name``
|
||||
so `-np8` / `--parallel=8` classify like the canonical tokens."""
|
||||
normalised = _flag_name(flag)
|
||||
return normalised is not None and normalised in _DENYLIST
|
||||
|
||||
|
||||
# Pass-through flags that shadow first-class ``LoadRequest`` fields
|
||||
# (max_seq_length, cache_type_kv, speculative_type,
|
||||
# chat_template_override). Stripped from inherited extras so they
|
||||
# can't last-wins-override an Apply that re-sets the same first-class
|
||||
# field.
|
||||
# Pass-through flags that shadow first-class LoadRequest fields;
|
||||
# stripped from inherited extras so they can't last-wins-override an
|
||||
# Apply that re-sets the same field.
|
||||
_CONTEXT_FLAGS: frozenset[str] = frozenset({"-c", "--ctx-size"})
|
||||
_CACHE_FLAGS: frozenset[str] = frozenset(
|
||||
{"-ctk", "--cache-type-k", "-ctv", "--cache-type-v"}
|
||||
|
|
@ -169,9 +159,8 @@ _SHADOWING_FLAGS: frozenset[str] = (
|
|||
_CONTEXT_FLAGS | _CACHE_FLAGS | _SPEC_FLAGS | _TEMPLATE_FLAGS
|
||||
)
|
||||
|
||||
# Boolean flags inside _SHADOWING_FLAGS that take no value. The
|
||||
# value-consuming heuristic in strip_shadowing_flags must skip just the
|
||||
# flag for these, never the following token.
|
||||
# Shadowing flags that take no value -- strip the flag only, never the
|
||||
# following token.
|
||||
_BOOLEAN_SHADOWING_FLAGS: frozenset[str] = frozenset(
|
||||
{"--spec-default", "--jinja", "--no-jinja"}
|
||||
)
|
||||
|
|
@ -187,14 +176,11 @@ def strip_shadowing_flags(
|
|||
) -> list[str]:
|
||||
"""Strip flags that shadow first-class Studio settings.
|
||||
|
||||
Used when the route inherits a previous load's ``llama_extra_args``
|
||||
so that an inherited ``-c 4096`` cannot override the current
|
||||
request's ``max_seq_length`` (and equivalents for cache /
|
||||
speculative / chat template). Each ``strip_*`` flag controls one
|
||||
group; the route only strips groups whose corresponding first-class
|
||||
field was actually supplied by the caller, so an inherited
|
||||
``--chat-template-file`` survives an Apply that omits both
|
||||
``llama_extra_args`` and ``chat_template_override``.
|
||||
Used when inheriting a previous load's ``llama_extra_args`` so an
|
||||
inherited `-c 4096` can't override the current `max_seq_length`
|
||||
(same for cache / spec / template). Each ``strip_*`` toggle
|
||||
controls one group; the route only strips groups whose first-class
|
||||
field the caller actually supplied.
|
||||
"""
|
||||
shadowing: set[str] = set()
|
||||
if strip_context:
|
||||
|
|
@ -216,9 +202,8 @@ def strip_shadowing_flags(
|
|||
out.append(tok)
|
||||
i += 1
|
||||
continue
|
||||
# Drop this token. Boolean shadowing flags never carry a value;
|
||||
# other shadowing flags consume the next token when it isn't a
|
||||
# flag and the value isn't already packed as ``--key=value``.
|
||||
# Drop the flag; consume the next token too unless it's
|
||||
# boolean, already inline (`-c=4096`), or another flag.
|
||||
if flag in _BOOLEAN_SHADOWING_FLAGS or "=" in tok:
|
||||
i += 1
|
||||
elif i + 1 < n and _flag_name(tokens[i + 1]) is None:
|
||||
|
|
|
|||
|
|
@ -65,28 +65,77 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
|
|||
},
|
||||
"gemini": {
|
||||
"display_name": "Google Gemini",
|
||||
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
# Curated lineup — Google's /v1beta/openai/models returns dozens
|
||||
# of historical / experimental / embedding ids. Cap to the current
|
||||
# 3.x family plus the rolling `*-latest` aliases.
|
||||
# Native Gemini REST endpoint -- the Gemini API does NOT speak
|
||||
# OpenAI Chat Completions on this base. Requests/responses are
|
||||
# translated in `_stream_gemini` in external_provider.py.
|
||||
# API reference: https://ai.google.dev/gemini-api/docs
|
||||
"base_url": "https://generativelanguage.googleapis.com/v1beta",
|
||||
# Curated lineup -- the live ListModels response returns dozens
|
||||
# of historical / experimental / embedding ids. Cap to the
|
||||
# current chat-capable Gemini families (3.5 / 3.1 / 3 Flash /
|
||||
# 2.5) plus the Nano Banana image trio and the rolling
|
||||
# `*-latest` aliases. Excluded on purpose:
|
||||
# - `gemini-2.0-flash*` (Google retired 2026-06-01; 404 on use)
|
||||
# - `gemini-3-pro-preview` (shut down 2026-03-09; auto-redirects
|
||||
# to `gemini-3.1-pro-preview` per Google's deprecation notice,
|
||||
# so we surface 3.1 directly and skip the redirect).
|
||||
# The allowlist below blocks the retired ids from re-appearing
|
||||
# via the live ListModels fetch. Verified against the live
|
||||
# `/v1beta/models` catalog 2026-05-24.
|
||||
"default_models": [
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3.1-flash-lite",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-pro-latest",
|
||||
"gemini-flash-latest",
|
||||
"gemini-flash-lite-latest",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-3-pro-image-preview",
|
||||
"gemini-3.1-flash-image-preview",
|
||||
"gemini-2.5-flash-image",
|
||||
],
|
||||
"supports_streaming": True,
|
||||
"supports_vision": True,
|
||||
"supports_tool_calling": True,
|
||||
"auth_header": "Authorization",
|
||||
"auth_prefix": "Bearer ",
|
||||
"notes": "OpenAI-compatible endpoint. API key from https://aistudio.google.com/apikey.",
|
||||
# The native API takes the API key on the `x-goog-api-key`
|
||||
# header. An empty `auth_prefix` ensures we send the bare key.
|
||||
"auth_header": "x-goog-api-key",
|
||||
"auth_prefix": "",
|
||||
"openai_compatible": False,
|
||||
"notes": (
|
||||
"Native Gemini API. Translation lives in _stream_gemini. "
|
||||
"API key from https://aistudio.google.com/apikey. "
|
||||
"See https://ai.google.dev/gemini-api/docs for endpoint shapes."
|
||||
),
|
||||
# Even after the regex match, drop ids that Google still
|
||||
# returns from ListModels but routes via implicit redirect.
|
||||
# gemini-3-pro-preview was shut down 2026-03-09 and is
|
||||
# auto-aliased to gemini-3.1-pro-preview; we surface the
|
||||
# canonical id only so users do not see two cards for the
|
||||
# same underlying model.
|
||||
"model_id_deny_exact": ("gemini-3-pro-preview",),
|
||||
# Matches the chat-capable 3.5 / 3.1 / 3 / 2.5 families plus the
|
||||
# rolling *-latest aliases (which Google rolls forward as new
|
||||
# generations ship). Image-tier ids (`-image`, `-image-preview`,
|
||||
# `nano-banana-pro-preview`) flow through the Nano Banana
|
||||
# `responseModalities` path in `_stream_gemini`. Retired 2.0
|
||||
# ids ARE NOT in this regex on purpose -- Google's ListModels
|
||||
# would otherwise re-surface them and they 404 on use.
|
||||
"model_id_allowlist": re.compile(
|
||||
r"^(gemini-3\.1-flash-lite|gemini-3-flash-preview|"
|
||||
r"gemini-3\.1-pro-preview|gemini-pro-latest|"
|
||||
r"gemini-flash-latest|gemini-flash-lite-latest)$"
|
||||
r"^("
|
||||
r"gemini-3\.5-(?:flash|pro)(?:-preview)?|"
|
||||
r"gemini-3\.1-(?:flash|pro|flash-lite)(?:-preview)?(?:-customtools)?|"
|
||||
r"gemini-3\.1-flash-image-preview|"
|
||||
r"gemini-3-(?:flash|pro)(?:-preview)?|"
|
||||
r"gemini-3-pro-image-preview|"
|
||||
r"nano-banana-pro-preview|"
|
||||
r"gemini-2\.5-pro|gemini-2\.5-flash|gemini-2\.5-flash-lite|"
|
||||
r"gemini-2\.5-flash-image|"
|
||||
r"gemini-pro-latest|gemini-flash-latest|gemini-flash-lite-latest"
|
||||
r")$"
|
||||
),
|
||||
# Gemini's OpenAI-compatible layer inherits OpenAI's 4-stop cap
|
||||
# (https://ai.google.dev/gemini-api/docs/openai). Without the
|
||||
|
|
|
|||
|
|
@ -632,8 +632,17 @@ def _validate_and_resolve_host(hostname: str, port: int) -> tuple[bool, str, str
|
|||
|
||||
for *_, sockaddr in infos:
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
# `not ip.is_global` rejects every category the denylist below
|
||||
# also rejects PLUS shared address space (100.64.0.0/10 carrier-
|
||||
# grade NAT) and benchmarking/documentation/exchange ranges that
|
||||
# Python classifies with `is_private=False` and `is_global=False`
|
||||
# (see https://docs.python.org/3/library/ipaddress.html#ipaddress.IPv4Address.is_global).
|
||||
# The explicit predicates after it give human-readable categories
|
||||
# in the error message, but a single non-global check is the
|
||||
# source of truth and prevents future ranges from leaking.
|
||||
if (
|
||||
ip.is_private
|
||||
not ip.is_global
|
||||
or ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
|
|
|
|||
|
|
@ -3057,6 +3057,14 @@ class UnslothTrainer:
|
|||
|
||||
logger.info("Configuring DeepSeek OCR data collator...\n")
|
||||
FastVisionModel.for_training(self.model)
|
||||
# DeepSeek OCR's (image_size, base_size, crop_mode) is a
|
||||
# coupled preset; changing image_size alone desyncs the
|
||||
# per-crop pixel grid from num_queries. Use Gundam.
|
||||
if training_args.get("vision_image_size") is not None:
|
||||
logger.info(
|
||||
"Vision image resize ignored for DeepSeek OCR "
|
||||
"(uses fixed Gundam preset).\n"
|
||||
)
|
||||
data_collator = DeepSeekOCRDataCollator(
|
||||
tokenizer = self.tokenizer,
|
||||
model = self.model,
|
||||
|
|
@ -3123,7 +3131,21 @@ class UnslothTrainer:
|
|||
from unsloth.trainer import UnslothVisionDataCollator
|
||||
|
||||
FastVisionModel.for_training(self.model)
|
||||
data_collator = UnslothVisionDataCollator(self.model, self.tokenizer)
|
||||
vision_image_size = training_args.get("vision_image_size")
|
||||
if vision_image_size is None:
|
||||
data_collator = UnslothVisionDataCollator(
|
||||
self.model, self.tokenizer
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Vision image resize: {vision_image_size} (max dimension)\n"
|
||||
)
|
||||
data_collator = UnslothVisionDataCollator(
|
||||
self.model,
|
||||
self.tokenizer,
|
||||
resize = vision_image_size,
|
||||
resize_dimension = "max",
|
||||
)
|
||||
logger.info("Vision data collator configured\n")
|
||||
|
||||
# ========== TRAINING CONFIGURATION ==========
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ class TrainingBackend:
|
|||
"hf_token": kwargs.get("hf_token", ""),
|
||||
"load_in_4bit": kwargs.get("load_in_4bit", True),
|
||||
"max_seq_length": kwargs.get("max_seq_length", 2048),
|
||||
"vision_image_size": kwargs.get("vision_image_size"),
|
||||
"hf_dataset": kwargs.get("hf_dataset", ""),
|
||||
"local_datasets": kwargs.get("local_datasets"),
|
||||
"local_eval_datasets": kwargs.get("local_eval_datasets"),
|
||||
|
|
|
|||
|
|
@ -959,7 +959,47 @@ def _activate_transformers_version(model_name: str) -> None:
|
|||
activate_transformers_for_subprocess(model_name)
|
||||
|
||||
|
||||
def _adapt_for_mlx_vlm(items):
|
||||
def _mlx_vlm_max_resized_size(width: int, height: int, target: int) -> tuple[int, int]:
|
||||
if width <= 0 or height <= 0 or target <= 0:
|
||||
return width, height
|
||||
largest_side = max(width, height)
|
||||
if largest_side <= target:
|
||||
return width, height
|
||||
# Integer formula matches unsloth_zoo's collator (Python round() differs
|
||||
# by 1px on half-pixel cases). max(1, _) avoids zero-side degenerate output.
|
||||
new_w = max(1, (width * target + largest_side // 2) // largest_side)
|
||||
new_h = max(1, (height * target + largest_side // 2) // largest_side)
|
||||
return new_w, new_h
|
||||
|
||||
|
||||
def _resize_mlx_vlm_image(image, resize):
|
||||
if resize is None:
|
||||
return image
|
||||
try:
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
except ImportError:
|
||||
return image
|
||||
if not isinstance(image, Image.Image):
|
||||
return image
|
||||
image = image.convert("RGB")
|
||||
new_size = _mlx_vlm_max_resized_size(*image.size, int(resize))
|
||||
if new_size != image.size:
|
||||
resampling = getattr(Image, "Resampling", Image).LANCZOS
|
||||
image = image.resize(new_size, resampling)
|
||||
# When a resize is requested, hand mlx-vlm a writable RGB ndarray so its
|
||||
# PIL-path square-resize is skipped and HF processors don't warn on
|
||||
# non-writable views. resize=None (Default) above keeps the original PIL.
|
||||
return np.array(image, copy = True)
|
||||
|
||||
|
||||
def _resize_mlx_vlm_images(value, resize):
|
||||
if isinstance(value, list):
|
||||
return [_resize_mlx_vlm_image(image, resize) for image in value]
|
||||
return _resize_mlx_vlm_image(value, resize)
|
||||
|
||||
|
||||
def _adapt_for_mlx_vlm(items, resize = None):
|
||||
"""Adapt GPU-path VLM dataset output for mlx-vlm consumption.
|
||||
|
||||
The GPU path embeds PIL images inside messages content as
|
||||
|
|
@ -979,7 +1019,7 @@ def _adapt_for_mlx_vlm(items):
|
|||
if isinstance(part, dict) and part.get("type") == "image":
|
||||
img = part.get("image")
|
||||
if img is not None:
|
||||
images.append(img)
|
||||
images.append(_resize_mlx_vlm_image(img, resize))
|
||||
new_content.append({"type": "image"})
|
||||
else:
|
||||
new_content.append(part)
|
||||
|
|
@ -990,9 +1030,9 @@ def _adapt_for_mlx_vlm(items):
|
|||
if images:
|
||||
out["image"] = images[0] if len(images) == 1 else images
|
||||
elif "image" in item:
|
||||
out["image"] = item["image"]
|
||||
out["image"] = _resize_mlx_vlm_images(item["image"], resize)
|
||||
elif "images" in item:
|
||||
out["images"] = item["images"]
|
||||
out["images"] = _resize_mlx_vlm_images(item["images"], resize)
|
||||
adapted.append(out)
|
||||
return adapted
|
||||
|
||||
|
|
@ -1168,6 +1208,25 @@ def _run_mlx_training(event_queue, stop_queue, config):
|
|||
|
||||
is_vlm = bool(is_dataset_image and getattr(model, "_is_vlm_model", False))
|
||||
model._is_vlm_model = is_vlm
|
||||
vision_image_size = config.get("vision_image_size")
|
||||
# DeepSeek OCR uses a coupled preset tuple; skip resize like the Torch path.
|
||||
_model_name_lower = str(config.get("model_name", "")).lower()
|
||||
_is_deepseek_ocr = "deepseek" in _model_name_lower and "ocr" in _model_name_lower
|
||||
if is_vlm and vision_image_size is not None and _is_deepseek_ocr:
|
||||
_send(
|
||||
"status",
|
||||
status_message = (
|
||||
"MLX vision image resize ignored for DeepSeek OCR "
|
||||
"(uses fixed Gundam preset)."
|
||||
),
|
||||
)
|
||||
vision_image_size = None
|
||||
elif is_vlm and vision_image_size is not None:
|
||||
vision_image_size = int(vision_image_size)
|
||||
_send(
|
||||
"status",
|
||||
status_message = f"MLX vision image resize: {vision_image_size} (max dimension)",
|
||||
)
|
||||
|
||||
# ── 2. Apply LoRA / full FT ──
|
||||
# Pass gradient_checkpointing as string ("mlx"/"unsloth"/"none"/etc.)
|
||||
|
|
@ -1302,7 +1361,10 @@ def _run_mlx_training(event_queue, stop_queue, config):
|
|||
progress_callback = _fmt_progress,
|
||||
)
|
||||
if vlm_info.get("success"):
|
||||
dataset = _adapt_for_mlx_vlm(vlm_info["dataset"])
|
||||
dataset = _adapt_for_mlx_vlm(
|
||||
vlm_info["dataset"],
|
||||
resize = vision_image_size,
|
||||
)
|
||||
else:
|
||||
errors = vlm_info.get("errors", [])
|
||||
raise ValueError(
|
||||
|
|
@ -1317,7 +1379,10 @@ def _run_mlx_training(event_queue, stop_queue, config):
|
|||
dataset_name = hf_dataset or "local",
|
||||
)
|
||||
if ev_info.get("success"):
|
||||
eval_dataset = _adapt_for_mlx_vlm(ev_info["dataset"])
|
||||
eval_dataset = _adapt_for_mlx_vlm(
|
||||
ev_info["dataset"],
|
||||
resize = vision_image_size,
|
||||
)
|
||||
|
||||
elif format_type:
|
||||
_send("status", status_message = f"Formatting dataset ({format_type})...")
|
||||
|
|
@ -2248,6 +2313,7 @@ def run_training_process(
|
|||
eval_dataset = eval_dataset,
|
||||
eval_steps = eval_steps,
|
||||
max_seq_length = config.get("max_seq_length", 2048),
|
||||
vision_image_size = config.get("vision_image_size"),
|
||||
optim = config.get("optim", "adamw_8bit"),
|
||||
lr_scheduler_type = config.get("lr_scheduler_type", "linear"),
|
||||
is_cpt = is_cpt,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,17 @@ _backend_dir = str(_Path(__file__).parent)
|
|||
if _backend_dir not in sys.path:
|
||||
sys.path.insert(0, _backend_dir)
|
||||
|
||||
# `uvicorn main:app` bypasses run.py; seed thread caps here too.
|
||||
from utils.cpu_threads import configure_cpu_threads
|
||||
|
||||
try:
|
||||
configure_cpu_threads()
|
||||
except ValueError as exc:
|
||||
_raw = os.environ.get("UNSLOTH_CPU_THREADS")
|
||||
raise SystemExit(
|
||||
f"Error: Invalid UNSLOTH_CPU_THREADS value {_raw!r}: {exc}"
|
||||
) from None
|
||||
|
||||
# Fix for Anaconda/conda-forge Python: seed platform._sys_version_cache before
|
||||
# any library imports that trigger attrs -> rich -> structlog -> platform crash.
|
||||
# See: https://github.com/python/cpython/issues/102396
|
||||
|
|
|
|||
|
|
@ -581,6 +581,14 @@ class ChatMessage(BaseModel):
|
|||
None,
|
||||
description = "OpenAI tool-result messages: name of the tool whose result this is.",
|
||||
)
|
||||
extra_content: Optional[dict] = Field(
|
||||
None,
|
||||
description = (
|
||||
"Provider-specific extra fields the translator may read. "
|
||||
"Gemini reads `extra_content.google.thought_signature` "
|
||||
"from assistant messages to replay text-part signatures."
|
||||
),
|
||||
)
|
||||
|
||||
@model_validator(mode = "after")
|
||||
def _validate_role_shape(self) -> "ChatMessage":
|
||||
|
|
@ -752,17 +760,42 @@ class ChatCompletionRequest(BaseModel):
|
|||
None,
|
||||
description = "[x-unsloth] Override base URL for the external provider.",
|
||||
)
|
||||
enable_prompt_caching: Optional[bool] = Field(
|
||||
enable_prompt_caching: Optional[Union[bool, str]] = Field(
|
||||
None,
|
||||
description = (
|
||||
"[x-unsloth] Opt in to provider-side prompt caching. On Anthropic, "
|
||||
"attaches cache_control={type:ephemeral} to the system block so the "
|
||||
"static prefix is reused across turns. On OpenAI cloud, caching is "
|
||||
"automatic for prompts >=1024 tokens and this flag is informational. "
|
||||
"Ignored for every other provider (mistral, gemini, kimi, openrouter, "
|
||||
"vllm, local, etc.). Treated as enabled when omitted."
|
||||
"boolean true attaches cache_control={type:ephemeral} to the system "
|
||||
"block so the static prefix is reused across turns. On OpenAI cloud, "
|
||||
"caching is automatic for prompts >=1024 tokens and the boolean is "
|
||||
"informational. On Gemini, pass a string cache resource name such "
|
||||
"as `cachedContents/abc123` to attach `cachedContent` on the native "
|
||||
"request (boolean true is a no-op on Gemini because creating the "
|
||||
"cache requires a separate POST /cachedContents call). Ignored for "
|
||||
"every other provider. Treated as enabled when omitted."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("enable_prompt_caching", mode = "before")
|
||||
@classmethod
|
||||
def _coerce_enable_prompt_caching(cls, value: Any) -> Any:
|
||||
"""Preserve the pre-PR coercion: the field used to be Optional[bool],
|
||||
so callers historically sent JSON strings `"true"` / `"false"` and
|
||||
Pydantic v1 coerced them. Widening to Optional[Union[bool, str]] for
|
||||
Gemini cache resource names lets `"false"` slip through as a truthy
|
||||
string. Coerce the canonical bool literals back so explicit opt-outs
|
||||
stay opt-out."""
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
# Match Pydantic v1's BooleanField coercion table (yes/y/on/t/1
|
||||
# and no/n/off/f/0) so opt-outs that used to parse still parse.
|
||||
# Anything else is preserved as a string for Gemini's
|
||||
# cachedContent resource path.
|
||||
if lowered in ("true", "t", "1", "yes", "y", "on"):
|
||||
return True
|
||||
if lowered in ("false", "f", "0", "no", "n", "off"):
|
||||
return False
|
||||
return value
|
||||
|
||||
prompt_cache_ttl: Optional[str] = Field(
|
||||
None,
|
||||
description = (
|
||||
|
|
|
|||
|
|
@ -5,10 +5,17 @@
|
|||
Pydantic schemas for Training API
|
||||
"""
|
||||
|
||||
import re
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from typing import Any, Optional, List, Dict, Literal
|
||||
|
||||
|
||||
# ASCII integer with an optional single sign. Used by _check_vision_image_size
|
||||
# to reject "++512", "--256", and Unicode-digit strings ("512", "٥١٢") that
|
||||
# would otherwise slip through str.isdigit() + int().
|
||||
_INT_RE = re.compile(r"[+-]?[0-9]+")
|
||||
|
||||
|
||||
_MAX_BATCH_SIZE = 4096
|
||||
_MAX_GRAD_ACCUM = 4096
|
||||
_MAX_STEPS = 1_000_000
|
||||
|
|
@ -18,6 +25,9 @@ _MAX_SEQ_LENGTH = 2_000_000
|
|||
_MAX_LR_VALUE = 1.0
|
||||
_MAX_LORA_R = 16_384
|
||||
_MAX_LORA_ALPHA = 32_768
|
||||
_MIN_VISION_IMAGE_SIZE = 256
|
||||
# 2048 was the most I could get most llms to work at without getting unstable
|
||||
_MAX_VISION_IMAGE_SIZE = 2048
|
||||
|
||||
|
||||
def _parse_lr(v: Any) -> float:
|
||||
|
|
@ -58,6 +68,10 @@ class TrainingStartRequest(BaseModel):
|
|||
hf_token: Optional[str] = Field(None, description = "HuggingFace token")
|
||||
load_in_4bit: bool = Field(True, description = "Load model in 4-bit quantization")
|
||||
max_seq_length: int = Field(2048, description = "Maximum sequence length")
|
||||
vision_image_size: Optional[int] = Field(
|
||||
None,
|
||||
description = "Optional maximum image side length for VLM training. Null uses model default.",
|
||||
)
|
||||
trust_remote_code: bool = Field(
|
||||
False,
|
||||
description = "Allow loading models with custom code (e.g. NVIDIA Nemotron). Only enable for repos you trust.",
|
||||
|
|
@ -159,6 +173,40 @@ class TrainingStartRequest(BaseModel):
|
|||
)
|
||||
return v
|
||||
|
||||
@field_validator("vision_image_size", mode = "before")
|
||||
@classmethod
|
||||
def _check_vision_image_size(cls, v: Any) -> Optional[int]:
|
||||
# mode="before" sees True/False as bool (not 1/0) for a precise error.
|
||||
if v is None:
|
||||
return v
|
||||
if isinstance(v, bool):
|
||||
raise ValueError("vision_image_size must be an integer or null")
|
||||
if isinstance(v, int):
|
||||
coerced = v
|
||||
elif isinstance(v, str) and _INT_RE.fullmatch(v.strip()):
|
||||
coerced = int(v.strip())
|
||||
elif isinstance(v, float) and v.is_integer():
|
||||
coerced = int(v)
|
||||
else:
|
||||
# numpy ints / Integral subclasses, without a hard numpy import.
|
||||
try:
|
||||
import numbers
|
||||
|
||||
if isinstance(v, numbers.Integral):
|
||||
coerced = int(v)
|
||||
elif isinstance(v, numbers.Real) and float(v).is_integer():
|
||||
coerced = int(v)
|
||||
else:
|
||||
raise TypeError
|
||||
except Exception:
|
||||
raise ValueError("vision_image_size must be an integer or null")
|
||||
if coerced < _MIN_VISION_IMAGE_SIZE or coerced > _MAX_VISION_IMAGE_SIZE:
|
||||
raise ValueError(
|
||||
f"vision_image_size must be in [{_MIN_VISION_IMAGE_SIZE}, "
|
||||
f"{_MAX_VISION_IMAGE_SIZE}] (got {coerced!r})"
|
||||
)
|
||||
return coerced
|
||||
|
||||
@field_validator("warmup_steps")
|
||||
@classmethod
|
||||
def _check_warmup_steps(cls, v: Optional[int]) -> Optional[int]:
|
||||
|
|
|
|||
|
|
@ -1721,6 +1721,7 @@ def _build_external_messages(
|
|||
messages: list,
|
||||
supports_vision: bool,
|
||||
provider_type: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Convert ChatMessage list to OpenAI-compatible dicts for external providers.
|
||||
|
|
@ -1748,14 +1749,171 @@ def _build_external_messages(
|
|||
document_provider = provider_type in _INPUT_DOCUMENT_PROVIDERS
|
||||
anthropic = provider_type == "anthropic"
|
||||
openai = provider_type == "openai"
|
||||
# `extra_content` is a Gemini-specific carrier for the assistant's
|
||||
# text-part `thoughtSignature` round-trip on the native
|
||||
# streamGenerateContent endpoint. Custom Gemini OpenAI-compatible
|
||||
# gateways (LiteLLM etc.) route through /chat/completions where
|
||||
# the field is unknown and can be rejected -- gate strictly on the
|
||||
# Google-hosted Gemini base.
|
||||
_native_gemini = False
|
||||
if provider_type == "gemini" and base_url:
|
||||
try:
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
_host = (_urlparse(base_url).hostname or "").lower()
|
||||
_native_gemini = _host == "generativelanguage.googleapis.com"
|
||||
except Exception:
|
||||
_native_gemini = False
|
||||
emit_extra_content = _native_gemini
|
||||
|
||||
_SERVER_BUILTIN_TOOL_NAMES = frozenset(
|
||||
{"web_search", "web_fetch", "code_execution", "image_generation"}
|
||||
)
|
||||
|
||||
def _is_marked_server_builtin_tool_call(tc: Any) -> bool:
|
||||
"""Return True iff `tc` is a synthetic provider-side tool card
|
||||
with one of the canonical builtin names and either:
|
||||
- the new `args._server_tool` marker stamped by the backend, or
|
||||
- a Gemini `args.google.native_part` payload (durable replay
|
||||
signal for code_execution / image_generation that predates
|
||||
the marker).
|
||||
Such cards must not be forwarded to non-native providers
|
||||
because they are not real user functions and the receiving API
|
||||
will reject the orphan tool history. Real user functions with
|
||||
these names normally have neither signal.
|
||||
"""
|
||||
if not isinstance(tc, dict):
|
||||
return False
|
||||
fn = tc.get("function")
|
||||
if not isinstance(fn, dict):
|
||||
return False
|
||||
name = (fn.get("name") or "").lower()
|
||||
if name not in _SERVER_BUILTIN_TOOL_NAMES:
|
||||
return False
|
||||
raw_args = fn.get("arguments") or ""
|
||||
try:
|
||||
args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
|
||||
except Exception:
|
||||
return False
|
||||
if not isinstance(args, dict):
|
||||
return False
|
||||
if args.get("_server_tool") is True:
|
||||
return True
|
||||
google = args.get("google")
|
||||
return isinstance(google, dict) and isinstance(google.get("native_part"), dict)
|
||||
|
||||
# When we drop a server-side builtin tool_call here, the matching
|
||||
# `role="tool"` follow-up must also be dropped from the outbound
|
||||
# history -- otherwise the provider receives an orphan
|
||||
# tool_call_id with no matching assistant call, which OpenAI
|
||||
# Responses and Anthropic both reject.
|
||||
dropped_server_builtin_tool_call_ids: set[str] = set()
|
||||
|
||||
def _filter_tool_calls(tool_calls: Any) -> Optional[list]:
|
||||
"""Sanitize assistant `tool_calls` for non-native-Gemini providers.
|
||||
|
||||
Two concerns:
|
||||
1. `tool_calls[i].extra_content` carries Gemini-only
|
||||
thoughtSignature metadata; strip it for providers that
|
||||
cannot parse the unknown key.
|
||||
2. Marked server-side builtin cards (`_server_tool: true` on
|
||||
a canonical builtin name, or a Gemini `native_part`
|
||||
payload) are provider-internal Studio tool cards from a
|
||||
prior native Gemini turn; forwarding them to OpenAI /
|
||||
Anthropic / custom OAI-compat gateways sends an orphan
|
||||
`tool_calls` entry (no matching tool declaration, often
|
||||
no matching `role="tool"` reply) that can be rejected.
|
||||
We record the dropped call_ids so the matching role=tool
|
||||
message is also skipped below.
|
||||
Native Gemini keeps both untouched so the native translator can
|
||||
replay them via `native_part`.
|
||||
"""
|
||||
if not tool_calls:
|
||||
return None
|
||||
if not isinstance(tool_calls, list):
|
||||
return tool_calls
|
||||
if emit_extra_content:
|
||||
return tool_calls
|
||||
cleaned: list = []
|
||||
for _tc in tool_calls:
|
||||
if _is_marked_server_builtin_tool_call(_tc):
|
||||
_tc_id = _tc.get("id") if isinstance(_tc, dict) else None
|
||||
if isinstance(_tc_id, str) and _tc_id:
|
||||
dropped_server_builtin_tool_call_ids.add(_tc_id)
|
||||
continue
|
||||
if not isinstance(_tc, dict):
|
||||
cleaned.append(_tc)
|
||||
continue
|
||||
if "extra_content" not in _tc:
|
||||
cleaned.append(_tc)
|
||||
continue
|
||||
_stripped = {k: v for k, v in _tc.items() if k != "extra_content"}
|
||||
cleaned.append(_stripped)
|
||||
return cleaned
|
||||
|
||||
result = []
|
||||
for msg in messages:
|
||||
# Drop role=tool messages whose matching server-builtin
|
||||
# tool_call was already filtered above. Forwarding an orphan
|
||||
# tool_result with no matching tool_call would be rejected by
|
||||
# OpenAI Responses and Anthropic.
|
||||
if (
|
||||
msg.role == "tool"
|
||||
and isinstance(msg.tool_call_id, str)
|
||||
and msg.tool_call_id in dropped_server_builtin_tool_call_ids
|
||||
):
|
||||
continue
|
||||
if isinstance(msg.content, str):
|
||||
# Skip assistant messages with empty content (some providers reject them)
|
||||
if msg.role == "assistant" and not msg.content.strip():
|
||||
# Drop bare assistant messages with no content AND no
|
||||
# tool_calls (some providers reject empty assistant turns).
|
||||
# Preserve assistant turns whose only payload is tool_calls
|
||||
# so multi-turn function-call loops round-trip.
|
||||
if (
|
||||
msg.role == "assistant"
|
||||
and not msg.content.strip()
|
||||
and not msg.tool_calls
|
||||
):
|
||||
continue
|
||||
result.append({"role": msg.role, "content": msg.content})
|
||||
elif isinstance(msg.content, list):
|
||||
out: dict[str, Any] = {"role": msg.role, "content": msg.content}
|
||||
if msg.role == "assistant" and msg.tool_calls:
|
||||
_tcs = _filter_tool_calls(msg.tool_calls)
|
||||
if _tcs:
|
||||
out["tool_calls"] = _tcs
|
||||
elif not msg.content.strip():
|
||||
# Every tool_call was a synthetic provider-side
|
||||
# card and was dropped; the assistant turn would
|
||||
# be an empty `{"role":"assistant","content":""}`
|
||||
# which some providers reject. Skip it entirely.
|
||||
continue
|
||||
if msg.role == "tool":
|
||||
if msg.tool_call_id:
|
||||
out["tool_call_id"] = msg.tool_call_id
|
||||
if msg.name:
|
||||
out["name"] = msg.name
|
||||
if emit_extra_content and msg.role == "assistant" and msg.extra_content:
|
||||
out["extra_content"] = msg.extra_content
|
||||
result.append(out)
|
||||
continue
|
||||
# Assistant messages with content=None but populated tool_calls
|
||||
# are valid (post-tool-call assistant turn). Forward them so the
|
||||
# provider helper can rebuild the functionCall part.
|
||||
if msg.content is None and msg.role == "assistant" and msg.tool_calls:
|
||||
_filtered_tcs = _filter_tool_calls(msg.tool_calls)
|
||||
if not _filtered_tcs:
|
||||
# Every tool_call on this turn was provider-side
|
||||
# synthetic and dropped; skipping the whole message
|
||||
# avoids forwarding an empty assistant turn.
|
||||
continue
|
||||
_assistant_only: dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": _filtered_tcs,
|
||||
}
|
||||
if emit_extra_content and msg.extra_content:
|
||||
_assistant_only["extra_content"] = msg.extra_content
|
||||
result.append(_assistant_only)
|
||||
continue
|
||||
if isinstance(msg.content, list):
|
||||
if supports_vision:
|
||||
parts = []
|
||||
for part in msg.content:
|
||||
|
|
@ -1813,9 +1971,27 @@ def _build_external_messages(
|
|||
# provider would 400 on the unknown part, so
|
||||
# gate by provider_type.
|
||||
parts.append({"type": "compaction", "content": part.content})
|
||||
if msg.role == "assistant" and not parts:
|
||||
entry: dict[str, Any] = {"role": msg.role, "content": parts}
|
||||
if msg.role == "assistant" and msg.tool_calls:
|
||||
_tcs = _filter_tool_calls(msg.tool_calls)
|
||||
if _tcs:
|
||||
entry["tool_calls"] = _tcs
|
||||
elif not parts:
|
||||
# All tool_calls were synthetic and dropped,
|
||||
# and no preserved content parts survived.
|
||||
# Skip rather than forward an empty assistant
|
||||
# turn that downstream providers reject.
|
||||
continue
|
||||
elif msg.role == "assistant" and not parts:
|
||||
continue
|
||||
result.append({"role": msg.role, "content": parts})
|
||||
if msg.role == "tool":
|
||||
if msg.tool_call_id:
|
||||
entry["tool_call_id"] = msg.tool_call_id
|
||||
if msg.name:
|
||||
entry["name"] = msg.name
|
||||
if emit_extra_content and msg.role == "assistant" and msg.extra_content:
|
||||
entry["extra_content"] = msg.extra_content
|
||||
result.append(entry)
|
||||
else:
|
||||
# Non-vision provider: strip images / documents, keep
|
||||
# text, optionally keep compaction (Anthropic only --
|
||||
|
|
@ -1851,9 +2027,32 @@ def _build_external_messages(
|
|||
if len(preserved) == 1 and preserved[0]["type"] == "text":
|
||||
# Single text part collapses back to a string for
|
||||
# providers that don't accept content arrays.
|
||||
result.append({"role": msg.role, "content": preserved[0]["text"]})
|
||||
entry = {"role": msg.role, "content": preserved[0]["text"]}
|
||||
else:
|
||||
result.append({"role": msg.role, "content": preserved})
|
||||
entry = {"role": msg.role, "content": preserved}
|
||||
if msg.role == "assistant" and msg.tool_calls:
|
||||
_tcs = _filter_tool_calls(msg.tool_calls)
|
||||
if _tcs:
|
||||
entry["tool_calls"] = _tcs
|
||||
else:
|
||||
# All tool_calls were synthetic and dropped;
|
||||
# skip if there's no surviving content either.
|
||||
_entry_content = entry.get("content")
|
||||
_has_text = (
|
||||
isinstance(_entry_content, str) and _entry_content.strip()
|
||||
) or (
|
||||
isinstance(_entry_content, list) and len(_entry_content) > 0
|
||||
)
|
||||
if not _has_text:
|
||||
continue
|
||||
if msg.role == "tool":
|
||||
if msg.tool_call_id:
|
||||
entry["tool_call_id"] = msg.tool_call_id
|
||||
if msg.name:
|
||||
entry["name"] = msg.name
|
||||
if emit_extra_content and msg.role == "assistant" and msg.extra_content:
|
||||
entry["extra_content"] = msg.extra_content
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -1928,6 +2127,7 @@ async def _proxy_to_external_provider(
|
|||
payload.messages,
|
||||
_supports_vision,
|
||||
provider_type = provider_type,
|
||||
base_url = base_url,
|
||||
)
|
||||
|
||||
client = ExternalProviderClient(
|
||||
|
|
@ -1936,6 +2136,14 @@ async def _proxy_to_external_provider(
|
|||
api_key = api_key,
|
||||
)
|
||||
|
||||
# `top_k` defaults to 20 in ChatCompletionRequest because the local
|
||||
# inference path expects an int, but the external-provider path
|
||||
# should treat "field omitted from JSON" as "use provider default"
|
||||
# so callers that send only model/messages do not silently get
|
||||
# different sampling than before this PR. Pydantic's
|
||||
# `model_fields_set` tracks explicit-vs-default per request.
|
||||
_top_k_explicit = payload.top_k if "top_k" in payload.model_fields_set else None
|
||||
|
||||
async def _stream():
|
||||
gen = client.stream_chat_completion(
|
||||
messages = chat_messages,
|
||||
|
|
@ -1944,7 +2152,7 @@ async def _proxy_to_external_provider(
|
|||
top_p = payload.top_p,
|
||||
max_tokens = payload.max_tokens,
|
||||
presence_penalty = payload.presence_penalty,
|
||||
top_k = payload.top_k,
|
||||
top_k = _top_k_explicit,
|
||||
enable_thinking = payload.enable_thinking,
|
||||
reasoning_effort = payload.reasoning_effort,
|
||||
enabled_tools = payload.enabled_tools,
|
||||
|
|
@ -1958,6 +2166,8 @@ async def _proxy_to_external_provider(
|
|||
stop = payload.stop,
|
||||
service_tier = payload.service_tier,
|
||||
parallel_tool_calls = payload.parallel_tool_calls,
|
||||
tools = payload.tools,
|
||||
tool_choice = payload.tool_choice,
|
||||
fast_mode = payload.fast_mode,
|
||||
stream = payload.stream,
|
||||
)
|
||||
|
|
@ -4579,7 +4789,17 @@ async def anthropic_messages(
|
|||
[m.model_dump() for m in payload.messages],
|
||||
payload.system,
|
||||
)
|
||||
openai_messages = _drop_empty_assistant_sentinels(openai_messages)
|
||||
# Strip synthetic provider-side builtin tool history (web_search,
|
||||
# web_fetch, code_execution, image_generation cards tagged with
|
||||
# _server_tool or extra_content.google.native_part) before handing
|
||||
# off to local llama-server. The local /v1/chat/completions and
|
||||
# GGUF passthrough builders apply the same strip; without it an
|
||||
# Anthropic /v1/messages caller replaying a prior provider-side
|
||||
# tool_use forwards fake builtin tool history to a backend that
|
||||
# has no matching function declarations.
|
||||
openai_messages = _strip_provider_synthetic_tool_history(
|
||||
_drop_empty_assistant_sentinels(openai_messages)
|
||||
)
|
||||
|
||||
# Enforce vision guard + re-encode embedded images to PNG so the
|
||||
# Anthropic endpoint matches the behavior of /v1/chat/completions.
|
||||
|
|
@ -5499,6 +5719,110 @@ def _drop_empty_assistant_sentinels(messages: list[dict]) -> list[dict]:
|
|||
return out
|
||||
|
||||
|
||||
_LOCAL_SERVER_BUILTIN_TOOL_NAMES = frozenset(
|
||||
{"web_search", "web_fetch", "code_execution", "image_generation"}
|
||||
)
|
||||
|
||||
|
||||
def _strip_provider_synthetic_tool_history(messages: list[dict]) -> list[dict]:
|
||||
"""Drop synthetic provider-side tool_calls + matching role=tool replies
|
||||
on the local-backend (llama-server / GGUF) dispatch path.
|
||||
|
||||
A Gemini chat that ran code_execution / image_generation persists the
|
||||
server-side tool card into thread history as an assistant tool_calls
|
||||
entry tagged with ``args._server_tool`` (or a Gemini
|
||||
``args.google.native_part`` payload) plus a follow-up role=tool reply.
|
||||
When the user switches the SAME thread to a local GGUF model, those
|
||||
synthetic tool_calls are not real user functions, llama-server has no
|
||||
matching declaration, and Gemini-only ``extra_content`` /
|
||||
``native_part`` payloads are meaningless. Forward only ordinary user
|
||||
function calls; strip the matched role=tool replies too so the
|
||||
backend does not see an orphan tool_call_id.
|
||||
"""
|
||||
dropped_ids: set[str] = set()
|
||||
sanitized_assistant: list[dict] = []
|
||||
for m in messages:
|
||||
if m.get("role") != "assistant":
|
||||
sanitized_assistant.append(m)
|
||||
continue
|
||||
tool_calls = m.get("tool_calls")
|
||||
if not isinstance(tool_calls, list) or not tool_calls:
|
||||
# Plain text Gemini reply: still strip message-level
|
||||
# `extra_content` (carries `google.thought_signature` replay
|
||||
# metadata) so a text-only Gemini turn switched to a local
|
||||
# GGUF backend does not leak Gemini-only fields to
|
||||
# llama-server. ChatMessage previously did not have
|
||||
# `extra_content`, so the field was implicitly dropped --
|
||||
# round-22 added it to ChatMessage, which is what made this
|
||||
# leak possible.
|
||||
if "extra_content" in m:
|
||||
m = {k: v for k, v in m.items() if k != "extra_content"}
|
||||
sanitized_assistant.append(m)
|
||||
continue
|
||||
cleaned: list[dict] = []
|
||||
for tc in tool_calls:
|
||||
if not isinstance(tc, dict):
|
||||
cleaned.append(tc)
|
||||
continue
|
||||
fn = tc.get("function")
|
||||
name = ""
|
||||
if isinstance(fn, dict):
|
||||
name = (fn.get("name") or "").lower()
|
||||
if name in _LOCAL_SERVER_BUILTIN_TOOL_NAMES:
|
||||
raw_args = fn.get("arguments") if isinstance(fn, dict) else None
|
||||
args_obj: Any = None
|
||||
if isinstance(raw_args, str):
|
||||
try:
|
||||
args_obj = json.loads(raw_args) if raw_args else None
|
||||
except Exception:
|
||||
args_obj = None
|
||||
elif isinstance(raw_args, dict):
|
||||
args_obj = raw_args
|
||||
is_synthetic = False
|
||||
if isinstance(args_obj, dict):
|
||||
if args_obj.get("_server_tool") is True:
|
||||
is_synthetic = True
|
||||
google = args_obj.get("google")
|
||||
if isinstance(google, dict) and isinstance(
|
||||
google.get("native_part"), dict
|
||||
):
|
||||
is_synthetic = True
|
||||
if is_synthetic:
|
||||
tc_id = tc.get("id")
|
||||
if isinstance(tc_id, str) and tc_id:
|
||||
dropped_ids.add(tc_id)
|
||||
continue
|
||||
# Strip Gemini-only `extra_content` on real user tool_calls
|
||||
# too — llama-server has no use for it and may pass it
|
||||
# through to the model unchanged.
|
||||
if "extra_content" in tc:
|
||||
tc = {k: v for k, v in tc.items() if k != "extra_content"}
|
||||
cleaned.append(tc)
|
||||
# Drop top-level message-level `extra_content` (Gemini
|
||||
# thoughtSignature replay metadata) on local dispatch.
|
||||
m_clean = {k: v for k, v in m.items() if k != "extra_content"}
|
||||
if cleaned:
|
||||
m_clean["tool_calls"] = cleaned
|
||||
else:
|
||||
m_clean.pop("tool_calls", None)
|
||||
if not m_clean.get("content") and not m_clean.get("tool_calls"):
|
||||
continue # assistant turn now empty, drop
|
||||
sanitized_assistant.append(m_clean)
|
||||
|
||||
if not dropped_ids:
|
||||
return sanitized_assistant
|
||||
out: list[dict] = []
|
||||
for m in sanitized_assistant:
|
||||
if (
|
||||
m.get("role") == "tool"
|
||||
and isinstance(m.get("tool_call_id"), str)
|
||||
and m["tool_call_id"] in dropped_ids
|
||||
):
|
||||
continue
|
||||
out.append(m)
|
||||
return out
|
||||
|
||||
|
||||
def _openai_messages_for_passthrough(payload) -> list[dict]:
|
||||
"""Build OpenAI-format message dicts for the /v1/chat/completions
|
||||
passthrough path.
|
||||
|
|
@ -5515,8 +5839,10 @@ def _openai_messages_for_passthrough(payload) -> list[dict]:
|
|||
``image_url`` content part so vision + function-calling requests work
|
||||
transparently.
|
||||
"""
|
||||
messages = _drop_empty_assistant_sentinels(
|
||||
[m.model_dump(exclude_none = True) for m in payload.messages]
|
||||
messages = _strip_provider_synthetic_tool_history(
|
||||
_drop_empty_assistant_sentinels(
|
||||
[m.model_dump(exclude_none = True) for m in payload.messages]
|
||||
)
|
||||
)
|
||||
|
||||
if not payload.image_base64:
|
||||
|
|
@ -5565,8 +5891,10 @@ def _openai_messages_for_gguf_chat(payload, is_vision: bool) -> tuple[list[dict]
|
|||
all per-turn ``image_url`` parts so multi-image chat history keeps each
|
||||
image attached to its original turn.
|
||||
"""
|
||||
messages = _drop_empty_assistant_sentinels(
|
||||
[m.model_dump(exclude_none = True) for m in payload.messages]
|
||||
messages = _strip_provider_synthetic_tool_history(
|
||||
_drop_empty_assistant_sentinels(
|
||||
[m.model_dump(exclude_none = True) for m in payload.messages]
|
||||
)
|
||||
)
|
||||
has_message_image = any(
|
||||
isinstance(msg.get("content"), list)
|
||||
|
|
|
|||
|
|
@ -318,22 +318,45 @@ async def list_provider_models(
|
|||
|
||||
try:
|
||||
models = await client.list_models()
|
||||
allow_prefixes = info.get("model_id_allow_prefixes")
|
||||
if allow_prefixes is not None:
|
||||
prefix_tuple = tuple(str(p) for p in allow_prefixes if str(p))
|
||||
if prefix_tuple:
|
||||
models = [m for m in models if m.get("id", "").startswith(prefix_tuple)]
|
||||
allowlist = info.get("model_id_allowlist")
|
||||
if allowlist is not None:
|
||||
models = [m for m in models if allowlist.match(m.get("id", ""))]
|
||||
deny_exact = info.get("model_id_deny_exact")
|
||||
if deny_exact is not None:
|
||||
deny_ids = {str(m) for m in deny_exact if str(m)}
|
||||
if deny_ids:
|
||||
models = [m for m in models if m.get("id", "") not in deny_ids]
|
||||
denylist = info.get("model_id_denylist")
|
||||
if denylist is not None:
|
||||
models = [m for m in models if not denylist.search(m.get("id", ""))]
|
||||
# Registry-level model-id filters are scoped to the canonical
|
||||
# native Gemini base. A custom Gemini OAI-compatible proxy
|
||||
# (LiteLLM, deployment gateway) returns IDs like
|
||||
# `google/gemini-2.5-flash`, `gemini/gemini-2.5-flash`, or
|
||||
# team-prefixed deployment aliases; the native allowlist regex
|
||||
# would strip those out and leave the picker empty even though
|
||||
# the chat path now routes them via the OAI-compatible
|
||||
# dispatcher (the same gate ExternalProviderClient applies for
|
||||
# request building). Match the host check here so the model
|
||||
# list and chat dispatch agree on what counts as "native".
|
||||
apply_registry_model_filters = True
|
||||
if payload.provider_type == "gemini":
|
||||
try:
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
_host = (_urlparse(base_url).hostname or "").lower()
|
||||
except Exception:
|
||||
_host = ""
|
||||
apply_registry_model_filters = _host == "generativelanguage.googleapis.com"
|
||||
|
||||
if apply_registry_model_filters:
|
||||
allow_prefixes = info.get("model_id_allow_prefixes")
|
||||
if allow_prefixes is not None:
|
||||
prefix_tuple = tuple(str(p) for p in allow_prefixes if str(p))
|
||||
if prefix_tuple:
|
||||
models = [
|
||||
m for m in models if m.get("id", "").startswith(prefix_tuple)
|
||||
]
|
||||
allowlist = info.get("model_id_allowlist")
|
||||
if allowlist is not None:
|
||||
models = [m for m in models if allowlist.match(m.get("id", ""))]
|
||||
deny_exact = info.get("model_id_deny_exact")
|
||||
if deny_exact is not None:
|
||||
deny_ids = {str(m) for m in deny_exact if str(m)}
|
||||
if deny_ids:
|
||||
models = [m for m in models if m.get("id", "") not in deny_ids]
|
||||
denylist = info.get("model_id_denylist")
|
||||
if denylist is not None:
|
||||
models = [m for m in models if not denylist.search(m.get("id", ""))]
|
||||
# Apply an optional cap after filtering so registry entries with a
|
||||
# large remote catalog (e.g. HF Inference Providers) can stay
|
||||
# picker-sized. No popularity sort happens server-side, so this is
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@ async def start_training(
|
|||
"hf_token": request.hf_token or "",
|
||||
"load_in_4bit": request.load_in_4bit,
|
||||
"max_seq_length": request.max_seq_length,
|
||||
"vision_image_size": request.vision_image_size,
|
||||
"hf_dataset": request.hf_dataset or "",
|
||||
"local_datasets": request.local_datasets,
|
||||
"local_eval_datasets": request.local_eval_datasets,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,16 @@ backend_dir = Path(__file__).parent
|
|||
if str(backend_dir) not in sys.path:
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
from utils.cpu_threads import configure_cpu_threads
|
||||
|
||||
try:
|
||||
configure_cpu_threads()
|
||||
except ValueError as exc:
|
||||
configured = os.environ.get("UNSLOTH_CPU_THREADS")
|
||||
raise SystemExit(
|
||||
f"Error: Invalid UNSLOTH_CPU_THREADS value {configured!r}: {exc}"
|
||||
) from None
|
||||
|
||||
# Fix for Anaconda/conda-forge Python: seed platform._sys_version_cache before
|
||||
# any library imports that trigger attrs -> rich -> structlog -> platform crash.
|
||||
# See: https://github.com/python/cpython/issues/102396
|
||||
|
|
@ -846,11 +856,33 @@ if __name__ == "__main__":
|
|||
action = "store_true",
|
||||
help = "API server only, no frontend (for Tauri)",
|
||||
)
|
||||
# Mirror unsloth_cli/commands/studio.py's _PARALLEL_*. Default 1
|
||||
# applies only to direct backend launches; `unsloth studio run`
|
||||
# always passes its own value (4) explicitly.
|
||||
_PARALLEL_MIN = 1
|
||||
_PARALLEL_MAX = 64
|
||||
_PARALLEL_DEFAULT_PLAIN = 1
|
||||
parser.add_argument(
|
||||
"--parallel",
|
||||
"--n-parallel",
|
||||
type = int,
|
||||
default = _PARALLEL_DEFAULT_PLAIN,
|
||||
help = (
|
||||
f"llama-server parallel decode slots ({_PARALLEL_MIN}..{_PARALLEL_MAX}). "
|
||||
f"Default {_PARALLEL_DEFAULT_PLAIN}; `unsloth studio run` uses 4."
|
||||
),
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
if not _PARALLEL_MIN <= args.parallel <= _PARALLEL_MAX:
|
||||
parser.error(f"--parallel must be between {_PARALLEL_MIN} and {_PARALLEL_MAX}")
|
||||
|
||||
kwargs = dict(
|
||||
host = args.host, port = args.port, silent = args.silent, api_only = args.api_only
|
||||
host = args.host,
|
||||
port = args.port,
|
||||
silent = args.silent,
|
||||
api_only = args.api_only,
|
||||
llama_parallel_slots = args.parallel,
|
||||
)
|
||||
if args.frontend is not None:
|
||||
kwargs["frontend_path"] = Path(args.frontend)
|
||||
|
|
|
|||
|
|
@ -275,7 +275,13 @@ def test_bash_code_execution_emits_tool_start_and_end(monkeypatch):
|
|||
assert start["type"] == "tool_start"
|
||||
assert start["tool_name"] == "code_execution"
|
||||
assert start["tool_call_id"] == "srvtoolu_1"
|
||||
assert start["arguments"] == {"kind": "bash", "command": "ls -la"}
|
||||
# `_server_tool: True` marks this as a provider-side synthetic
|
||||
# tool card for the frontend's history serializer.
|
||||
assert start["arguments"] == {
|
||||
"kind": "bash",
|
||||
"command": "ls -la",
|
||||
"_server_tool": True,
|
||||
}
|
||||
|
||||
assert end["type"] == "tool_end"
|
||||
assert end["tool_call_id"] == "srvtoolu_1"
|
||||
|
|
|
|||
|
|
@ -271,7 +271,12 @@ def test_web_fetch_success_emits_tool_start_and_end(monkeypatch):
|
|||
assert start["type"] == "tool_start"
|
||||
assert start["tool_name"] == "web_fetch"
|
||||
assert start["tool_call_id"] == "srvtoolu_wf1"
|
||||
assert start["arguments"] == {"url": "https://example.com/article"}
|
||||
# `_server_tool: True` marks this as a provider-side synthetic
|
||||
# tool card for the frontend's history serializer.
|
||||
assert start["arguments"] == {
|
||||
"url": "https://example.com/article",
|
||||
"_server_tool": True,
|
||||
}
|
||||
assert end["type"] == "tool_end"
|
||||
assert end["tool_call_id"] == "srvtoolu_wf1"
|
||||
# The source pill uses Title / URL / snippet as parseSourcesFromResult expects.
|
||||
|
|
|
|||
152
studio/backend/tests/test_cpu_threads.py
Normal file
152
studio/backend/tests/test_cpu_threads.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Tests for Studio's early CPU thread-pool configuration."""
|
||||
|
||||
import ast
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from utils.cpu_threads import _THREAD_POOL_ENV_VARS, configure_cpu_threads
|
||||
|
||||
|
||||
_BACKEND_DIR = Path(__file__).resolve().parent.parent
|
||||
_RUN_PY = _BACKEND_DIR / "run.py"
|
||||
_MAIN_PY = _BACKEND_DIR / "main.py"
|
||||
|
||||
|
||||
# Explicit positive integers seed all four native pool env vars.
|
||||
def test_cpu_thread_cap_seeds_native_pool_limits():
|
||||
env = {"UNSLOTH_CPU_THREADS": " 6 "}
|
||||
|
||||
configure_cpu_threads(env)
|
||||
|
||||
assert {variable: env[variable] for variable in _THREAD_POOL_ENV_VARS} == {
|
||||
variable: "6" for variable in _THREAD_POOL_ENV_VARS
|
||||
}
|
||||
|
||||
|
||||
# Explicit per-library values win over the Studio knob via setdefault.
|
||||
def test_cpu_thread_cap_preserves_runtime_specific_override():
|
||||
env = {"UNSLOTH_CPU_THREADS": "4", "OMP_NUM_THREADS": "2"}
|
||||
|
||||
configure_cpu_threads(env)
|
||||
|
||||
assert env["OMP_NUM_THREADS"] == "2"
|
||||
assert env["MKL_NUM_THREADS"] == "4"
|
||||
|
||||
|
||||
# Whitespace / plus-prefix / leading zero all normalise via int().
|
||||
@pytest.mark.parametrize("raw", ["+4", "007", " 4 "])
|
||||
def test_cpu_thread_cap_normalises_valid_inputs(raw):
|
||||
env = {"UNSLOTH_CPU_THREADS": raw}
|
||||
|
||||
configure_cpu_threads(env)
|
||||
|
||||
assert env["OMP_NUM_THREADS"] == str(int(raw.strip()))
|
||||
|
||||
|
||||
# Unset / empty / whitespace -> no env mutation (pure opt-in).
|
||||
@pytest.mark.parametrize("raw", [None, "", " ", "\t"])
|
||||
def test_cpu_thread_cap_is_opt_in(raw):
|
||||
env = {} if raw is None else {"UNSLOTH_CPU_THREADS": raw}
|
||||
snapshot = dict(env)
|
||||
|
||||
configure_cpu_threads(env)
|
||||
|
||||
assert env == snapshot
|
||||
assert all(variable not in env for variable in _THREAD_POOL_ENV_VARS)
|
||||
|
||||
|
||||
# Anything that is not a positive integer raises a clear ValueError.
|
||||
@pytest.mark.parametrize("raw", ["zero", "0", "-3", "1.5", "abc", "8a", "0x4", "1e3", "4 0"])
|
||||
def test_cpu_thread_cap_requires_positive_integer(raw):
|
||||
with pytest.raises(ValueError, match="must be a positive integer"):
|
||||
configure_cpu_threads({"UNSLOTH_CPU_THREADS": raw})
|
||||
|
||||
|
||||
# env=None path uses real os.environ (production call from run.py / main.py).
|
||||
def test_cpu_thread_cap_uses_os_environ_when_env_is_none(monkeypatch):
|
||||
for variable in (*_THREAD_POOL_ENV_VARS, "UNSLOTH_CPU_THREADS"):
|
||||
monkeypatch.delenv(variable, raising=False)
|
||||
monkeypatch.setenv("UNSLOTH_CPU_THREADS", "3")
|
||||
|
||||
configure_cpu_threads()
|
||||
|
||||
for variable in _THREAD_POOL_ENV_VARS:
|
||||
assert os.environ[variable] == "3"
|
||||
|
||||
|
||||
# Calling twice must not flip any seeded value.
|
||||
def test_cpu_thread_cap_idempotent(monkeypatch):
|
||||
for variable in (*_THREAD_POOL_ENV_VARS, "UNSLOTH_CPU_THREADS"):
|
||||
monkeypatch.delenv(variable, raising=False)
|
||||
monkeypatch.setenv("UNSLOTH_CPU_THREADS", "5")
|
||||
|
||||
configure_cpu_threads()
|
||||
snapshot = {v: os.environ.get(v) for v in _THREAD_POOL_ENV_VARS}
|
||||
configure_cpu_threads()
|
||||
|
||||
assert {v: os.environ.get(v) for v in _THREAD_POOL_ENV_VARS} == snapshot
|
||||
|
||||
|
||||
def _ast_line_of_configure_call(source: str) -> int:
|
||||
tree = ast.parse(source)
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "configure_cpu_threads"
|
||||
):
|
||||
return node.lineno
|
||||
raise AssertionError("configure_cpu_threads() call not found")
|
||||
|
||||
|
||||
def _ast_line_of_platform_compat_import(source: str) -> int:
|
||||
tree = ast.parse(source)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name == "_platform_compat":
|
||||
return node.lineno
|
||||
raise AssertionError("_platform_compat import not found")
|
||||
|
||||
|
||||
# AST-based ordering: configure_cpu_threads() must precede _platform_compat
|
||||
# in both run.py and main.py. Robust to formatting / line shifts.
|
||||
@pytest.mark.parametrize("entry_point", [_RUN_PY, _MAIN_PY])
|
||||
def test_cpu_thread_configuration_runs_before_backend_imports(entry_point):
|
||||
source = entry_point.read_text()
|
||||
call_line = _ast_line_of_configure_call(source)
|
||||
compat_line = _ast_line_of_platform_compat_import(source)
|
||||
assert call_line < compat_line, (
|
||||
f"{entry_point.name}: configure_cpu_threads() (line {call_line}) "
|
||||
f"must precede import _platform_compat (line {compat_line})"
|
||||
)
|
||||
|
||||
|
||||
# Invalid env -> exit 1, one-line stderr, no traceback, gated before any
|
||||
# heavy import. Parametrised over both entry points.
|
||||
@pytest.mark.parametrize("entry_point", [_RUN_PY, _MAIN_PY])
|
||||
def test_invalid_cpu_thread_cap_exits_without_traceback(entry_point):
|
||||
env = os.environ.copy()
|
||||
env["UNSLOTH_CPU_THREADS"] = "not-a-count"
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(entry_point)],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert (
|
||||
"Error: Invalid UNSLOTH_CPU_THREADS value 'not-a-count': "
|
||||
"UNSLOTH_CPU_THREADS must be a positive integer"
|
||||
) in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
assert "_platform_compat" not in result.stderr
|
||||
|
|
@ -484,11 +484,14 @@ from typer.testing import CliRunner
|
|||
studio_home = Path(sys.argv[1])
|
||||
real_import = builtins.__import__
|
||||
|
||||
def guarded_import(name, *args, **kwargs):
|
||||
def guarded_import(name, globals = None, locals = None, fromlist = (), level = 0):
|
||||
# Only gate absolute imports; relative `from .utils import x` inside
|
||||
# third-party packages (e.g. typer._click.decorators) hits level > 0
|
||||
# with name="utils" and must pass through.
|
||||
blocked = ("auth", "fastapi", "structlog", "utils")
|
||||
if name in blocked or name.startswith(("auth.", "utils.")):
|
||||
if level == 0 and (name in blocked or name.startswith(("auth.", "utils."))):
|
||||
raise ModuleNotFoundError(name)
|
||||
return real_import(name, *args, **kwargs)
|
||||
return real_import(name, globals, locals, fromlist, level)
|
||||
|
||||
builtins.__import__ = guarded_import
|
||||
from unsloth_cli.commands import studio as studio_cli
|
||||
|
|
|
|||
5501
studio/backend/tests/test_gemini_provider.py
Normal file
5501
studio/backend/tests/test_gemini_provider.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -3,21 +3,35 @@
|
|||
|
||||
"""Unit tests for the llama-server pass-through args validator.
|
||||
|
||||
The validator is the security boundary between user-supplied CLI / HTTP
|
||||
input and the llama-server subprocess command. These tests pin the
|
||||
denylist behavior so the boundary doesn't quietly regress when new
|
||||
managed flags are added.
|
||||
The validator is the boundary between user CLI/HTTP input and the
|
||||
llama-server subprocess. These tests pin denylist behaviour so it
|
||||
doesn't quietly regress when new managed flags are added.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from core.inference.llama_server_args import (
|
||||
is_managed_flag,
|
||||
strip_shadowing_flags,
|
||||
validate_extra_args,
|
||||
# Load llama_server_args.py directly so this test doesn't drag in the
|
||||
# full backend chain (fastapi / structlog / loggers / utils.hardware)
|
||||
# via core/inference/__init__.py. The validator is intentionally
|
||||
# dependency-free and unit-tests should reflect that.
|
||||
_LSA_PATH = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "core"
|
||||
/ "inference"
|
||||
/ "llama_server_args.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("_lsa_test_only", _LSA_PATH)
|
||||
_lsa = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_lsa)
|
||||
is_managed_flag = _lsa.is_managed_flag
|
||||
strip_shadowing_flags = _lsa.strip_shadowing_flags
|
||||
validate_extra_args = _lsa.validate_extra_args
|
||||
|
||||
|
||||
# ── Pass-through (allowed) ───────────────────────────────────────────
|
||||
|
|
@ -60,13 +74,12 @@ from core.inference.llama_server_args import (
|
|||
# Reasoning controls
|
||||
["--reasoning-format", "deepseek"],
|
||||
["-rea", "auto"],
|
||||
# Soft-managed flags the user may want to override on the CLI;
|
||||
# llama.cpp's last-wins parsing means these win over Studio's
|
||||
# auto-set version.
|
||||
# Soft-managed: user-supplied flags last-wins-override Studio's
|
||||
# auto-set version. --parallel / -np / --n-parallel are NOT
|
||||
# here -- they're hard-denied (KV-cache + slot count would
|
||||
# desync). Use `unsloth studio run --parallel N` instead.
|
||||
["-c", "131072"],
|
||||
["--ctx-size", "8192"],
|
||||
["--parallel", "1"],
|
||||
["-np", "8"],
|
||||
["--flash-attn", "off"],
|
||||
["-fa", "on"],
|
||||
["--no-context-shift"],
|
||||
|
|
@ -99,8 +112,7 @@ def test_value_with_equals_form_passes_through():
|
|||
|
||||
|
||||
def test_non_flag_token_passes_through():
|
||||
# A bare positional value (not preceded by a flag) is preserved
|
||||
# verbatim. llama-server may reject it, but that's not our job.
|
||||
# Bare positionals are passed through; llama-server can reject them.
|
||||
assert validate_extra_args(["foo"]) == ["foo"]
|
||||
|
||||
|
||||
|
|
@ -110,18 +122,33 @@ def test_non_flag_token_passes_through():
|
|||
@pytest.mark.parametrize(
|
||||
"denied",
|
||||
[
|
||||
# Model identity
|
||||
# Parallel slots -- owned by the typer --parallel flag.
|
||||
"-np",
|
||||
"--parallel",
|
||||
"--n-parallel",
|
||||
# Model identity (every alias; bumping llama.cpp must keep
|
||||
# every form rejected, not just the long).
|
||||
"-m",
|
||||
"--model",
|
||||
"-mu",
|
||||
"--model-url",
|
||||
"-dr",
|
||||
"--docker-repo",
|
||||
"-hf",
|
||||
"-hfr",
|
||||
"--hf-repo",
|
||||
"-hff",
|
||||
"--hf-file",
|
||||
"-hfv",
|
||||
"-hfrv",
|
||||
"--hf-repo-v",
|
||||
"-hffv",
|
||||
"--hf-file-v",
|
||||
"-hft",
|
||||
"--hf-token",
|
||||
"-mm",
|
||||
"--mmproj",
|
||||
"-mmu",
|
||||
"--mmproj-url",
|
||||
# Networking (Studio binds + proxies)
|
||||
"--host",
|
||||
|
|
@ -134,11 +161,28 @@ def test_non_flag_token_passes_through():
|
|||
"--api-key-file",
|
||||
"--ssl-key-file",
|
||||
"--ssl-cert-file",
|
||||
# Single-model server
|
||||
# Single-model server (legacy --webui + current --ui group)
|
||||
"--webui",
|
||||
"--no-webui",
|
||||
"--ui",
|
||||
"--no-ui",
|
||||
"--ui-config",
|
||||
"--ui-config-file",
|
||||
"--ui-mcp-proxy",
|
||||
"--no-ui-mcp-proxy",
|
||||
"--models-dir",
|
||||
"--models-preset",
|
||||
"--models-max",
|
||||
"--models-autoload",
|
||||
"--no-models-autoload",
|
||||
# Server-mode flips: --embedding / --rerank would restrict
|
||||
# llama-server to those endpoints and break Studio's chat hop.
|
||||
"--embedding",
|
||||
"--embeddings",
|
||||
"--rerank",
|
||||
"--reranking",
|
||||
# llama-server's own --tools clashes with Studio's tool policy.
|
||||
"--tools",
|
||||
],
|
||||
)
|
||||
def test_denylist_rejects_all_aliases(denied):
|
||||
|
|
@ -146,14 +190,65 @@ def test_denylist_rejects_all_aliases(denied):
|
|||
validate_extra_args([denied, "value"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args,offending",
|
||||
[
|
||||
# Pass-through --parallel would last-wins-override the real
|
||||
# slot count while Studio's KV-cache fit + llama_parallel_slots
|
||||
# stay at the typer value -- plan vs. process disagree.
|
||||
(["--parallel", "8"], "--parallel"),
|
||||
(["--parallel=8"], "--parallel"),
|
||||
(["--n-parallel", "16"], "--n-parallel"),
|
||||
(["--n-parallel=16"], "--n-parallel"),
|
||||
(["-np", "32"], "-np"),
|
||||
# Attached short form: Click clusters it CLI-side; HTTP /load
|
||||
# with `["-np8"]` must still resolve to managed.
|
||||
(["-np8"], "-np"),
|
||||
(["-np64"], "-np"),
|
||||
# Out-of-range values that would bypass the typer 1..64 guard.
|
||||
(["--parallel", "999"], "--parallel"),
|
||||
(["-np", "0"], "-np"),
|
||||
(["-np999"], "-np"),
|
||||
# Signed attached forms; `-np-1` must not slip past.
|
||||
(["-np-1"], "-np"),
|
||||
(["-np+1"], "-np"),
|
||||
],
|
||||
)
|
||||
def test_parallel_flags_are_managed(args, offending):
|
||||
with pytest.raises(ValueError, match = re.escape(offending)):
|
||||
validate_extra_args(args)
|
||||
|
||||
|
||||
def test_denylist_rejects_equals_form():
|
||||
with pytest.raises(ValueError, match = "--port"):
|
||||
validate_extra_args(["--port=9000"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"padded",
|
||||
[" --parallel", "--parallel ", "\t--parallel", " -np", "-np \n", "-np\t"],
|
||||
)
|
||||
def test_denylist_rejects_whitespace_padded_forms(padded):
|
||||
# `_flag_name` trims whitespace before lookup; otherwise a trailing
|
||||
# space could slip a managed flag past the boundary.
|
||||
with pytest.raises(ValueError, match = "parallel|np"):
|
||||
validate_extra_args([padded, "8"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"attached",
|
||||
["-np8x", "-np-1foo", "-np+1bar", "-np9zzz"],
|
||||
)
|
||||
def test_denylist_rejects_np_with_digit_prefix_and_junk(attached):
|
||||
# Backend `_flag_name` must classify the same forms the CLI
|
||||
# rewriter expands, else HTTP /load could smuggle `-np8x` through.
|
||||
with pytest.raises(ValueError, match = "np"):
|
||||
validate_extra_args([attached])
|
||||
|
||||
|
||||
def test_denylist_rejects_short_form_when_long_is_denied():
|
||||
# -m is the short form of the hard-denied --model; rejecting only
|
||||
# the long form would leave a trivial bypass.
|
||||
# `-m` is the short form of --model; rejecting only the long
|
||||
# form would leave a trivial bypass.
|
||||
with pytest.raises(ValueError, match = "-m"):
|
||||
validate_extra_args(["-m", "/some/other/path.gguf"])
|
||||
|
||||
|
|
@ -165,9 +260,7 @@ def test_denylist_message_names_offending_flag():
|
|||
|
||||
|
||||
def test_first_denied_flag_short_circuits():
|
||||
# Validation stops at the first denied flag; later denied flags
|
||||
# in the same call don't matter for behaviour, but the message
|
||||
# should name the first one we hit.
|
||||
# Validation stops at the first denied flag; the message names it.
|
||||
with pytest.raises(ValueError, match = "--port"):
|
||||
validate_extra_args(["--port", "1", "--host", "x"])
|
||||
|
||||
|
|
@ -177,8 +270,7 @@ def test_first_denied_flag_short_circuits():
|
|||
|
||||
@pytest.mark.parametrize("value", ["-1", "-0.5", "-42", "-.5"])
|
||||
def test_negative_number_value_is_not_flag(value):
|
||||
# ``--seed -1`` is a value, not a flag. Validator must not try
|
||||
# to look up "-1" in the denylist.
|
||||
# `--seed -1`: the -1 is a value, not a flag.
|
||||
assert validate_extra_args(["--seed", value]) == ["--seed", value]
|
||||
|
||||
|
||||
|
|
@ -190,6 +282,15 @@ def test_is_managed_flag_true_for_denied():
|
|||
assert is_managed_flag("--api-key") is True
|
||||
assert is_managed_flag("-m") is True
|
||||
assert is_managed_flag("--model") is True
|
||||
# Parallel slots owned by the typer --parallel flag.
|
||||
assert is_managed_flag("--parallel") is True
|
||||
assert is_managed_flag("--n-parallel") is True
|
||||
assert is_managed_flag("-np") is True
|
||||
# Normalised forms must classify like the canonical token so
|
||||
# is_managed_flag filtering stays in sync with validate_extra_args.
|
||||
assert is_managed_flag("-np8") is True
|
||||
assert is_managed_flag("--parallel=8") is True
|
||||
assert is_managed_flag("--port=9000") is True
|
||||
|
||||
|
||||
def test_is_managed_flag_false_for_pass_through():
|
||||
|
|
@ -199,7 +300,6 @@ def test_is_managed_flag_false_for_pass_through():
|
|||
# Soft-managed flags pass through (last-wins override)
|
||||
assert is_managed_flag("-c") is False
|
||||
assert is_managed_flag("--ctx-size") is False
|
||||
assert is_managed_flag("--parallel") is False
|
||||
assert is_managed_flag("--flash-attn") is False
|
||||
assert is_managed_flag("-ngl") is False
|
||||
assert is_managed_flag("--threads") is False
|
||||
|
|
@ -231,8 +331,8 @@ def test_strip_shadowing_flags_keeps_context_when_not_requested():
|
|||
|
||||
|
||||
def test_strip_shadowing_flags_keeps_chat_template_when_template_disabled():
|
||||
# Caller did not supply chat_template_override; the inherited
|
||||
# --chat-template-file must survive the strip.
|
||||
# No chat_template_override supplied; inherited
|
||||
# --chat-template-file must survive.
|
||||
out = strip_shadowing_flags(
|
||||
["--chat-template-file", "/tmp/custom.jinja", "--top-k", "20"],
|
||||
strip_context = True,
|
||||
|
|
@ -282,7 +382,7 @@ def test_strip_shadowing_flags_keeps_spec_when_spec_disabled():
|
|||
|
||||
|
||||
def test_strip_shadowing_flags_drops_mtp_flags_when_requested():
|
||||
# MTP / draft-mtp flags must be stripped when speculative_type is re-applied.
|
||||
# MTP / draft-mtp flags must drop when speculative_type re-applies.
|
||||
out = strip_shadowing_flags(
|
||||
[
|
||||
"--spec-type",
|
||||
|
|
@ -311,8 +411,7 @@ def test_is_managed_flag_false_for_mtp_pass_through():
|
|||
|
||||
|
||||
def test_strip_shadowing_flags_boolean_does_not_consume_next_token():
|
||||
# --spec-default is a boolean shadowing flag; the value-skipping
|
||||
# heuristic must skip just the flag, not the following positional.
|
||||
# `--spec-default` is boolean; drop just the flag, keep the next token.
|
||||
out = strip_shadowing_flags(["--spec-default", "ngram-mod"], strip_spec = True)
|
||||
assert out == ["ngram-mod"]
|
||||
|
||||
|
|
@ -343,8 +442,8 @@ def test_strip_shadowing_flags_handles_empty_input():
|
|||
|
||||
|
||||
def test_strip_shadowing_flags_defaults_strip_everything():
|
||||
# The route's already-loaded comparator calls strip_shadowing_flags
|
||||
# with no kwargs to detect ANY shadowing flag in stored extras.
|
||||
# The route's already-loaded comparator calls with no kwargs to
|
||||
# detect ANY shadowing flag in stored extras.
|
||||
out = strip_shadowing_flags(
|
||||
["-c", "4096", "--cache-type-k", "q8_0", "--spec-default", "--jinja"]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ def _load_worker_module():
|
|||
_worker = _load_worker_module()
|
||||
_normalize_mlx_studio_optimizer = _worker._normalize_mlx_studio_optimizer
|
||||
_normalize_mlx_studio_scheduler = _worker._normalize_mlx_studio_scheduler
|
||||
_mlx_vlm_max_resized_size = _worker._mlx_vlm_max_resized_size
|
||||
|
||||
|
||||
def test_mlx_studio_optimizer_aliases_are_explicit():
|
||||
|
|
@ -82,3 +83,14 @@ def test_mlx_studio_rejects_unknown_optimizer():
|
|||
def test_mlx_studio_rejects_unknown_scheduler():
|
||||
with pytest.raises(ValueError, match = "Unsupported LR scheduler for MLX training"):
|
||||
_normalize_mlx_studio_scheduler("linear_typo")
|
||||
|
||||
|
||||
def test_mlx_vlm_resize_uses_max_dimension_like_torch_trainer():
|
||||
assert _mlx_vlm_max_resized_size(1000, 500, 512) == (512, 256)
|
||||
assert _mlx_vlm_max_resized_size(500, 1000, 512) == (256, 512)
|
||||
assert _mlx_vlm_max_resized_size(1000, 1000, 512) == (512, 512)
|
||||
assert _mlx_vlm_max_resized_size(256, 128, 1536) == (256, 128)
|
||||
assert _mlx_vlm_max_resized_size(512, 256, 512) == (512, 256)
|
||||
# Half-pixel cases must match the Torch collator (not banker's round).
|
||||
assert _mlx_vlm_max_resized_size(333, 1000, 500) == (167, 500)
|
||||
assert _mlx_vlm_max_resized_size(1000, 333, 500) == (500, 167)
|
||||
|
|
|
|||
|
|
@ -269,7 +269,15 @@ def test_shell_call_emits_tool_start_and_end(monkeypatch):
|
|||
assert len(ends) == 1
|
||||
assert starts[0]["tool_name"] == "code_execution"
|
||||
assert starts[0]["tool_call_id"] == "scall_1"
|
||||
assert starts[0]["arguments"] == {"kind": "bash", "command": "ls -la"}
|
||||
# `_server_tool: True` is the synthetic-builtin marker the
|
||||
# backend stamps onto every provider-side tool_start so the
|
||||
# frontend serializer can distinguish hosted tools from
|
||||
# user-declared functions on history replay.
|
||||
assert starts[0]["arguments"] == {
|
||||
"kind": "bash",
|
||||
"command": "ls -la",
|
||||
"_server_tool": True,
|
||||
}
|
||||
assert ends[0]["tool_call_id"] == "scall_1"
|
||||
assert "total 24" in ends[0]["result"]
|
||||
|
||||
|
|
|
|||
|
|
@ -207,9 +207,12 @@ def test_image_generation_done_emits_tool_event_chunks(monkeypatch):
|
|||
ends = [e for e in image_events if e.get("type") == "tool_end"]
|
||||
assert len(starts) == 1, image_events
|
||||
assert len(ends) == 1, image_events
|
||||
# `_server_tool: True` marks this as a provider-side synthetic
|
||||
# tool card on the frontend's history serializer.
|
||||
assert starts[0]["arguments"] == {
|
||||
"kind": "image",
|
||||
"prompt": "A photorealistic cat sitting",
|
||||
"_server_tool": True,
|
||||
"openai_image_generation_call_id": "img_abc",
|
||||
}
|
||||
assert ends[0]["image_b64"] == "AAAA"
|
||||
|
|
|
|||
|
|
@ -215,6 +215,254 @@ def test_responses_sse_translates_to_chat_completions_chunks(monkeypatch):
|
|||
assert payloads[-1] == "[DONE]"
|
||||
|
||||
|
||||
def test_responses_function_call_output_translates_to_delta_tool_calls(monkeypatch):
|
||||
"""Round 12: caller-supplied function tools forwarded into /v1/responses
|
||||
must have their `function_call` output items translated back into Chat
|
||||
Completions delta.tool_calls, and the terminal chunk must emit
|
||||
finish_reason="tool_calls" so the frontend's accumulator runs the
|
||||
function instead of seeing finish_reason="stop"."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
events = [
|
||||
{"type": "response.created"},
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"id": "fc_abc",
|
||||
"call_id": "call_xyz",
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city":"SF"}',
|
||||
},
|
||||
},
|
||||
{"type": "response.completed", "response": {}},
|
||||
]
|
||||
return httpx.Response(
|
||||
200,
|
||||
content = _responses_sse(events),
|
||||
headers = {"content-type": "text/event-stream"},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
async def run():
|
||||
client = _make_client()
|
||||
lines = await _collect(
|
||||
client._stream_openai_responses(
|
||||
messages = [{"role": "user", "content": "weather?"}],
|
||||
model = "gpt-5.5",
|
||||
temperature = 0.7,
|
||||
top_p = 0.95,
|
||||
max_tokens = None,
|
||||
enable_thinking = None,
|
||||
reasoning_effort = None,
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
await client.close()
|
||||
return lines
|
||||
|
||||
lines = _drive(run())
|
||||
payloads = [
|
||||
json.loads(line[len("data:") :].strip())
|
||||
for line in lines
|
||||
if line.startswith("data:") and line[len("data:") :].strip() != "[DONE]"
|
||||
]
|
||||
tool_call_deltas = [
|
||||
p
|
||||
for p in payloads
|
||||
if isinstance(p, dict)
|
||||
and p.get("choices")
|
||||
and p["choices"][0].get("delta", {}).get("tool_calls")
|
||||
]
|
||||
assert tool_call_deltas, payloads
|
||||
tc = tool_call_deltas[0]["choices"][0]["delta"]["tool_calls"][0]
|
||||
assert tc["id"] == "call_xyz"
|
||||
assert tc["function"]["name"] == "get_weather"
|
||||
assert tc["function"]["arguments"] == '{"city":"SF"}'
|
||||
# Final chunk reports tool_calls instead of stop.
|
||||
terminal = next(
|
||||
p
|
||||
for p in payloads
|
||||
if isinstance(p, dict)
|
||||
and p.get("choices")
|
||||
and p["choices"][0].get("finish_reason") in ("stop", "tool_calls")
|
||||
)
|
||||
assert terminal["choices"][0]["finish_reason"] == "tool_calls", payloads
|
||||
|
||||
|
||||
def test_responses_parallel_function_calls_get_distinct_indices(monkeypatch):
|
||||
"""Round 13: parallel function_call items must land on distinct
|
||||
delta.tool_calls[].index slots so index-keyed clients don't
|
||||
collapse the second call into the first."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
events = [
|
||||
{"type": "response.created"},
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"id": "fc_a",
|
||||
"call_id": "call_a",
|
||||
"name": "lookup_a",
|
||||
"arguments": "{}",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"id": "fc_b",
|
||||
"call_id": "call_b",
|
||||
"name": "lookup_b",
|
||||
"arguments": "{}",
|
||||
},
|
||||
},
|
||||
{"type": "response.completed", "response": {}},
|
||||
]
|
||||
return httpx.Response(
|
||||
200,
|
||||
content = _responses_sse(events),
|
||||
headers = {"content-type": "text/event-stream"},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
async def run():
|
||||
client = _make_client()
|
||||
lines = await _collect(
|
||||
client._stream_openai_responses(
|
||||
messages = [{"role": "user", "content": "x"}],
|
||||
model = "gpt-5.5",
|
||||
temperature = 0.7,
|
||||
top_p = 0.95,
|
||||
max_tokens = None,
|
||||
enable_thinking = None,
|
||||
reasoning_effort = None,
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup_a",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup_b",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
)
|
||||
await client.close()
|
||||
return lines
|
||||
|
||||
lines = _drive(run())
|
||||
indices: list[int] = []
|
||||
for raw in lines:
|
||||
if not raw.startswith("data:"):
|
||||
continue
|
||||
payload = raw[len("data:") :].strip()
|
||||
if payload == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(payload)
|
||||
except Exception:
|
||||
continue
|
||||
delta = (obj.get("choices") or [{}])[0].get("delta") or {}
|
||||
for tc in delta.get("tool_calls") or []:
|
||||
indices.append(tc.get("index"))
|
||||
assert indices == [0, 1], indices
|
||||
|
||||
|
||||
def test_responses_follow_up_tool_result_uses_function_call_output_items(monkeypatch):
|
||||
"""Round 13: a second turn after a Responses function call must
|
||||
serialize the tool_calls history and tool result as Responses
|
||||
`function_call` / `function_call_output` input items, not as
|
||||
Chat Completions role="tool" content."""
|
||||
captured: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = json.loads(request.content.decode("utf-8"))
|
||||
return httpx.Response(
|
||||
200,
|
||||
content = _responses_sse(
|
||||
[
|
||||
{"type": "response.created"},
|
||||
{"type": "response.completed", "response": {}},
|
||||
]
|
||||
),
|
||||
headers = {"content-type": "text/event-stream"},
|
||||
)
|
||||
|
||||
_mock_http_client(monkeypatch, handler)
|
||||
|
||||
async def run():
|
||||
client = _make_client()
|
||||
await _collect(
|
||||
client._stream_openai_responses(
|
||||
messages = [
|
||||
{"role": "user", "content": "weather?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_xyz",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city":"SF"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_xyz",
|
||||
"content": "sunny",
|
||||
},
|
||||
{"role": "user", "content": "thanks"},
|
||||
],
|
||||
model = "gpt-5.5",
|
||||
temperature = 0.7,
|
||||
top_p = 0.95,
|
||||
max_tokens = None,
|
||||
enable_thinking = None,
|
||||
reasoning_effort = None,
|
||||
)
|
||||
)
|
||||
await client.close()
|
||||
|
||||
_drive(run())
|
||||
items = captured["body"]["input"]
|
||||
types = [it.get("type") or it.get("role") for it in items]
|
||||
assert "function_call" in types, items
|
||||
assert "function_call_output" in types, items
|
||||
fc = next(it for it in items if it.get("type") == "function_call")
|
||||
assert fc["call_id"] == "call_xyz"
|
||||
assert fc["name"] == "get_weather"
|
||||
assert fc["arguments"] == '{"city":"SF"}'
|
||||
fco = next(it for it in items if it.get("type") == "function_call_output")
|
||||
assert fco["call_id"] == "call_xyz"
|
||||
assert fco["output"] == "sunny"
|
||||
|
||||
|
||||
def test_responses_response_incomplete_maps_to_length_finish_reason(monkeypatch):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
events = [
|
||||
|
|
|
|||
|
|
@ -653,10 +653,9 @@ def test_openrouter_stop_cap_is_4(monkeypatch):
|
|||
assert body["stop"] == ["S0", "S1", "S2", "S3"], body
|
||||
|
||||
|
||||
def test_gemini_stop_cap_is_4(monkeypatch):
|
||||
"""Gemini's OpenAI-compatible layer inherits OpenAI's 4-entry stop
|
||||
cap (https://ai.google.dev/gemini-api/docs/openai). The default
|
||||
16-cap is too permissive."""
|
||||
def test_gemini_stop_sequences_capped_to_5(monkeypatch):
|
||||
"""Native Gemini API forwards `stop` as generationConfig.stopSequences,
|
||||
capped at 5 per https://ai.google.dev/api/generate-content#generationconfig."""
|
||||
captured = _install_mock(monkeypatch, sse_payload = _oai_done_payload())
|
||||
|
||||
async def run():
|
||||
|
|
@ -678,8 +677,8 @@ def test_gemini_stop_cap_is_4(monkeypatch):
|
|||
|
||||
_drive(run())
|
||||
body = captured["body"]
|
||||
assert len(body.get("stop", [])) == 4, body
|
||||
assert body["stop"] == ["S0", "S1", "S2", "S3"], body
|
||||
gen_config = body.get("generationConfig", {})
|
||||
assert gen_config.get("stopSequences") == ["S0", "S1", "S2", "S3", "S4"], body
|
||||
|
||||
|
||||
def test_kimi_drops_stop_strings_over_32_bytes(monkeypatch):
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ from models.training import (
|
|||
_MAX_LORA_ALPHA,
|
||||
_MAX_LORA_R,
|
||||
_MAX_SEQ_LENGTH,
|
||||
_MAX_VISION_IMAGE_SIZE,
|
||||
_MIN_VISION_IMAGE_SIZE,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -62,6 +64,52 @@ class TestBatchSizeCap:
|
|||
_check_field("batch_size", 0)
|
||||
|
||||
|
||||
class TestVisionImageSizeCap:
|
||||
def test_none_accepts_model_default(self):
|
||||
_check_field("vision_image_size", None)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[_MIN_VISION_IMAGE_SIZE, 640, 1000, _MAX_VISION_IMAGE_SIZE],
|
||||
)
|
||||
def test_in_range_accepts(self, value):
|
||||
_check_field("vision_image_size", value)
|
||||
assert _MIN_VISION_IMAGE_SIZE == 256
|
||||
assert _MAX_VISION_IMAGE_SIZE == 2048
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[_MIN_VISION_IMAGE_SIZE - 1, _MAX_VISION_IMAGE_SIZE + 1, 640.5, True],
|
||||
)
|
||||
def test_invalid_rejects(self, value):
|
||||
with pytest.raises(ValidationError):
|
||||
_check_field("vision_image_size", value)
|
||||
|
||||
@pytest.mark.parametrize("value", [True, False])
|
||||
def test_bool_error_says_integer_not_range(self, value):
|
||||
# Regression guard: bools must say "integer or null", not "in [256, 2048]".
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
_check_field("vision_image_size", value)
|
||||
assert "integer or null" in str(exc.value)
|
||||
|
||||
@pytest.mark.parametrize("value", ["++512", "--256", "+-+512", "+", "-"])
|
||||
def test_multi_sign_string_says_integer_not_raw(self, value):
|
||||
# Regression guard: multi-sign strings must not leak int()'s raw
|
||||
# "invalid literal" message; precise contract is "integer or null".
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
_check_field("vision_image_size", value)
|
||||
assert "integer or null" in str(exc.value)
|
||||
assert "invalid literal" not in str(exc.value)
|
||||
|
||||
@pytest.mark.parametrize("value", ["512", "٥١٢", "१०२४"])
|
||||
def test_unicode_digit_string_rejected(self, value):
|
||||
# Full-width / Arabic-Indic / Devanagari digits must be rejected so the
|
||||
# value reaching the backend equals the ASCII the user typed.
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
_check_field("vision_image_size", value)
|
||||
assert "integer or null" in str(exc.value)
|
||||
|
||||
|
||||
class TestLoraRCap:
|
||||
def test_at_cap_accepts(self):
|
||||
_check_field("lora_r", _MAX_LORA_R)
|
||||
|
|
|
|||
39
studio/backend/utils/cpu_threads.py
Normal file
39
studio/backend/utils/cpu_threads.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Early CPU thread-pool configuration for Studio processes."""
|
||||
|
||||
import os
|
||||
from typing import MutableMapping, Optional
|
||||
|
||||
|
||||
_THREAD_POOL_ENV_VARS = (
|
||||
"OMP_NUM_THREADS",
|
||||
"MKL_NUM_THREADS",
|
||||
"OPENBLAS_NUM_THREADS",
|
||||
"NUMEXPR_NUM_THREADS",
|
||||
)
|
||||
|
||||
|
||||
def configure_cpu_threads(env: Optional[MutableMapping[str, str]] = None) -> None:
|
||||
"""Apply ``UNSLOTH_CPU_THREADS`` to native CPU pools when configured.
|
||||
|
||||
This must run before importing libraries that initialize an OpenMP or
|
||||
BLAS thread pool. Library-specific variables are left untouched so users
|
||||
can override a single runtime independently.
|
||||
"""
|
||||
environ = os.environ if env is None else env
|
||||
configured = environ.get("UNSLOTH_CPU_THREADS", "").strip()
|
||||
if not configured:
|
||||
return
|
||||
|
||||
try:
|
||||
thread_count = int(configured)
|
||||
except ValueError as exc:
|
||||
raise ValueError("UNSLOTH_CPU_THREADS must be a positive integer") from exc
|
||||
if thread_count < 1:
|
||||
raise ValueError("UNSLOTH_CPU_THREADS must be a positive integer")
|
||||
|
||||
value = str(thread_count)
|
||||
for variable in _THREAD_POOL_ENV_VARS:
|
||||
environ.setdefault(variable, value)
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"i18n:check": "node --experimental-strip-types --no-warnings src/i18n/check-parity.ts",
|
||||
"biome:check": "biome check",
|
||||
"biome:fix": "biome check --write"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import { Link, createRouter, useRouterState } from "@tanstack/react-router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useT } from "@/i18n";
|
||||
import { Route as rootRoute } from "./routes/__root";
|
||||
import { Route as dataRecipesRoute } from "./routes/data-recipes";
|
||||
import { Route as dataRecipeRoute } from "./routes/data-recipes.$recipeId";
|
||||
|
|
@ -31,7 +32,9 @@ const routeTree = rootRoute.addChildren([
|
|||
]);
|
||||
|
||||
function DefaultNotFound() {
|
||||
const t = useT();
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<img
|
||||
|
|
@ -41,14 +44,14 @@ function DefaultNotFound() {
|
|||
/>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<h1 className="font-heading font-semibold text-2xl tracking-tight">
|
||||
Page not found
|
||||
{t("shell.notFound.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm break-all">
|
||||
{pathname} does not exist.
|
||||
{t("shell.notFound.description", { path: pathname })}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link to="/chat">Back to chat</Link>
|
||||
<Link to="/chat">{t("shell.notFound.backToChat")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import { Navbar } from "@/components/navbar";
|
|||
import { fetchDeviceType, usePlatformStore } from "@/config/env";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { SettingsDialog, useSettingsDialogStore } from "@/features/settings";
|
||||
import { useTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
|
||||
import { useTrainingUnloadGuard } from "@/features/training";
|
||||
import { useSidebarPin } from "@/hooks/use-sidebar-pin";
|
||||
import { useT, type TranslationKey } from "@/i18n";
|
||||
import {
|
||||
Outlet,
|
||||
createRootRoute,
|
||||
|
|
@ -16,24 +17,25 @@ import {
|
|||
useRouterState,
|
||||
} from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Suspense, useEffect, useLayoutEffect, type ReactNode } from "react";
|
||||
import { Suspense, useEffect, useLayoutEffect } from "react";
|
||||
import { AppProvider } from "../provider";
|
||||
|
||||
// Type `staticData.title` on every route so the matched-title selector
|
||||
// below stays type-safe without an inline cast.
|
||||
declare module "@tanstack/react-router" {
|
||||
interface StaticDataRouteOption {
|
||||
title?: string;
|
||||
titleKey?: TranslationKey;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback while a lazy route bundle (Train/Recipes/Export) loads.
|
||||
// /chat is synchronous and never hits this.
|
||||
const RouteFallback: ReactNode = (
|
||||
<div className="flex h-full min-h-0 flex-1 items-center justify-center text-muted-foreground text-sm">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
function RouteFallback() {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1 items-center justify-center text-muted-foreground text-sm">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CHAT_ONLY_ALLOWED = new Set([
|
||||
"/",
|
||||
|
|
@ -68,6 +70,7 @@ const HIDDEN_NAVBAR_ROUTES = ["/onboarding", "/login", "/change-password"];
|
|||
const DEFAULT_DOCUMENT_TITLE = "Unsloth Studio";
|
||||
|
||||
function RootLayout() {
|
||||
const t = useT();
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
const hideNavbar = HIDDEN_NAVBAR_ROUTES.includes(pathname);
|
||||
const isChatRoute = pathname.startsWith("/chat");
|
||||
|
|
@ -75,24 +78,20 @@ function RootLayout() {
|
|||
|
||||
useTrainingUnloadGuard();
|
||||
|
||||
// Walk matches deepest-first; each route declares its own title.
|
||||
const matchedTitle = useMatches({
|
||||
select: (matches) => {
|
||||
for (let i = matches.length - 1; i >= 0; i--) {
|
||||
const title = matches[i].staticData.title;
|
||||
const { title, titleKey } = matches[i].staticData;
|
||||
if (titleKey) return t(titleKey);
|
||||
if (title) return title;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
// `/settings` redirects in `beforeLoad`, so its route never stays
|
||||
// matched; surface the modal's title via the store instead.
|
||||
const settingsDialogOpen = useSettingsDialogStore((s) => s.open);
|
||||
const documentTitle = settingsDialogOpen ? "Settings" : matchedTitle;
|
||||
const documentTitle = settingsDialogOpen ? t("settings.title") : matchedTitle;
|
||||
|
||||
// useLayoutEffect updates the tab title before paint, avoiding a
|
||||
// one-frame flash of the previous route's title on navigation.
|
||||
useLayoutEffect(() => {
|
||||
document.title = documentTitle
|
||||
? `${documentTitle} - ${DEFAULT_DOCUMENT_TITLE}`
|
||||
|
|
@ -116,7 +115,7 @@ function RootLayout() {
|
|||
<SettingsDialog />
|
||||
{hideNavbar ? (
|
||||
<main className="flex-1">
|
||||
<Suspense fallback={RouteFallback}>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
|
|
@ -142,7 +141,7 @@ function RootLayout() {
|
|||
transition={{ duration: 0.15 }}
|
||||
className={`flex min-h-0 min-w-0 flex-1 basis-0 flex-col ${isChatRoute ? "overflow-hidden" : "overflow-visible"}`}
|
||||
>
|
||||
<Suspense fallback={RouteFallback}>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const StudioPage = lazy(() =>
|
|||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/studio",
|
||||
staticData: { title: "Train" },
|
||||
staticData: { titleKey: "studio.routeTitle" },
|
||||
beforeLoad: () => requireAuth(),
|
||||
component: StudioPage,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -90,9 +90,33 @@ import {
|
|||
useTrainingRuntimeStore,
|
||||
} from "@/features/training";
|
||||
import type { TrainingRunSummary } from "@/features/training";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { ShutdownDialog } from "@/components/shutdown-dialog";
|
||||
import { translate, useT, type TranslationKey } from "@/i18n";
|
||||
|
||||
const EMPHASIS_MARKER = "__UNSLOTH_I18N_EMPHASIS_MARKER__";
|
||||
|
||||
type AppT = ReturnType<typeof useT>;
|
||||
|
||||
function renderEmphasizedTranslation(
|
||||
t: AppT,
|
||||
key: TranslationKey,
|
||||
emphasizedValue: string,
|
||||
): ReactNode {
|
||||
const translated = t(key, { name: EMPHASIS_MARKER });
|
||||
const parts = translated.split(EMPHASIS_MARKER);
|
||||
if (parts.length === 1) return translated;
|
||||
|
||||
const nodes: ReactNode[] = [];
|
||||
parts.forEach((part, index) => {
|
||||
if (part.length > 0) nodes.push(part);
|
||||
if (index < parts.length - 1) {
|
||||
nodes.push(<em key={`emphasis-${index}`}>{emphasizedValue}</em>);
|
||||
}
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function getTourId(pathname: string): string | null {
|
||||
if (pathname.startsWith("/studio")) return "studio";
|
||||
|
|
@ -185,6 +209,7 @@ function NavItem({
|
|||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
const t = useT();
|
||||
const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle();
|
||||
const { pathname, search } = useRouterState({
|
||||
select: (s) => ({
|
||||
|
|
@ -204,14 +229,8 @@ export function AppSidebar() {
|
|||
const chatOnly = usePlatformStore((s) => s.isChatOnly());
|
||||
const [shutdownOpen, setShutdownOpen] = useState(false);
|
||||
|
||||
// Chat collapsible state — open by default, auto-expand on route entry
|
||||
const isChatRoute = pathname.startsWith("/chat");
|
||||
const isStudioRoute = pathname === "/studio" || pathname.startsWith("/studio/");
|
||||
const [chatOpen, setChatOpen] = useState(true);
|
||||
const [runsOpen, setRunsOpen] = useState(true);
|
||||
|
||||
useEffect(() => { if (isChatRoute) setChatOpen(true); }, [isChatRoute]);
|
||||
useEffect(() => { if (isStudioRoute) setRunsOpen(true); }, [isStudioRoute]);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
|
@ -290,7 +309,7 @@ export function AppSidebar() {
|
|||
try {
|
||||
await renameChatItem(target.item, renameTrimmed);
|
||||
} catch (err) {
|
||||
toast.error("Failed to rename chat", {
|
||||
toast.error(translate("shell.toast.failedToRenameChat"), {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
|
|
@ -300,7 +319,7 @@ export function AppSidebar() {
|
|||
const updated = await renameTrainingRun(target.run.id, nextRunDisplayName);
|
||||
emitTrainingRunUpdated(updated);
|
||||
} catch (err) {
|
||||
toast.error("Failed to rename run", {
|
||||
toast.error(translate("shell.toast.failedToRenameRun"), {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
|
|
@ -320,14 +339,14 @@ export function AppSidebar() {
|
|||
try {
|
||||
await handleDeleteThread(target.item);
|
||||
} catch (err) {
|
||||
toast.error("Failed to delete chat", {
|
||||
toast.error(translate("shell.toast.failedToDeleteChat"), {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (target.run.status === "running") {
|
||||
toast.error("Cannot delete a running training run");
|
||||
toast.error(t("shell.toast.cannotDeleteRunningRun"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -337,7 +356,7 @@ export function AppSidebar() {
|
|||
}
|
||||
emitTrainingRunDeleted(target.run.id);
|
||||
} catch (err) {
|
||||
toast.error("Failed to delete run", {
|
||||
toast.error(translate("shell.toast.failedToDeleteRun"), {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
|
|
@ -366,7 +385,7 @@ export function AppSidebar() {
|
|||
});
|
||||
}}
|
||||
className="flex items-center gap-[6px] select-none"
|
||||
aria-label="Unsloth home"
|
||||
aria-label={t("shell.aria.home")}
|
||||
>
|
||||
<img
|
||||
src="/circle-logo-small.png"
|
||||
|
|
@ -377,7 +396,7 @@ export function AppSidebar() {
|
|||
unsloth
|
||||
</span>
|
||||
<span className="nav-badge ml-0.5 inline-flex items-center justify-center rounded-full border border-nav-beta-border px-[5px] pt-[3px] pb-[2px] text-[8px] font-medium leading-none tracking-[0.04em] text-nav-fg-muted antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]">
|
||||
BETA
|
||||
{t("shell.beta")}
|
||||
</span>
|
||||
</Link>
|
||||
{!isMobile && (
|
||||
|
|
@ -387,7 +406,7 @@ export function AppSidebar() {
|
|||
type="button"
|
||||
onClick={togglePinned}
|
||||
className="inline-flex h-[35px] w-[32px] items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Close sidebar"
|
||||
aria-label={t("shell.aria.closeSidebar")}
|
||||
>
|
||||
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
|
||||
</button>
|
||||
|
|
@ -397,7 +416,7 @@ export function AppSidebar() {
|
|||
sideOffset={6}
|
||||
className="tooltip-compact"
|
||||
>
|
||||
Close sidebar
|
||||
{t("shell.aria.closeSidebar")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
|
@ -412,7 +431,7 @@ export function AppSidebar() {
|
|||
type="button"
|
||||
onClick={togglePinned}
|
||||
className="inline-flex h-[35px] w-[32px] items-center justify-center rounded-[10px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Open sidebar"
|
||||
aria-label={t("shell.aria.openSidebar")}
|
||||
>
|
||||
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
|
||||
</button>
|
||||
|
|
@ -422,7 +441,7 @@ export function AppSidebar() {
|
|||
sideOffset={8}
|
||||
className="tooltip-compact"
|
||||
>
|
||||
Open sidebar
|
||||
{t("shell.aria.openSidebar")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
@ -434,7 +453,7 @@ export function AppSidebar() {
|
|||
<SidebarMenu>
|
||||
<NavItem
|
||||
icon={PencilEdit02Icon}
|
||||
label="New Chat"
|
||||
label={t("shell.navigation.newChat")}
|
||||
active={false}
|
||||
disabled={chatDisabled}
|
||||
onClick={() => {
|
||||
|
|
@ -446,7 +465,7 @@ export function AppSidebar() {
|
|||
/>
|
||||
<NavItem
|
||||
icon={ColumnInsertIcon}
|
||||
label="Compare"
|
||||
label={t("shell.navigation.compare")}
|
||||
active={!!search.compare && !chatItems.some((i) => i.id === search.compare)}
|
||||
disabled={chatDisabled}
|
||||
dataTour="chat-compare"
|
||||
|
|
@ -459,7 +478,7 @@ export function AppSidebar() {
|
|||
/>
|
||||
<NavItem
|
||||
icon={Search01Icon}
|
||||
label="Search"
|
||||
label={t("shell.navigation.search")}
|
||||
active={false}
|
||||
disabled={chatDisabled}
|
||||
onClick={() => {
|
||||
|
|
@ -477,7 +496,7 @@ export function AppSidebar() {
|
|||
<SidebarMenu>
|
||||
<NavItem
|
||||
icon={TestTubeOutlineIcon}
|
||||
label="Train"
|
||||
label={t("shell.navigation.train")}
|
||||
active={pathname === "/studio" || pathname.startsWith("/studio/")}
|
||||
disabled={chatOnly}
|
||||
onClick={() => {
|
||||
|
|
@ -489,7 +508,7 @@ export function AppSidebar() {
|
|||
|
||||
<NavItem
|
||||
icon={ChefHatIcon}
|
||||
label="Recipes"
|
||||
label={t("shell.navigation.recipes")}
|
||||
active={isRecipesRoute}
|
||||
onClick={() => {
|
||||
navigate({ to: "/data-recipes" });
|
||||
|
|
@ -499,7 +518,7 @@ export function AppSidebar() {
|
|||
|
||||
<NavItem
|
||||
icon={DownloadSquare01Icon}
|
||||
label="Export"
|
||||
label={t("shell.navigation.export")}
|
||||
active={pathname === "/export" || pathname.startsWith("/export/")}
|
||||
disabled={chatOnly}
|
||||
onClick={() => {
|
||||
|
|
@ -513,13 +532,16 @@ export function AppSidebar() {
|
|||
</SidebarGroup>
|
||||
|
||||
<SidebarContent ref={scrollRef} className="gap-0 overflow-y-auto overscroll-contain min-h-0">
|
||||
{/* Recent Chats — hide on Studio only (Eyera fac13); chatOpen = ec695 clickability */}
|
||||
{!isStudioRoute && chatItems.length > 0 && (
|
||||
<Collapsible open={chatOpen} onOpenChange={setChatOpen} asChild>
|
||||
<Collapsible
|
||||
key={isChatRoute ? "chat-route" : "non-chat-route"}
|
||||
defaultOpen
|
||||
asChild
|
||||
>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
|
||||
<SidebarGroupLabel className={cn("sidebar-sticky-label", scrolled && "is-scrolled")} asChild>
|
||||
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
|
||||
Recents
|
||||
{t("shell.navigation.recents")}
|
||||
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
|
|
@ -552,7 +574,7 @@ export function AppSidebar() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Chat options"
|
||||
aria-label={t("shell.aria.chatOptions")}
|
||||
className="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"
|
||||
>
|
||||
<span className="sidebar-row-action-glyph">
|
||||
|
|
@ -568,14 +590,14 @@ export function AppSidebar() {
|
|||
>
|
||||
<DropdownMenuItem onSelect={() => openRenameChat(item)}>
|
||||
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Rename</span>
|
||||
<span>{t("common.rename")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => setConfirmingDelete({ kind: "chat", item })}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Delete</span>
|
||||
<span>{t("common.delete")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -588,13 +610,12 @@ export function AppSidebar() {
|
|||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Recent Runs */}
|
||||
{isStudioRoute && runItems.length > 0 && !chatOnly && (
|
||||
<Collapsible open={runsOpen} onOpenChange={setRunsOpen} asChild>
|
||||
<Collapsible key="studio-runs-route" defaultOpen asChild>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
|
||||
<SidebarGroupLabel className={cn("sidebar-sticky-label", scrolled && "is-scrolled")} asChild>
|
||||
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
|
||||
Recents
|
||||
{t("shell.navigation.recents")}
|
||||
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
|
|
@ -641,7 +662,7 @@ export function AppSidebar() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Run options"
|
||||
aria-label={t("shell.aria.runOptions")}
|
||||
className="sidebar-row-action group-hover/run-item:opacity-100 group-hover/run-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
|
||||
>
|
||||
<span className="sidebar-row-action-glyph">
|
||||
|
|
@ -657,7 +678,7 @@ export function AppSidebar() {
|
|||
>
|
||||
<DropdownMenuItem onSelect={() => openRenameRun(run)}>
|
||||
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Rename</span>
|
||||
<span>{t("common.rename")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
|
|
@ -667,7 +688,7 @@ export function AppSidebar() {
|
|||
}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Delete</span>
|
||||
<span>{t("common.delete")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -689,7 +710,7 @@ export function AppSidebar() {
|
|||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
aria-label={`${displayTitle} account menu`}
|
||||
aria-label={t("shell.accountMenu", { name: displayTitle })}
|
||||
className="sidebar-nav-btn !h-[50px] gap-[8px] px-2 py-[9px] rounded-[10px]"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
|
|
@ -717,16 +738,16 @@ export function AppSidebar() {
|
|||
onSelect={() => useSettingsDialogStore.getState().openDialog()}
|
||||
>
|
||||
<HugeiconsIcon icon={Settings02Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Settings</span>
|
||||
<span>{t("shell.navigation.settings")}</span>
|
||||
<DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => useSettingsDialogStore.getState().openDialog("api-keys")}
|
||||
>
|
||||
<HugeiconsIcon icon={Globe02Icon} strokeWidth={1.75} className="size-[18px]" />
|
||||
<span>API</span>
|
||||
<span>{t("shell.navigation.api")}</span>
|
||||
<span className="ml-auto rounded-[6px] border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
New
|
||||
{t("common.new")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
|
|
@ -734,7 +755,11 @@ export function AppSidebar() {
|
|||
onSelect={(e) => { e.preventDefault(); toggleTheme(); }}
|
||||
>
|
||||
{isDark ? <Sun strokeWidth={1.75} className="size-icon" /> : <Moon strokeWidth={1.75} className="size-icon" />}
|
||||
<span>{isDark ? "Light Mode" : "Dark Mode"}</span>
|
||||
<span>
|
||||
{isDark
|
||||
? t("shell.navigation.lightMode")
|
||||
: t("shell.navigation.darkMode")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!getTourId(pathname)}
|
||||
|
|
@ -749,7 +774,7 @@ export function AppSidebar() {
|
|||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={CursorInfo02Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Guided Tour</span>
|
||||
<span>{t("shell.navigation.guidedTour")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator className="mx-2.5! my-2.5! h-0! border-t border-border/70 bg-transparent!" />
|
||||
|
|
@ -757,7 +782,7 @@ export function AppSidebar() {
|
|||
onSelect={() => useSettingsDialogStore.getState().openDialog("about")}
|
||||
>
|
||||
<HugeiconsIcon icon={HelpCircleIcon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Help</span>
|
||||
<span>{t("common.help")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={async () => {
|
||||
|
|
@ -772,11 +797,11 @@ export function AppSidebar() {
|
|||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={Logout01Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Log out</span>
|
||||
<span>{t("shell.navigation.logOut")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setShutdownOpen(true)}>
|
||||
<HugeiconsIcon icon={PowerIcon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Shutdown</span>
|
||||
<span>{t("common.shutdown")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -800,20 +825,23 @@ export function AppSidebar() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{confirmingDelete?.kind === "run"
|
||||
? "Delete training run"
|
||||
: "Delete chat"}
|
||||
? t("shell.dialog.deleteRun.title")
|
||||
: t("shell.dialog.deleteChat.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{confirmingDelete?.kind === "run" ? (
|
||||
<>
|
||||
Are you sure you want to delete this run{" "}
|
||||
<em>{confirmingDelete.run.display_name ?? confirmingDelete.run.model_name}</em>?
|
||||
</>
|
||||
renderEmphasizedTranslation(
|
||||
t,
|
||||
"shell.dialog.deleteRun.description",
|
||||
confirmingDelete.run.display_name ??
|
||||
confirmingDelete.run.model_name,
|
||||
)
|
||||
) : confirmingDelete?.kind === "chat" ? (
|
||||
<>
|
||||
Are you sure you want to delete this chat{" "}
|
||||
<em>{confirmingDelete.item.title}</em>?
|
||||
</>
|
||||
renderEmphasizedTranslation(
|
||||
t,
|
||||
"shell.dialog.deleteChat.description",
|
||||
confirmingDelete.item.title,
|
||||
)
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
@ -823,14 +851,14 @@ export function AppSidebar() {
|
|||
variant="ghost"
|
||||
onClick={() => setConfirmingDelete(null)}
|
||||
>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => void commitDelete()}
|
||||
>
|
||||
Delete
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
@ -844,7 +872,9 @@ export function AppSidebar() {
|
|||
<DialogContent className="corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{renamingTarget?.kind === "run" ? "Rename run" : "Rename chat"}
|
||||
{renamingTarget?.kind === "run"
|
||||
? t("shell.dialog.renameRun.title")
|
||||
: t("shell.dialog.renameChat.title")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
|
|
@ -858,8 +888,16 @@ export function AppSidebar() {
|
|||
}}
|
||||
autoFocus
|
||||
maxLength={120}
|
||||
placeholder={renamingTarget?.kind === "run" ? "Run name" : "Chat title"}
|
||||
aria-label={renamingTarget?.kind === "run" ? "Run name" : "Chat title"}
|
||||
placeholder={
|
||||
renamingTarget?.kind === "run"
|
||||
? t("shell.dialog.renameRun.placeholder")
|
||||
: t("shell.dialog.renameChat.placeholder")
|
||||
}
|
||||
aria-label={
|
||||
renamingTarget?.kind === "run"
|
||||
? t("shell.dialog.renameRun.placeholder")
|
||||
: t("shell.dialog.renameChat.placeholder")
|
||||
}
|
||||
className="focus-visible:border-input focus-visible:ring-0"
|
||||
/>
|
||||
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
|
||||
|
|
@ -868,14 +906,14 @@ export function AppSidebar() {
|
|||
variant="ghost"
|
||||
onClick={() => setRenamingTarget(null)}
|
||||
>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void commitRename()}
|
||||
disabled={!renameDirty}
|
||||
>
|
||||
Save
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -802,6 +802,7 @@ const ReasoningToggle: FC = () => {
|
|||
{
|
||||
isReasoningProvider:
|
||||
selectedExternalProvider?.isReasoningModel === true,
|
||||
baseUrl: selectedExternalProvider?.baseUrl ?? null,
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ export const LR_DEFAULT_CPT = 5e-5;
|
|||
export const DEFAULT_HYPERPARAMS = {
|
||||
epochs: 3,
|
||||
contextLength: 2048,
|
||||
visionImageSize: null as number | null,
|
||||
learningRate: LR_DEFAULT_LORA,
|
||||
// null = let backend auto-compute (lr/10 per Unsloth CPT recipe). Only used by CPT.
|
||||
embeddingLearningRate: null as number | null,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -731,7 +731,10 @@ export function ChatPage(): ReactElement {
|
|||
const reasoningCaps = getExternalReasoningCapabilities(
|
||||
provider?.providerType,
|
||||
selection.modelId,
|
||||
{ isReasoningProvider: provider?.isReasoningModel === true },
|
||||
{
|
||||
isReasoningProvider: provider?.isReasoningModel === true,
|
||||
baseUrl: provider?.baseUrl ?? null,
|
||||
},
|
||||
);
|
||||
const state = useChatRuntimeStore.getState();
|
||||
const preferredEffort = state.reasoningEffort;
|
||||
|
|
@ -772,6 +775,8 @@ export function ChatPage(): ReactElement {
|
|||
: state.reasoningEffort;
|
||||
const supportsBuiltinWebSearch = providerSupportsBuiltinWebSearch(
|
||||
provider?.providerType,
|
||||
selection.modelId,
|
||||
provider?.baseUrl,
|
||||
);
|
||||
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution(
|
||||
provider?.providerType,
|
||||
|
|
@ -971,6 +976,7 @@ export function ChatPage(): ReactElement {
|
|||
{
|
||||
isReasoningProvider:
|
||||
selectedProvider?.isReasoningModel === true,
|
||||
baseUrl: selectedProvider?.baseUrl ?? null,
|
||||
},
|
||||
);
|
||||
const preferredEffort = store.reasoningEffort;
|
||||
|
|
@ -1012,6 +1018,8 @@ export function ChatPage(): ReactElement {
|
|||
store.setCheckpoint(value, null);
|
||||
const supportsBuiltinWebSearch = providerSupportsBuiltinWebSearch(
|
||||
selectedProvider?.providerType,
|
||||
selectedExternal?.modelId,
|
||||
selectedProvider?.baseUrl,
|
||||
);
|
||||
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution(
|
||||
selectedProvider?.providerType,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ export interface ExternalProviderConfig {
|
|||
updatedAt: number;
|
||||
}
|
||||
|
||||
// Gemini supports prompt caching, but the wire flow requires a
|
||||
// separate POST to /v1beta/cachedContents to create the cache before
|
||||
// the generateContent call can reference it; the boolean Studio
|
||||
// currently emits on enable_prompt_caching is not enough on its own.
|
||||
// Until that two-step orchestration ships we keep the picker off so
|
||||
// the toggle does not silently no-op for Gemini users. See
|
||||
// https://ai.google.dev/gemini-api/docs/caching.
|
||||
const PROMPT_CACHING_PROVIDER_TYPES = new Set(["openai", "anthropic"]);
|
||||
|
||||
export function supportsProviderPromptCaching(
|
||||
|
|
|
|||
|
|
@ -274,7 +274,27 @@ function _inferProviderFromOpenrouterId(
|
|||
// matching backend translation first.
|
||||
export function providerSupportsBuiltinWebSearch(
|
||||
providerType: string | null | undefined,
|
||||
modelId?: string | null | undefined,
|
||||
baseUrl?: string | null | undefined,
|
||||
): boolean {
|
||||
// Gemini ships grounded search via `tools: [{googleSearch: {}}]` on
|
||||
// every chat-capable model. Most image-tier ids (`-image`,
|
||||
// `nano-banana`) reject text-tool wiring because the
|
||||
// responseModalities path is mutually exclusive with text tools, but
|
||||
// Google explicitly documents Search grounding on the Gemini 3 image
|
||||
// family (gemini-3-pro-image-preview, gemini-3.1-flash-image-preview,
|
||||
// nano-banana-pro). Allow Search on those; hide on older image ids.
|
||||
// Custom Gemini OpenAI-compat proxies (non-Google bases) skip the
|
||||
// native translator on the backend, so native tool envelopes never
|
||||
// reach them -- hide the pill there.
|
||||
if (providerType === "gemini") {
|
||||
if (isGeminiCustomOpenAICompatBase(baseUrl)) return false;
|
||||
const normalized = modelId?.trim().toLowerCase() ?? "";
|
||||
if (normalized && isGeminiImageModel(normalized)) {
|
||||
return geminiImageModelAllowsGoogleSearch(normalized);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
providerType === "openai" ||
|
||||
providerType === "anthropic" ||
|
||||
|
|
@ -381,6 +401,20 @@ export function providerSupportsBuiltinCodeExecution(
|
|||
normalized.startsWith(prefix),
|
||||
);
|
||||
}
|
||||
if (providerType === "gemini") {
|
||||
// Gemini's `tools: [{codeExecution: {}}]` is supported on every
|
||||
// chat-capable model. Image-tier ids (`-image`, `nano-banana`)
|
||||
// reject text-tool wiring because the inline-image path is
|
||||
// mutually exclusive with codeExecution. Custom Gemini
|
||||
// OpenAI-compat proxies skip the native translator on the
|
||||
// backend, so native codeExecution envelopes do not reach them.
|
||||
// Wire-up lives in `_stream_gemini` on the backend; output comes
|
||||
// back inline as executableCode/codeExecutionResult parts. See
|
||||
// https://ai.google.dev/gemini-api/docs/code-execution.
|
||||
if (isGeminiCustomOpenAICompatBase(baseUrl)) return false;
|
||||
if (isGeminiImageModel(normalized)) return false;
|
||||
return normalized.startsWith("gemini-");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -404,12 +438,75 @@ export function providerSupportsBuiltinImageGeneration(
|
|||
modelId: string | null | undefined,
|
||||
baseUrl?: string | null,
|
||||
): boolean {
|
||||
if (providerType !== "openai") return false;
|
||||
if (!isOpenAICloudBaseUrl(baseUrl)) return false;
|
||||
const normalized = modelId?.trim().toLowerCase() ?? "";
|
||||
if (!normalized) return false;
|
||||
return OPENAI_IMAGE_GENERATION_MODEL_PREFIXES.some((prefix) =>
|
||||
normalized.startsWith(prefix),
|
||||
if (providerType === "openai") {
|
||||
if (!isOpenAICloudBaseUrl(baseUrl)) return false;
|
||||
return OPENAI_IMAGE_GENERATION_MODEL_PREFIXES.some((prefix) =>
|
||||
normalized.startsWith(prefix),
|
||||
);
|
||||
}
|
||||
if (providerType === "gemini") {
|
||||
// Gemini's Nano Banana image-output ids carry either `-image` (e.g.
|
||||
// `gemini-2.5-flash-image`, `gemini-3.1-flash-image-preview`) or the
|
||||
// `nano-banana` alias (`nano-banana-pro-preview`). The backend flips
|
||||
// generationConfig.responseModalities to ["TEXT", "IMAGE"] when one
|
||||
// is picked, and translates inlineData parts into the same image_b64
|
||||
// tool_end envelope the OpenAI path emits so the chat UI renders the
|
||||
// picture inline. Custom Gemini OpenAI-compat proxies skip the
|
||||
// native translator on the backend, so hide the image pill there.
|
||||
// See https://ai.google.dev/gemini-api/docs/image-generation.
|
||||
if (isGeminiCustomOpenAICompatBase(baseUrl)) return false;
|
||||
return normalized.includes("-image") || normalized.includes("nano-banana");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `modelId` is a Gemini image-output id (Nano Banana family).
|
||||
* Mirrors the backend's `is_image_picker_model` guard so the frontend
|
||||
* hides text-only tool pills (web_search, code_execution) for these.
|
||||
*/
|
||||
function isGeminiImageModel(modelId: string): boolean {
|
||||
const m = modelId.toLowerCase();
|
||||
return m.includes("-image") || m.includes("nano-banana");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the saved Gemini connection points at a custom
|
||||
* OpenAI-compatible gateway (any non-Google host). The backend
|
||||
* `_is_openai_compatible` mirrors this to route those connections
|
||||
* through `/chat/completions` instead of the native translator, so
|
||||
* native Gemini tool envelopes (googleSearch, codeExecution,
|
||||
* responseModalities) never reach them. Hide the corresponding
|
||||
* Studio pills here so the request, builder, and UI agree.
|
||||
*/
|
||||
export function isGeminiCustomOpenAICompatBase(
|
||||
baseUrl: string | null | undefined,
|
||||
): boolean {
|
||||
if (!baseUrl) return false;
|
||||
try {
|
||||
const host = new URL(baseUrl).hostname.toLowerCase();
|
||||
return host.length > 0 && host !== "generativelanguage.googleapis.com";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given Gemini image model supports `tools: [{googleSearch: {}}]`.
|
||||
* Google documents Search grounding on the Gemini 3 image family
|
||||
* (gemini-3-pro-image-preview, gemini-3.1-flash-image-preview,
|
||||
* "Nano Banana Pro"); older image ids (gemini-2.5-flash-image) reject
|
||||
* it with "Search as tool is not enabled for this model".
|
||||
*/
|
||||
function geminiImageModelAllowsGoogleSearch(modelId: string): boolean {
|
||||
const m = modelId.toLowerCase();
|
||||
return (
|
||||
m.startsWith("gemini-3-pro-image") ||
|
||||
m.startsWith("gemini-3.1-flash-image") ||
|
||||
m.startsWith("nano-banana-pro") ||
|
||||
m.startsWith("nano-banana-2")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -805,7 +902,53 @@ const PROVIDER_CAPABILITIES: Record<string, ProviderCapabilities> = {
|
|||
postSamplingProbs: false,
|
||||
},
|
||||
mistral: OPENAI_COMPAT_BASE,
|
||||
gemini: OPENAI_COMPAT_BASE,
|
||||
// Gemini's native generationConfig accepts temperature, topP, topK,
|
||||
// presencePenalty, frequencyPenalty (not surfaced today), seed and
|
||||
// stopSequences. minP and repetitionPenalty are not part of the
|
||||
// contract -- see https://ai.google.dev/api/rest/v1beta/GenerationConfig.
|
||||
// Backend request shaping lives in _stream_gemini in
|
||||
// studio/backend/core/inference/external_provider.py.
|
||||
gemini: {
|
||||
temperature: true,
|
||||
topP: true,
|
||||
topK: true,
|
||||
minP: false,
|
||||
repetitionPenalty: false,
|
||||
presencePenalty: true,
|
||||
frequencyPenalty: false,
|
||||
seed: false,
|
||||
stop: true,
|
||||
serviceTier: false,
|
||||
parallelToolCalls: false,
|
||||
typicalP: false,
|
||||
topNSigma: false,
|
||||
repeatLastN: false,
|
||||
dynatempRange: false,
|
||||
dynatempExponent: false,
|
||||
mirostat: false,
|
||||
mirostatTau: false,
|
||||
mirostatEta: false,
|
||||
topA: false,
|
||||
dryMultiplier: false,
|
||||
dryBase: false,
|
||||
dryAllowedLength: false,
|
||||
dryPenaltyLastN: false,
|
||||
xtcProbability: false,
|
||||
xtcThreshold: false,
|
||||
minKeep: false,
|
||||
ignoreEos: false,
|
||||
minTokens: false,
|
||||
skipSpecialTokens: false,
|
||||
spacesBetweenSpecialTokens: false,
|
||||
includeStopStrInOutput: false,
|
||||
truncatePromptTokens: false,
|
||||
nKeep: false,
|
||||
nProbs: false,
|
||||
cachePrompt: false,
|
||||
returnTokens: false,
|
||||
timingsPerToken: false,
|
||||
postSamplingProbs: false,
|
||||
},
|
||||
// Kimi K2.x locks temperature + top_p ("only 1 is allowed for this
|
||||
// model"); seed + parallel_tool_calls aren't in the Chat schema
|
||||
// (platform.kimi.ai/docs/api/chat). Backend strips via body_omit.
|
||||
|
|
@ -1116,6 +1259,119 @@ function resolveKimiReasoningCapabilities(modelId: string): ExternalReasoningCap
|
|||
return withEnableThinkingStyle();
|
||||
}
|
||||
|
||||
// Gemini's thinking ladder.
|
||||
// - Gemini 3.x (3 / 3.1 / 3.5, Pro + Flash + Flash-Lite) and the
|
||||
// gemini-pro-latest / gemini-flash-latest aliases use the new
|
||||
// `thinkingConfig.thinkingLevel` string field (LOW/MEDIUM/HIGH/
|
||||
// MINIMAL). Pro tier rejects MINIMAL.
|
||||
// - Gemini 2.5 Flash + 2.5 Pro stay on the integer
|
||||
// `thinkingConfig.thinkingBudget` (0=off on Flash, -1=dynamic,
|
||||
// N>0=cap; Pro rejects 0).
|
||||
// - 2.5 Flash-Lite: no native thinking surfaced; leave it off.
|
||||
// - Image-tier ids (`*-image*`, `nano-banana-pro-preview`): image
|
||||
// generation path -- no reasoning controls.
|
||||
const GEMINI3_PRO_PREFIXES = [
|
||||
"gemini-3.5-pro",
|
||||
"gemini-3.1-pro",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-pro-latest",
|
||||
];
|
||||
const GEMINI3_FLASH_PREFIXES = [
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3.1-flash",
|
||||
"gemini-3-flash",
|
||||
"gemini-flash-latest",
|
||||
"gemini-flash-lite-latest",
|
||||
];
|
||||
const GEMINI25_PRO_PREFIXES = [
|
||||
"gemini-2.5-pro",
|
||||
];
|
||||
const GEMINI25_FLASH_PREFIXES = [
|
||||
"gemini-2.5-flash",
|
||||
];
|
||||
const GEMINI_IMAGE_HINTS = [
|
||||
"-image",
|
||||
"nano-banana",
|
||||
];
|
||||
function resolveGeminiReasoningCapabilities(
|
||||
modelId: string,
|
||||
): ExternalReasoningCapabilities {
|
||||
const m = modelId.toLowerCase();
|
||||
if (GEMINI_IMAGE_HINTS.some((h) => m.includes(h))) {
|
||||
// Image generation; no thinking knob.
|
||||
return withEnableThinkingStyle();
|
||||
}
|
||||
// Gemini 2.5 Flash-Lite supports `thinkingBudget` with `0` = off and
|
||||
// a positive range starting at 512 (the backend maps "minimal" to
|
||||
// that floor at external_provider._stream_gemini). Check this branch
|
||||
// BEFORE the broader `gemini-2.5-flash` prefix.
|
||||
// https://ai.google.dev/gemini-api/docs/thinking
|
||||
if (m.startsWith("gemini-2.5-flash-lite")) {
|
||||
return withReasoningEffortStyle({
|
||||
supportsReasoning: true,
|
||||
supportsReasoningOff: true,
|
||||
reasoningEffortLevels: [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"max",
|
||||
] as const,
|
||||
});
|
||||
}
|
||||
if (GEMINI3_PRO_PREFIXES.some((p) => m.startsWith(p))) {
|
||||
// Gemini 3.x Pro: thinkingLevel supports low/medium/high per
|
||||
// https://ai.google.dev/gemini-api/docs/thinking and
|
||||
// https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-1-pro.
|
||||
// Cannot fully disable thinking; "minimal" is rejected on Pro.
|
||||
return withReasoningEffortStyle({
|
||||
supportsReasoning: true,
|
||||
supportsReasoningOff: false,
|
||||
reasoningEffortLevels: ["low", "medium", "high"] as const,
|
||||
});
|
||||
}
|
||||
if (GEMINI3_FLASH_PREFIXES.some((p) => m.startsWith(p))) {
|
||||
// Gemini 3 Flash: thinkingLevel minimal/low/medium/high. Minimal
|
||||
// is the closest to "off" Google offers on Gemini 3.
|
||||
return withReasoningEffortStyle({
|
||||
supportsReasoning: true,
|
||||
supportsReasoningOff: false,
|
||||
reasoningEffortLevels: [
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
] as const,
|
||||
});
|
||||
}
|
||||
if (GEMINI25_PRO_PREFIXES.some((p) => m.startsWith(p))) {
|
||||
// Gemini 2.5 Pro: thinkingBudget cannot be 0 (API rejects with
|
||||
// "only works in thinking mode"); backend coerces to a small
|
||||
// positive budget. The picker still hides the off switch.
|
||||
return withReasoningEffortStyle({
|
||||
supportsReasoning: true,
|
||||
supportsReasoningOff: false,
|
||||
reasoningEffortLevels: ["low", "medium", "high", "max"] as const,
|
||||
});
|
||||
}
|
||||
if (GEMINI25_FLASH_PREFIXES.some((p) => m.startsWith(p))) {
|
||||
// Gemini 2.5 Flash: thinkingBudget supports 0 = off cleanly.
|
||||
return withReasoningEffortStyle({
|
||||
supportsReasoning: true,
|
||||
supportsReasoningOff: true,
|
||||
reasoningEffortLevels: [
|
||||
"none",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"max",
|
||||
] as const,
|
||||
});
|
||||
}
|
||||
return withEnableThinkingStyle();
|
||||
}
|
||||
|
||||
function resolveMistralReasoningCapabilities(modelId: string): ExternalReasoningCapabilities {
|
||||
// magistral-* is native always-on (no reasoning_effort param; 422 if
|
||||
// injected). mistral-{small,medium,vibe-cli}-latest is adjustable
|
||||
|
|
@ -1147,6 +1403,8 @@ function resolveMistralReasoningCapabilities(modelId: string): ExternalReasoning
|
|||
export interface ExternalReasoningResolveOptions {
|
||||
/** vLLM connection flagged as a reasoning model in provider config. */
|
||||
isReasoningProvider?: boolean;
|
||||
/** Provider base URL; used to detect custom Gemini OAI-compat gateways. */
|
||||
baseUrl?: string | null;
|
||||
}
|
||||
|
||||
// vLLM has no per-model reasoning signal on OpenAI-compat — pin via user toggle.
|
||||
|
|
@ -1219,6 +1477,16 @@ export function getExternalReasoningCapabilities(
|
|||
}
|
||||
if (isKimiProvider) return resolveKimiReasoningCapabilities(modelForMatching);
|
||||
if (isMistralProvider) return resolveMistralReasoningCapabilities(modelForMatching);
|
||||
if (normalizedProvider === "gemini") {
|
||||
// Custom Gemini OAI-compat gateways (LiteLLM, proxies) route
|
||||
// through /chat/completions which drops the Gemini-native
|
||||
// thinkingConfig payload. Hide the native thinking ladder so the
|
||||
// UI does not advertise a control the backend cannot honor.
|
||||
if (isGeminiCustomOpenAICompatBase(options?.baseUrl)) {
|
||||
return withEnableThinkingStyle();
|
||||
}
|
||||
return resolveGeminiReasoningCapabilities(modelForMatching);
|
||||
}
|
||||
if (!isOpenAIProvider && !isAnthropicProvider) {
|
||||
return withEnableThinkingStyle();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -396,6 +396,7 @@ export function SharedComposer({
|
|||
{
|
||||
isReasoningProvider:
|
||||
selectedExternalProvider?.isReasoningModel === true,
|
||||
baseUrl: selectedExternalProvider?.baseUrl ?? null,
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
|
@ -449,16 +450,36 @@ export function SharedComposer({
|
|||
const supportsBuiltinWebFetch = providerSupportsBuiltinWebFetch(
|
||||
selectedExternalProvider?.providerType,
|
||||
);
|
||||
const searchDisabled =
|
||||
!modelLoaded || !(supportsTools || supportsBuiltinWebSearch);
|
||||
const codeDisabled =
|
||||
!modelLoaded || !(supportsTools || supportsBuiltinCodeExecution);
|
||||
// Images pill is only ever lit on OpenAI cloud's Responses-API models.
|
||||
// No local tool runtime fallback because the only image-generation
|
||||
// server tool we wire today is OpenAI's; local models cannot dispatch
|
||||
// it. Hidden entirely when the active model does not advertise it so
|
||||
// the pill row stays compact for providers without the capability.
|
||||
// Gemini rejects codeExecution alongside image modalities. Search is
|
||||
// blocked on older Gemini image ids but allowed on Gemini 3 image
|
||||
// models -- supportsBuiltinWebSearch already encodes the per-model
|
||||
// allowance, so we only disable Code unconditionally in Gemini
|
||||
// image mode.
|
||||
const isExternalGemini = selectedExternalProvider?.providerType === "gemini";
|
||||
const imageDisabled = !modelLoaded || !supportsBuiltinImageGeneration;
|
||||
const imageModeDisablesCode =
|
||||
isExternalGemini && imageToolsEnabled && !imageDisabled;
|
||||
// Image-tier Gemini models always reject codeExecution and reject
|
||||
// web_search on older ids (Gemini 3.x Pro/Flash allow it -- encoded
|
||||
// in supportsBuiltinWebSearch). Don't let the local `supportsTools`
|
||||
// runtime flag re-enable a pill the Gemini backend will silently
|
||||
// drop. Detect "external provider is Gemini AND model is image-tier"
|
||||
// and gate strictly on the provider builtin support.
|
||||
const isGeminiImageTier =
|
||||
isExternalGemini && supportsBuiltinImageGeneration;
|
||||
const searchDisabled =
|
||||
!modelLoaded ||
|
||||
(isGeminiImageTier
|
||||
? !supportsBuiltinWebSearch
|
||||
: !(supportsTools || supportsBuiltinWebSearch));
|
||||
const codeDisabled =
|
||||
!modelLoaded ||
|
||||
(isGeminiImageTier
|
||||
? true
|
||||
: !(supportsTools || supportsBuiltinCodeExecution)) ||
|
||||
imageModeDisablesCode;
|
||||
// Images pill is only ever lit on OpenAI cloud's Responses-API models
|
||||
// and Gemini Nano Banana family. No local tool runtime fallback.
|
||||
const showImagePill = supportsBuiltinImageGeneration;
|
||||
// Fetch pill: Anthropic-only (web_fetch_20250910 / web_fetch_20260209).
|
||||
const webFetchDisabled = !modelLoaded || !supportsBuiltinWebFetch;
|
||||
|
|
|
|||
|
|
@ -219,9 +219,34 @@ export type OpenAIMessageContentPart =
|
|||
|
||||
export type OpenAIMessageContent = string | OpenAIMessageContentPart[];
|
||||
|
||||
/**
|
||||
* OpenAI Chat Completions tool_call shape. Assistant turns echo back
|
||||
* function/tool calls as `tool_calls`; the matching tool result rides
|
||||
* on a separate `role="tool"` message keyed by `tool_call_id`.
|
||||
* `extra_content.google.thought_signature` is the Gemini-specific
|
||||
* round-trip field the backend translator both emits (on `delta.
|
||||
* tool_calls`) and consumes (when rebuilding the native functionCall
|
||||
* part on the next turn).
|
||||
*/
|
||||
export interface OpenAIToolCallPart {
|
||||
id?: string;
|
||||
type?: "function";
|
||||
function?: {
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
};
|
||||
extra_content?: unknown;
|
||||
}
|
||||
|
||||
export interface OpenAIChatMessage {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: OpenAIMessageContent;
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: OpenAIMessageContent | null;
|
||||
/** Assistant tool-call deltas, when the turn invoked a function tool. */
|
||||
tool_calls?: OpenAIToolCallPart[];
|
||||
/** `role="tool"` only: id matching `assistant.tool_calls[].id`. */
|
||||
tool_call_id?: string;
|
||||
/** `role="tool"` only: name of the function that produced the result. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface OpenAIChatCompletionsRequest {
|
||||
|
|
@ -262,7 +287,14 @@ export interface OpenAIChatCompletionsRequest {
|
|||
external_model?: string;
|
||||
encrypted_api_key?: string;
|
||||
provider_base_url?: string | null;
|
||||
enable_prompt_caching?: boolean | null;
|
||||
/**
|
||||
* Boolean toggle for OpenAI/Anthropic ephemeral cache_control. For
|
||||
* Gemini the backend also accepts the cached-content resource name
|
||||
* (`cachedContents/...`) as a string, which is forwarded as
|
||||
* `generationConfig.cachedContent` on the native streamGenerateContent
|
||||
* request.
|
||||
*/
|
||||
enable_prompt_caching?: boolean | string | null;
|
||||
/**
|
||||
* OpenAI shell-tool container id captured from the prior response in
|
||||
* this chat thread. When set and the Code pill is on, the backend
|
||||
|
|
@ -365,7 +397,20 @@ export interface OpenAIChatCompletionsRequest {
|
|||
|
||||
export interface OpenAIChatDelta {
|
||||
role?: string;
|
||||
content?: string;
|
||||
content?: string | null;
|
||||
/**
|
||||
* Streamed assistant tool calls. The Gemini and OpenAI Responses
|
||||
* translators emit incremental `tool_calls` deltas (function name +
|
||||
* arguments fragments) so the chat-adapter can render tool cards as
|
||||
* they arrive.
|
||||
*/
|
||||
tool_calls?: OpenAIToolCallPart[];
|
||||
/**
|
||||
* Provider-specific passthrough. Gemini ships `thoughtSignature`,
|
||||
* citations, `native_part`, etc., here so the round-trip can replay
|
||||
* them on follow-up turns without bleeding into other providers.
|
||||
*/
|
||||
extra_content?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface OpenAIChatChunkChoice {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getAuthToken } from "@/features/auth";
|
||||
import { useT } from "@/i18n";
|
||||
import { toastError, toastSuccess } from "@/shared/toast";
|
||||
import { Camera } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
|
|
@ -37,6 +38,7 @@ function readPersistedProfile(): { displayName: string; avatarDataUrl: string |
|
|||
}
|
||||
|
||||
export function ProfilePersonalizationPanel() {
|
||||
const t = useT();
|
||||
const displayName = useUserProfileStore((s) => s.displayName);
|
||||
const avatarDataUrl = useUserProfileStore((s) => s.avatarDataUrl);
|
||||
const setDisplayName = useUserProfileStore((s) => s.setDisplayName);
|
||||
|
|
@ -60,11 +62,11 @@ export function ProfilePersonalizationPanel() {
|
|||
setDisplayName(trimmed);
|
||||
const persisted = readPersistedProfile();
|
||||
if (persisted && persisted.displayName === trimmed) {
|
||||
toastSuccess("Profile name saved");
|
||||
toastSuccess(t("settings.profile.nameSaved"));
|
||||
} else {
|
||||
toastError(
|
||||
"Could not persist profile name",
|
||||
"Name updated for this session, but may not persist after reload.",
|
||||
t("settings.profile.namePersistErrorTitle"),
|
||||
t("settings.profile.namePersistErrorDescription"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -78,17 +80,18 @@ export function ProfilePersonalizationPanel() {
|
|||
setAvatarDataUrl(dataUrl);
|
||||
const persisted = readPersistedProfile();
|
||||
if (persisted && persisted.avatarDataUrl === dataUrl) {
|
||||
toastSuccess("Profile photo updated");
|
||||
toastSuccess(t("settings.profile.photoUpdated"));
|
||||
} else {
|
||||
toastError(
|
||||
"Could not persist profile photo",
|
||||
"Photo updated for this session, but may not persist after reload.",
|
||||
t("settings.profile.photoPersistErrorTitle"),
|
||||
t("settings.profile.photoPersistErrorDescription"),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Could not use this image.";
|
||||
const message =
|
||||
e instanceof Error ? e.message : t("settings.profile.imageUseError");
|
||||
setImageError(message);
|
||||
toastError("Could not update profile photo", message);
|
||||
toastError(t("settings.profile.photoUpdateErrorTitle"), message);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -115,7 +118,7 @@ export function ProfilePersonalizationPanel() {
|
|||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute right-0 bottom-0 -translate-x-[15.625%] -translate-y-[15.625%] flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
aria-label="Change profile picture"
|
||||
aria-label={t("settings.profile.changePicture")}
|
||||
>
|
||||
<Camera className="size-3.5" strokeWidth={2} />
|
||||
</button>
|
||||
|
|
@ -123,7 +126,7 @@ export function ProfilePersonalizationPanel() {
|
|||
|
||||
<div className="flex w-full max-w-[560px] flex-col gap-2">
|
||||
<Label htmlFor="profile-display-name" className="text-xs font-medium text-muted-foreground">
|
||||
Display name
|
||||
{t("settings.profile.displayName")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
|
|
@ -142,7 +145,7 @@ export function ProfilePersonalizationPanel() {
|
|||
className="h-10 min-w-0 flex-1 rounded-full text-sm"
|
||||
/>
|
||||
<Button type="button" size="sm" className="h-10 px-5" onClick={saveName} disabled={!hasNameChanges}>
|
||||
Save
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,30 +14,39 @@ import {
|
|||
MoreHorizontalIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useT } from "@/i18n";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import type { ApiKey } from "../api/api-keys";
|
||||
|
||||
function relative(iso: string | null): string {
|
||||
if (!iso) return "never";
|
||||
type SettingsT = ReturnType<typeof useT>;
|
||||
|
||||
function relative(iso: string | null, t: SettingsT): string {
|
||||
if (!iso) return t("settings.apiKeys.relativeNever");
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (days < 1) {
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
if (hours < 1) return "just now";
|
||||
return `${hours}h ago`;
|
||||
if (hours < 1) return t("settings.apiKeys.relativeJustNow");
|
||||
return t("settings.apiKeys.relativeHoursAgo", { count: hours });
|
||||
}
|
||||
if (days < 30) return `${days}d ago`;
|
||||
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
|
||||
return `${Math.floor(days / 365)}y ago`;
|
||||
if (days < 30) return t("settings.apiKeys.relativeDaysAgo", { count: days });
|
||||
if (days < 365) {
|
||||
return t("settings.apiKeys.relativeMonthsAgo", {
|
||||
count: Math.floor(days / 30),
|
||||
});
|
||||
}
|
||||
return t("settings.apiKeys.relativeYearsAgo", {
|
||||
count: Math.floor(days / 365),
|
||||
});
|
||||
}
|
||||
|
||||
function expiresText(iso: string | null): string {
|
||||
if (!iso) return "never";
|
||||
function expiresText(iso: string | null, t: SettingsT): string {
|
||||
if (!iso) return t("settings.apiKeys.relativeNever");
|
||||
const diff = new Date(iso).getTime() - Date.now();
|
||||
if (diff < 0) return "expired";
|
||||
if (diff < 0) return t("settings.apiKeys.expired");
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (days < 1) return "today";
|
||||
return `in ${days}d`;
|
||||
if (days < 1) return t("settings.apiKeys.today");
|
||||
return t("settings.apiKeys.inDays", { count: days });
|
||||
}
|
||||
|
||||
export function ApiKeyRow({
|
||||
|
|
@ -47,6 +56,7 @@ export function ApiKeyRow({
|
|||
apiKey: ApiKey;
|
||||
onRevoke: (key: ApiKey) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const prefix = `sk-unsloth-${apiKey.key_prefix}…`;
|
||||
return (
|
||||
<div className="group flex items-center gap-3 border-b border-border/60 px-1 py-3 last:border-b-0 transition-colors hover:bg-accent/40">
|
||||
|
|
@ -64,11 +74,23 @@ export function ApiKeyRow({
|
|||
</code>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-1.5 text-[11px] text-muted-foreground">
|
||||
<span>Created {relative(apiKey.created_at)}</span>
|
||||
<span>
|
||||
{t("settings.apiKeys.created", {
|
||||
value: relative(apiKey.created_at, t),
|
||||
})}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>Used {relative(apiKey.last_used_at)}</span>
|
||||
<span>
|
||||
{t("settings.apiKeys.used", {
|
||||
value: relative(apiKey.last_used_at, t),
|
||||
})}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>Expires {expiresText(apiKey.expires_at)}</span>
|
||||
<span>
|
||||
{t("settings.apiKeys.expires", {
|
||||
value: expiresText(apiKey.expires_at, t),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
|
|
@ -77,7 +99,7 @@ export function ApiKeyRow({
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-7 p-0 opacity-0 transition-opacity group-hover:opacity-100 data-[state=open]:opacity-100 max-sm:!opacity-100 max-sm:size-9"
|
||||
aria-label={`Actions for ${apiKey.name}`}
|
||||
aria-label={t("settings.apiKeys.actionsFor", { name: apiKey.name })}
|
||||
>
|
||||
<HugeiconsIcon icon={MoreHorizontalIcon} className="size-4" />
|
||||
</Button>
|
||||
|
|
@ -85,14 +107,14 @@ export function ApiKeyRow({
|
|||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={async () => { await copyToClipboard(prefix); }}>
|
||||
<HugeiconsIcon icon={Copy01Icon} className="size-3.5 mr-2" />
|
||||
Copy prefix
|
||||
{t("settings.apiKeys.copyPrefix")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onRevoke(apiKey)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-3.5 mr-2" />
|
||||
Revoke token
|
||||
{t("settings.apiKeys.revokeToken")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useT } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { createApiKey } from "../api/api-keys";
|
||||
|
|
@ -21,6 +22,7 @@ export function CreateKeyForm({
|
|||
onCreated: (rawKey: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [name, setName] = useState("");
|
||||
const [expiry, setExpiry] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -33,8 +35,11 @@ export function CreateKeyForm({
|
|||
const result = await createApiKey(name.trim(), expiry);
|
||||
onCreated(result.key);
|
||||
setName("");
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Couldn't create access token.");
|
||||
} catch {
|
||||
// API helpers in ../api/api-keys.ts throw generic English Error
|
||||
// messages; always use the translated message so zh-CN users do not
|
||||
// see English text bleed through from internal exceptions.
|
||||
onError(t("settings.apiKeys.createError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -49,9 +54,9 @@ export function CreateKeyForm({
|
|||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Token name (e.g. production)"
|
||||
placeholder={t("settings.apiKeys.tokenNamePlaceholder")}
|
||||
className="h-8 min-w-[180px] flex-1 text-sm"
|
||||
aria-label="New access token name"
|
||||
aria-label={t("settings.apiKeys.newAccessTokenName")}
|
||||
/>
|
||||
<div className="inline-flex items-center rounded-md border border-border bg-background p-0.5">
|
||||
{EXPIRY_PRESETS.map((p) => {
|
||||
|
|
@ -69,13 +74,15 @@ export function CreateKeyForm({
|
|||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
{p.value === null ? t("settings.apiKeys.never") : p.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={loading || !name.trim()}>
|
||||
{loading ? "Creating…" : "Create token"}
|
||||
{loading
|
||||
? t("settings.apiKeys.creating")
|
||||
: t("settings.apiKeys.createToken")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useT } from "@/i18n";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
|
|
@ -15,6 +16,7 @@ export function KeyRevealCard({
|
|||
rawKey: string;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
|
|
@ -32,7 +34,7 @@ export function KeyRevealCard({
|
|||
className="size-3.5 text-emerald-600 dark:text-emerald-500"
|
||||
/>
|
||||
<span className="text-xs font-medium text-emerald-700 dark:text-emerald-500">
|
||||
New access token created
|
||||
{t("settings.apiKeys.newTokenCreated")}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -43,7 +45,11 @@ export function KeyRevealCard({
|
|||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
||||
copied && "border-emerald-500/40 bg-emerald-500/10",
|
||||
)}
|
||||
aria-label={copied ? "Access token copied" : "Copy access token"}
|
||||
aria-label={
|
||||
copied
|
||||
? t("settings.apiKeys.accessTokenCopied")
|
||||
: t("settings.apiKeys.copyAccessToken")
|
||||
}
|
||||
>
|
||||
<code className="min-w-0 flex-1 break-all text-left text-foreground">
|
||||
{rawKey}
|
||||
|
|
@ -55,7 +61,7 @@ export function KeyRevealCard({
|
|||
</button>
|
||||
<div className="flex items-center justify-between gap-3 pt-0.5">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Copy now — this won't be shown again.
|
||||
{t("settings.apiKeys.copyNow")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -63,7 +69,7 @@ export function KeyRevealCard({
|
|||
onClick={onDone}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
|
||||
>
|
||||
Done
|
||||
{t("common.done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
// 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
LOCALES,
|
||||
isSupportedLocale,
|
||||
setLocale,
|
||||
useT,
|
||||
useLocale,
|
||||
} from "@/i18n";
|
||||
|
||||
export function LanguageSelect() {
|
||||
const t = useT();
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={locale}
|
||||
onValueChange={(value) => {
|
||||
if (isSupportedLocale(value)) setLocale(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("settings.appearance.language.label")}
|
||||
className="w-40"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(LOCALES).map(([value, metadata]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{metadata.nativeLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useT, type TranslationKey } from "@/i18n";
|
||||
import {
|
||||
LaptopIcon,
|
||||
Moon02Icon,
|
||||
|
|
@ -11,13 +12,18 @@ import { HugeiconsIcon } from "@hugeicons/react";
|
|||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useTheme, type Theme } from "../stores/theme-store";
|
||||
|
||||
const OPTIONS: { value: Theme; label: string; icon: typeof Sun02Icon }[] = [
|
||||
{ value: "light", label: "Light", icon: Sun02Icon },
|
||||
{ value: "dark", label: "Dark", icon: Moon02Icon },
|
||||
{ value: "system", label: "System", icon: LaptopIcon },
|
||||
const OPTIONS: {
|
||||
value: Theme;
|
||||
labelKey: TranslationKey;
|
||||
icon: typeof Sun02Icon;
|
||||
}[] = [
|
||||
{ value: "light", labelKey: "settings.appearance.theme.light", icon: Sun02Icon },
|
||||
{ value: "dark", labelKey: "settings.appearance.theme.dark", icon: Moon02Icon },
|
||||
{ value: "system", labelKey: "settings.appearance.theme.system", icon: LaptopIcon },
|
||||
];
|
||||
|
||||
export function ThemeSegmented() {
|
||||
const t = useT();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const reduced = useReducedMotion();
|
||||
return (
|
||||
|
|
@ -49,7 +55,7 @@ export function ThemeSegmented() {
|
|||
/>
|
||||
)}
|
||||
<HugeiconsIcon icon={opt.icon} className="relative z-10 size-3.5" />
|
||||
<span className="relative z-10">{opt.label}</span>
|
||||
<span className="relative z-10">{t(opt.labelKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
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 { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
|
@ -29,10 +30,13 @@ export type UpdateInstallSource =
|
|||
| "unknown";
|
||||
type UpdateInstallSourceState = UpdateInstallSource | "loading";
|
||||
|
||||
function getStudioUpdateInstructionLine(shell: UpdateShell): string {
|
||||
function getStudioUpdateInstructionLine(
|
||||
shell: UpdateShell,
|
||||
t: ReturnType<typeof useT>,
|
||||
): string {
|
||||
return shell === "windows"
|
||||
? "Open PowerShell and run:"
|
||||
: "Open Terminal and run:";
|
||||
? t("settings.about.update.openPowerShell")
|
||||
: t("settings.about.update.openTerminal");
|
||||
}
|
||||
|
||||
function isLocalInstallSource(
|
||||
|
|
@ -59,6 +63,7 @@ function CopyableCommand({
|
|||
command: string;
|
||||
copyLabel: string;
|
||||
}): ReactElement {
|
||||
const t = useT();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
|
@ -89,14 +94,26 @@ function CopyableCommand({
|
|||
value={command}
|
||||
className="min-w-0 flex-1 bg-transparent px-2 py-1.5 font-mono text-[11px] text-foreground outline-none"
|
||||
title={command}
|
||||
aria-label={`${copyLabel} text`}
|
||||
aria-label={t("settings.about.update.commandText", {
|
||||
label: copyLabel,
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex shrink-0 items-center justify-center border-l border-border px-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={copied ? "Copied" : "Copy command"}
|
||||
aria-label={copied ? `${copyLabel} copied` : `Copy ${copyLabel}`}
|
||||
title={
|
||||
copied
|
||||
? t("settings.about.update.copied")
|
||||
: t("settings.about.update.copyCommand")
|
||||
}
|
||||
aria-label={
|
||||
copied
|
||||
? t("settings.about.update.commandCopied", { label: copyLabel })
|
||||
: t("settings.about.update.copyNamedCommand", {
|
||||
label: copyLabel,
|
||||
})
|
||||
}
|
||||
>
|
||||
{copied ? (
|
||||
<HugeiconsIcon
|
||||
|
|
@ -123,7 +140,9 @@ export function UpdateStudioInstructions({
|
|||
installSource?: UpdateInstallSourceState | null;
|
||||
showTitle?: boolean;
|
||||
}): ReactElement {
|
||||
const [shell, setShell] = useState<UpdateShell>(defaultShell);
|
||||
const t = useT();
|
||||
const [shellOverride, setShellOverride] = useState<UpdateShell | null>(null);
|
||||
const shell = shellOverride ?? defaultShell;
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const windows = shell === "windows";
|
||||
const localInstallSource = isLocalInstallSource(installSource);
|
||||
|
|
@ -144,10 +163,6 @@ export function UpdateStudioInstructions({
|
|||
? { opacity: 1 }
|
||||
: { opacity: 0, y: -2 };
|
||||
|
||||
useEffect(() => {
|
||||
setShell(defaultShell);
|
||||
}, [defaultShell]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3", className)}>
|
||||
<div
|
||||
|
|
@ -158,13 +173,13 @@ export function UpdateStudioInstructions({
|
|||
>
|
||||
{showTitle ? (
|
||||
<p className="shrink-0 whitespace-nowrap text-sm font-semibold font-heading">
|
||||
Update Unsloth Studio
|
||||
{t("settings.about.update.title")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex shrink-0 items-center gap-0.5 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShell("windows")}
|
||||
onClick={() => setShellOverride("windows")}
|
||||
className={cn(
|
||||
"px-0.5 py-0.5 font-medium transition-colors",
|
||||
windows
|
||||
|
|
@ -178,7 +193,7 @@ export function UpdateStudioInstructions({
|
|||
<span className="text-border">/</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShell("unix")}
|
||||
onClick={() => setShellOverride("unix")}
|
||||
className={cn(
|
||||
"px-0.5 py-0.5 font-medium transition-colors",
|
||||
windows
|
||||
|
|
@ -193,31 +208,28 @@ export function UpdateStudioInstructions({
|
|||
</div>
|
||||
{loadingInstallSource ? (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Checking how Studio was installed…
|
||||
{t("settings.about.update.checkingInstall")}
|
||||
</p>
|
||||
) : localInstallSource ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Source or local install detected. To avoid replacing it with PyPI,
|
||||
update from the checkout or source you originally installed from.
|
||||
{t("settings.about.update.localInstallDetected")}
|
||||
</p>
|
||||
{checkoutInstallSource ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Pull latest changes from your Unsloth repo checkout, then update
|
||||
Studio locally:
|
||||
{t("settings.about.update.pullThenUpdate")}
|
||||
</p>
|
||||
<CopyableCommand
|
||||
command={STUDIO_LOCAL_PULL_CMD}
|
||||
copyLabel="git pull command"
|
||||
copyLabel={t("settings.about.update.gitPullCommand")}
|
||||
/>
|
||||
<CopyableCommand
|
||||
command={STUDIO_LOCAL_UPDATE_CMD}
|
||||
copyLabel="local update command"
|
||||
copyLabel={t("settings.about.update.localUpdateCommand")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
If the Studio update command is unavailable, run the local
|
||||
installer from that checkout:
|
||||
{t("settings.about.update.localInstallerFallback")}
|
||||
</p>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
|
|
@ -233,7 +245,7 @@ export function UpdateStudioInstructions({
|
|||
? STUDIO_LOCAL_FALLBACK_WINDOWS_CMD
|
||||
: STUDIO_LOCAL_FALLBACK_UNIX_CMD
|
||||
}
|
||||
copyLabel="local installer command"
|
||||
copyLabel={t("settings.about.update.localInstallerCommand")}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
|
@ -242,12 +254,10 @@ export function UpdateStudioInstructions({
|
|||
{packagedSourceInstall ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
This looks like a source or VCS package install. Reinstall from
|
||||
the original local path or Git URL you used.
|
||||
{t("settings.about.update.sourceInstallDetected")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
If you still have the Unsloth repo checkout, run the local
|
||||
installer from that checkout:
|
||||
{t("settings.about.update.repoCheckoutFallback")}
|
||||
</p>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
|
|
@ -263,39 +273,37 @@ export function UpdateStudioInstructions({
|
|||
? STUDIO_LOCAL_FALLBACK_WINDOWS_CMD
|
||||
: STUDIO_LOCAL_FALLBACK_UNIX_CMD
|
||||
}
|
||||
copyLabel="local installer command"
|
||||
copyLabel={t("settings.about.update.localInstallerCommand")}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Restart Studio after updating for changes to take effect.
|
||||
{t("settings.about.update.restartAfterUpdate")}
|
||||
</p>
|
||||
</>
|
||||
) : unknownInstallSource ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Studio could not detect how it was installed. Check how you
|
||||
installed Studio first, then choose the matching update path.
|
||||
{t("settings.about.update.unknownInstall")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
For curl or PyPI installs, run:
|
||||
{t("settings.about.update.curlOrPypi")}
|
||||
</p>
|
||||
<CopyableCommand
|
||||
command={STUDIO_UPDATE_CMD}
|
||||
copyLabel="update command"
|
||||
copyLabel={t("settings.about.update.updateCommand")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
For local checkout installs, update from that checkout instead and
|
||||
use the local update command:
|
||||
{t("settings.about.update.localCheckout")}
|
||||
</p>
|
||||
<CopyableCommand
|
||||
command={STUDIO_LOCAL_UPDATE_CMD}
|
||||
copyLabel="local update command"
|
||||
copyLabel={t("settings.about.update.localUpdateCommand")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Restart Studio after updating for changes to take effect.
|
||||
{t("settings.about.update.restartAfterUpdate")}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -309,15 +317,15 @@ export function UpdateStudioInstructions({
|
|||
transition={fadeTransition}
|
||||
className="text-xs text-muted-foreground leading-relaxed"
|
||||
>
|
||||
{getStudioUpdateInstructionLine(shell)}
|
||||
{getStudioUpdateInstructionLine(shell, t)}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
<CopyableCommand
|
||||
command={STUDIO_UPDATE_CMD}
|
||||
copyLabel="update command"
|
||||
copyLabel={t("settings.about.update.updateCommand")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
If that fails or unsloth studio update is unavailable, run:
|
||||
{t("settings.about.update.fallbackInstruction")}
|
||||
</p>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
|
|
@ -333,12 +341,12 @@ export function UpdateStudioInstructions({
|
|||
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
|
||||
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
|
||||
}
|
||||
copyLabel="fallback command"
|
||||
copyLabel={t("settings.about.update.fallbackCommand")}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Restart Studio after updating for changes to take effect.
|
||||
{t("settings.about.update.restartAfterUpdate")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { useT } from "@/i18n";
|
||||
import {
|
||||
ArrowUpRight01Icon,
|
||||
Copy01Icon,
|
||||
|
|
@ -78,6 +79,7 @@ for chunk in response:
|
|||
}
|
||||
|
||||
export function UsageExamples() {
|
||||
const t = useT();
|
||||
const [lang, setLang] = useState<Lang>("curl");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const snippets = useMemo(
|
||||
|
|
@ -97,17 +99,19 @@ export function UsageExamples() {
|
|||
|
||||
return (
|
||||
<section className="flex min-w-0 max-w-full flex-col">
|
||||
<h2 className="mb-2 text-sm font-semibold text-foreground">Usage examples</h2>
|
||||
<h2 className="mb-2 text-sm font-semibold text-foreground">
|
||||
{t("settings.apiKeys.usageExamples")}
|
||||
</h2>
|
||||
<div className="min-w-0 max-w-full overflow-hidden rounded-lg border border-border bg-muted/20">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 border-b border-border px-2 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-0.5">
|
||||
{TABS.map((t) => {
|
||||
const active = lang === t.id;
|
||||
{TABS.map((tab) => {
|
||||
const active = lang === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setLang(t.id)}
|
||||
onClick={() => setLang(tab.id)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
|
|
@ -116,7 +120,9 @@ export function UsageExamples() {
|
|||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
{tab.id === "tools"
|
||||
? t("settings.apiKeys.usageTools")
|
||||
: tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -125,20 +131,20 @@ export function UsageExamples() {
|
|||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Copy snippet"
|
||||
aria-label={t("settings.apiKeys.copySnippet")}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={copied ? Tick02Icon : Copy01Icon}
|
||||
className={cn("size-3.5", copied && "text-emerald-600")}
|
||||
/>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
{copied ? t("settings.apiKeys.copied") : t("settings.apiKeys.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="max-w-full overflow-x-auto whitespace-pre-wrap break-words p-3 font-mono text-[11px] leading-relaxed text-foreground">
|
||||
{snippets[lang]}
|
||||
</pre>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
|
||||
<span>Setup docs:</span>
|
||||
<span>{t("settings.apiKeys.setupDocs")}</span>
|
||||
{DOC_LINKS.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useT, type TranslationKey } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Cancel01Icon,
|
||||
|
|
@ -35,19 +36,33 @@ import { ProfileTab } from "./tabs/profile-tab";
|
|||
|
||||
interface TabDef {
|
||||
id: SettingsTab;
|
||||
label: string;
|
||||
labelKey: TranslationKey;
|
||||
icon: typeof Settings02Icon;
|
||||
badge?: string;
|
||||
badgeKey?: TranslationKey;
|
||||
}
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ id: "general", label: "General", icon: Settings02Icon },
|
||||
{ id: "profile", label: "Profile", icon: UserIcon },
|
||||
{ id: "appearance", label: "Appearance", icon: PaintBrush02Icon },
|
||||
{ id: "chat", label: "Chat", icon: Message01Icon },
|
||||
{ id: "connections", label: "Connections", icon: CloudIcon, badge: "New" },
|
||||
{ id: "api-keys", label: "API", icon: Globe02Icon, badge: "New" },
|
||||
{ id: "about", label: "Help", icon: HelpCircleIcon },
|
||||
{ id: "general", labelKey: "settings.tabs.general", icon: Settings02Icon },
|
||||
{ id: "profile", labelKey: "settings.tabs.profile", icon: UserIcon },
|
||||
{
|
||||
id: "appearance",
|
||||
labelKey: "settings.tabs.appearance",
|
||||
icon: PaintBrush02Icon,
|
||||
},
|
||||
{ id: "chat", labelKey: "settings.tabs.chat", icon: Message01Icon },
|
||||
{
|
||||
id: "connections",
|
||||
labelKey: "settings.tabs.connections",
|
||||
icon: CloudIcon,
|
||||
badgeKey: "common.new",
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
labelKey: "settings.tabs.apiKeys",
|
||||
icon: Globe02Icon,
|
||||
badgeKey: "common.new",
|
||||
},
|
||||
{ id: "about", labelKey: "settings.tabs.about", icon: HelpCircleIcon },
|
||||
];
|
||||
|
||||
function renderTab(tab: SettingsTab) {
|
||||
|
|
@ -70,6 +85,7 @@ function renderTab(tab: SettingsTab) {
|
|||
}
|
||||
|
||||
export function SettingsDialog() {
|
||||
const t = useT();
|
||||
const open = useSettingsDialogStore((s) => s.open);
|
||||
const activeTab = useSettingsDialogStore((s) => s.activeTab);
|
||||
const setActiveTab = useSettingsDialogStore((s) => s.setActiveTab);
|
||||
|
|
@ -117,9 +133,9 @@ export function SettingsDialog() {
|
|||
"max-sm:h-dvh max-sm:w-dvw max-sm:!max-w-none max-sm:rounded-none",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">Settings</DialogTitle>
|
||||
<DialogTitle className="sr-only">{t("settings.dialog.title")}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Manage your Unsloth Studio preferences.
|
||||
{t("settings.dialog.description")}
|
||||
</DialogDescription>
|
||||
<div className="flex h-full min-h-0 max-sm:flex-col">
|
||||
<aside className="font-heading flex w-[216px] shrink-0 flex-col border-r border-border bg-muted/20 p-2 max-sm:w-full max-sm:border-r-0 max-sm:border-b">
|
||||
|
|
@ -165,11 +181,11 @@ export function SettingsDialog() {
|
|||
className="relative z-10 size-icon"
|
||||
/>
|
||||
<span className="relative z-10 min-w-0 truncate">
|
||||
{tab.label}
|
||||
{t(tab.labelKey)}
|
||||
</span>
|
||||
{tab.badge ? (
|
||||
{tab.badgeKey ? (
|
||||
<span className="relative z-10 ml-auto rounded-[6px] border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
{tab.badge}
|
||||
{t(tab.badgeKey)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
|
@ -183,7 +199,7 @@ export function SettingsDialog() {
|
|||
type="button"
|
||||
onClick={closeDialog}
|
||||
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-[8px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2d2f33] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Close settings"
|
||||
aria-label={t("settings.dialog.closeAriaLabel")}
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { usePlatformStore } from "@/config/env";
|
||||
import { getAuthToken } from "@/features/auth";
|
||||
import { removeTrainingUnloadGuard } from "@/features/training";
|
||||
import { useT } from "@/i18n";
|
||||
import { apiUrl, isTauri } from "@/lib/api-base";
|
||||
import {
|
||||
ArrowUpRight01Icon,
|
||||
|
|
@ -94,6 +95,7 @@ async function fetchInstallSource(): Promise<UpdateInstallSource> {
|
|||
}
|
||||
|
||||
export function AboutTab() {
|
||||
const t = useT();
|
||||
const deviceType = usePlatformStore((s) => s.deviceType);
|
||||
const defaultShell = deviceType === "windows" ? "windows" : "unix";
|
||||
const [shutdownOpen, setShutdownOpen] = useState(false);
|
||||
|
|
@ -132,26 +134,28 @@ export function AboutTab() {
|
|||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">Help</h1>
|
||||
<h1 className="text-lg font-semibold font-heading">
|
||||
{t("settings.about.title")}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Documentation, release notes, feedback, and Studio build info.
|
||||
{t("settings.about.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SettingsSection title="Studio">
|
||||
<SettingsRow label="Studio Version">
|
||||
<SettingsRow label={t("settings.about.studioVersion")}>
|
||||
<code className="font-mono text-xs text-muted-foreground">
|
||||
{studioVersion}
|
||||
</code>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Package Version">
|
||||
<SettingsRow label={t("settings.about.packageVersion")}>
|
||||
<code className="font-mono text-xs text-muted-foreground">
|
||||
{packageVersion}
|
||||
</code>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Updates">
|
||||
<SettingsSection title={t("settings.about.updates")}>
|
||||
<div className="py-2">
|
||||
<UpdateStudioInstructions
|
||||
defaultShell={defaultShell}
|
||||
|
|
@ -161,8 +165,8 @@ export function AboutTab() {
|
|||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Help">
|
||||
<SettingsRow label="Documentation">
|
||||
<SettingsSection title={t("settings.about.help")}>
|
||||
<SettingsRow label={t("settings.about.documentation")}>
|
||||
<a
|
||||
href="https://unsloth.ai/docs"
|
||||
target="_blank"
|
||||
|
|
@ -174,7 +178,7 @@ export function AboutTab() {
|
|||
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
|
||||
</a>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Release notes">
|
||||
<SettingsRow label={t("settings.about.releaseNotes")}>
|
||||
<a
|
||||
href="https://unsloth.ai/docs/new/changelog"
|
||||
target="_blank"
|
||||
|
|
@ -182,11 +186,11 @@ export function AboutTab() {
|
|||
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={NewReleasesIcon} className="size-3.5" />
|
||||
What's new
|
||||
{t("settings.about.whatsNew")}
|
||||
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
|
||||
</a>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Feedback">
|
||||
<SettingsRow label={t("settings.about.feedback")}>
|
||||
<a
|
||||
href="https://github.com/unslothai/unsloth/issues"
|
||||
target="_blank"
|
||||
|
|
@ -197,17 +201,17 @@ export function AboutTab() {
|
|||
icon={MessageNotification01Icon}
|
||||
className="size-3.5"
|
||||
/>
|
||||
Report an issue
|
||||
{t("settings.about.reportIssue")}
|
||||
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
|
||||
</a>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Danger zone">
|
||||
<SettingsSection title={t("settings.about.dangerZone")}>
|
||||
<SettingsRow
|
||||
destructive={true}
|
||||
label="Shut down Unsloth Studio"
|
||||
description="Stops the Studio server process and ends your session."
|
||||
label={t("settings.about.shutDownStudio")}
|
||||
description={t("settings.about.shutDownStudioDescription")}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -216,7 +220,7 @@ export function AboutTab() {
|
|||
className="text-destructive hover:text-destructive hover:border-destructive/60"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-3.5 mr-1.5" />
|
||||
Shut down
|
||||
{t("settings.about.shutDown")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { translate, useT } from "@/i18n";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { fetchApiKeys, revokeApiKey, type ApiKey } from "../api/api-keys";
|
||||
|
|
@ -19,6 +20,7 @@ import { KeyRevealCard } from "../components/key-reveal-card";
|
|||
import { UsageExamples } from "../components/usage-examples";
|
||||
|
||||
export function ApiKeysTab() {
|
||||
const t = useT();
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -26,25 +28,47 @@ export function ApiKeysTab() {
|
|||
const [revoking, setRevoking] = useState(false);
|
||||
const [revealed, setRevealed] = useState<string | null>(null);
|
||||
const reduced = useReducedMotion();
|
||||
const t = reduced
|
||||
const transition = reduced
|
||||
? { duration: 0 }
|
||||
: { duration: 0.18, ease: [0.165, 0.84, 0.44, 1] as const };
|
||||
|
||||
// API helpers in ../api/api-keys.ts throw generic English Error messages
|
||||
// ("Failed to load API access", etc.). Always use the translated message
|
||||
// so zh-CN users do not see those English strings bleed through.
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setKeys(await fetchApiKeys());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn't load API access.");
|
||||
} catch {
|
||||
setError(translate("settings.apiKeys.loadError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
let cancelled = false;
|
||||
|
||||
async function loadInitialApiKeys() {
|
||||
try {
|
||||
const apiKeys = await fetchApiKeys();
|
||||
if (cancelled) return;
|
||||
setKeys(apiKeys);
|
||||
setError(null);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setError(translate("settings.apiKeys.loadError"));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void loadInitialApiKeys();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const confirmRevoke = async () => {
|
||||
if (!revokeTarget) return;
|
||||
|
|
@ -53,8 +77,8 @@ export function ApiKeysTab() {
|
|||
await revokeApiKey(revokeTarget.id);
|
||||
await load();
|
||||
setRevokeTarget(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn't revoke access token.");
|
||||
} catch {
|
||||
setError(translate("settings.apiKeys.revokeError"));
|
||||
} finally {
|
||||
setRevoking(false);
|
||||
}
|
||||
|
|
@ -63,16 +87,18 @@ export function ApiKeysTab() {
|
|||
return (
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-6">
|
||||
<header className="flex min-w-0 flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">API</h1>
|
||||
<h1 className="text-lg font-semibold font-heading">
|
||||
{t("settings.apiKeys.title")}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Access Unsloth programmatically via the OpenAI-compatible API.{" "}
|
||||
{t("settings.apiKeys.description")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/basics/api"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium text-foreground underline decoration-border underline-offset-2 transition-colors hover:decoration-foreground"
|
||||
>
|
||||
Read the API docs
|
||||
{t("settings.apiKeys.readDocs")}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
|
@ -85,7 +111,7 @@ export function ApiKeysTab() {
|
|||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={t}
|
||||
transition={transition}
|
||||
>
|
||||
<KeyRevealCard
|
||||
rawKey={revealed}
|
||||
|
|
@ -98,7 +124,7 @@ export function ApiKeysTab() {
|
|||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 4 }}
|
||||
transition={t}
|
||||
transition={transition}
|
||||
>
|
||||
<CreateKeyForm
|
||||
onCreated={(raw) => {
|
||||
|
|
@ -112,7 +138,9 @@ export function ApiKeysTab() {
|
|||
</AnimatePresence>
|
||||
|
||||
<section className="flex min-w-0 flex-col">
|
||||
<h2 className="mb-2 text-sm font-semibold text-foreground">Access tokens</h2>
|
||||
<h2 className="mb-2 text-sm font-semibold text-foreground">
|
||||
{t("settings.apiKeys.accessTokens")}
|
||||
</h2>
|
||||
{error ? (
|
||||
<div className="rounded-md border border-destructive/20 bg-destructive/5 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
|
|
@ -128,7 +156,7 @@ export function ApiKeysTab() {
|
|||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">
|
||||
No API access yet.
|
||||
{t("settings.apiKeys.noAccess")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-col">
|
||||
|
|
@ -144,21 +172,29 @@ export function ApiKeysTab() {
|
|||
<Dialog open={revokeTarget !== null} onOpenChange={(o) => !o && setRevokeTarget(null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Revoke access token “{revokeTarget?.name}”?</DialogTitle>
|
||||
<DialogTitle>
|
||||
{t("settings.apiKeys.revokeTitle", {
|
||||
name: revokeTarget?.name ?? "",
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Applications using this token will immediately lose access. This cannot be undone.
|
||||
{t("settings.apiKeys.revokeDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRevokeTarget(null)}>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmRevoke}
|
||||
disabled={revoking}
|
||||
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||||
>
|
||||
{revoking ? "Revoking…" : `Revoke “${revokeTarget?.name}”`}
|
||||
{revoking
|
||||
? t("settings.apiKeys.revoking")
|
||||
: t("settings.apiKeys.revokeAction", {
|
||||
name: revokeTarget?.name ?? "",
|
||||
})}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -3,34 +3,48 @@
|
|||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useSidebarPin } from "@/hooks/use-sidebar-pin";
|
||||
import { useT } from "@/i18n";
|
||||
import { LanguageSelect } from "../components/language-select";
|
||||
import { SettingsRow } from "../components/settings-row";
|
||||
import { SettingsSection } from "../components/settings-section";
|
||||
import { ThemeSegmented } from "../components/theme-segmented";
|
||||
|
||||
export function AppearanceTab() {
|
||||
const t = useT();
|
||||
const { pinned, setPinned } = useSidebarPin();
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">Appearance</h1>
|
||||
<h1 className="text-lg font-semibold font-heading">
|
||||
{t("settings.appearance.title")}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How Unsloth Studio looks on this device.
|
||||
{t("settings.appearance.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SettingsSection title="Theme">
|
||||
<SettingsSection title={t("settings.appearance.theme.title")}>
|
||||
<SettingsRow
|
||||
label="Color scheme"
|
||||
description="Choose light, dark, or follow your system."
|
||||
label={t("settings.appearance.theme.label")}
|
||||
description={t("settings.appearance.theme.description")}
|
||||
>
|
||||
<ThemeSegmented />
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Layout">
|
||||
<SettingsSection title={t("settings.appearance.language.title")}>
|
||||
<SettingsRow
|
||||
label="Pin sidebar by default"
|
||||
description="Keep the sidebar expanded instead of collapsing to icons."
|
||||
label={t("settings.appearance.language.label")}
|
||||
description={t("settings.appearance.language.description")}
|
||||
>
|
||||
<LanguageSelect />
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings.appearance.layout.title")}>
|
||||
<SettingsRow
|
||||
label={t("settings.appearance.layout.compactSidebar")}
|
||||
description={t("settings.appearance.layout.compactSidebarDescription")}
|
||||
>
|
||||
<Switch checked={pinned} onCheckedChange={setPinned} />
|
||||
</SettingsRow>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
countAllChats,
|
||||
downloadChatExport,
|
||||
} from "@/features/chat";
|
||||
import { useT } from "@/i18n";
|
||||
import { Delete02Icon, Download02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
|
@ -23,6 +24,7 @@ import { SettingsRow } from "../components/settings-row";
|
|||
import { SettingsSection } from "../components/settings-section";
|
||||
|
||||
export function ChatTab() {
|
||||
const t = useT();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [count, setCount] = useState<number | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
|
@ -53,8 +55,10 @@ export function ChatTab() {
|
|||
setConfirmOpen(false);
|
||||
toast.success(
|
||||
clearedCount === 0
|
||||
? "Cleared all chats"
|
||||
: `Cleared ${clearedCount} chat${clearedCount === 1 ? "" : "s"}`,
|
||||
? t("settings.chat.clearedAllChats")
|
||||
: clearedCount === 1
|
||||
? t("settings.chat.clearedOneChat")
|
||||
: t("settings.chat.clearedChatCount", { count: clearedCount }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -66,20 +70,29 @@ export function ChatTab() {
|
|||
const remaining = await countAllChats().catch(() => fallbackRemaining);
|
||||
setCount(remaining);
|
||||
setConfirmOpen(false);
|
||||
toast.warning("Some chats could not be cleared", {
|
||||
toast.warning(t("settings.chat.someChatsCouldNotBeCleared"), {
|
||||
description:
|
||||
result.failedThreadIds.length > 0
|
||||
? `${clearedCount} chat${clearedCount === 1 ? "" : "s"} cleared; ${
|
||||
result.failedThreadIds.length
|
||||
} chat${result.failedThreadIds.length === 1 ? "" : "s"} remain. Please retry.`
|
||||
: `A storage clear failed; ${remaining} chat${
|
||||
remaining === 1 ? "" : "s"
|
||||
} may remain. Please retry.`,
|
||||
? clearedCount === 1 && result.failedThreadIds.length === 1
|
||||
? t("settings.chat.oneChatClearedRemainOne")
|
||||
: clearedCount === 1
|
||||
? t("settings.chat.oneChatClearedRemain", {
|
||||
remainingCount: result.failedThreadIds.length,
|
||||
})
|
||||
: result.failedThreadIds.length === 1
|
||||
? t("settings.chat.chatsClearedRemainOne", { clearedCount })
|
||||
: t("settings.chat.chatsClearedRemain", {
|
||||
clearedCount,
|
||||
remainingCount: result.failedThreadIds.length,
|
||||
})
|
||||
: remaining === 1
|
||||
? t("settings.chat.storageClearFailedOne")
|
||||
: t("settings.chat.storageClearFailed", { count: remaining }),
|
||||
});
|
||||
} catch (error) {
|
||||
const remaining = await countAllChats().catch(() => count);
|
||||
setCount(remaining);
|
||||
toast.error("Failed to clear chats", {
|
||||
toast.error(t("settings.chat.failedToClearChats"), {
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -90,16 +103,18 @@ export function ChatTab() {
|
|||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">Chat</h1>
|
||||
<h1 className="text-lg font-semibold font-heading">
|
||||
{t("settings.chat.title")}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Manage your chat history stored on this device.
|
||||
{t("settings.chat.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SettingsSection title="Data">
|
||||
<SettingsSection title={t("settings.chat.data")}>
|
||||
<SettingsRow
|
||||
label="Export chat history"
|
||||
description="Download all chats and messages as a JSON file."
|
||||
label={t("settings.chat.exportHistory")}
|
||||
description={t("settings.chat.exportHistoryDescription")}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -108,19 +123,23 @@ export function ChatTab() {
|
|||
disabled={exporting || count === 0}
|
||||
>
|
||||
<HugeiconsIcon icon={Download02Icon} className="size-3.5 mr-1.5" />
|
||||
{exporting ? "Exporting…" : "Export"}
|
||||
{exporting
|
||||
? t("settings.chat.exportingAction")
|
||||
: t("settings.chat.exportAction")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
destructive
|
||||
label="Clear all chats"
|
||||
label={t("settings.chat.clearAllChats")}
|
||||
description={
|
||||
count === null
|
||||
? "Permanently delete every chat on this device."
|
||||
? t("settings.chat.clearAllChatsDescription")
|
||||
: count === 0
|
||||
? "No chats to clear."
|
||||
: `Permanently delete all ${count} chat${count === 1 ? "" : "s"} on this device.`
|
||||
? t("settings.chat.noChatsToClear")
|
||||
: count === 1
|
||||
? t("settings.chat.clearOneChatDescription")
|
||||
: t("settings.chat.clearChatCountDescription", { count })
|
||||
}
|
||||
>
|
||||
<Button
|
||||
|
|
@ -131,7 +150,7 @@ export function ChatTab() {
|
|||
className="text-destructive hover:text-destructive hover:border-destructive/60"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-3.5 mr-1.5" />
|
||||
Clear chats
|
||||
{t("settings.chat.clearChatsAction")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
|
@ -140,16 +159,17 @@ export function ChatTab() {
|
|||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Clear {count ?? 0} chat{count === 1 ? "" : "s"}?
|
||||
{count === 1
|
||||
? t("settings.chat.clearOneChatTitle")
|
||||
: t("settings.chat.clearChatsTitle", { count: count ?? 0 })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This permanently deletes every chat and message stored on this
|
||||
device. This cannot be undone.
|
||||
{t("settings.chat.clearChatsConfirmDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClear}
|
||||
|
|
@ -157,8 +177,12 @@ export function ChatTab() {
|
|||
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||||
>
|
||||
{clearing
|
||||
? "Clearing…"
|
||||
: `Clear ${count ?? 0} chat${count === 1 ? "" : "s"}`}
|
||||
? t("settings.chat.clearingAction")
|
||||
: count === 1
|
||||
? t("settings.chat.clearOneChatAction")
|
||||
: t("settings.chat.clearChatCountAction", {
|
||||
count: count ?? 0,
|
||||
})}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -14,8 +14,9 @@ import { Input } from "@/components/ui/input";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import { resetOnboardingDone } from "@/features/auth";
|
||||
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
|
||||
import { useChatRuntimeStore } from "@/features/chat";
|
||||
import { useSettingsDialogStore } from "@/features/settings";
|
||||
import { LOCALE_STORAGE_KEY, useT } from "@/i18n";
|
||||
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
|
@ -35,6 +36,7 @@ import { SettingsSection } from "../components/settings-section";
|
|||
const PREFS_KEYS: string[] = [
|
||||
// Appearance
|
||||
"theme",
|
||||
LOCALE_STORAGE_KEY,
|
||||
// UI state
|
||||
"sidebar_pinned",
|
||||
"unsloth_sidebar_navigate_open",
|
||||
|
|
@ -81,6 +83,7 @@ function resetAllPrefs() {
|
|||
}
|
||||
|
||||
export function GeneralTab() {
|
||||
const t = useT();
|
||||
const navigate = useNavigate();
|
||||
const closeDialog = useSettingsDialogStore((s) => s.closeDialog);
|
||||
const { pathname, search } = useRouterState({
|
||||
|
|
@ -132,16 +135,18 @@ export function GeneralTab() {
|
|||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">General</h1>
|
||||
<h1 className="text-lg font-semibold font-heading">
|
||||
{t("settings.general.title")}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Global preferences for Unsloth Studio.
|
||||
{t("settings.general.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SettingsSection title="Account">
|
||||
<SettingsSection title={t("settings.general.account")}>
|
||||
<SettingsRow
|
||||
label="Hugging Face token"
|
||||
description="Used to load gated models and push artifacts."
|
||||
label={t("settings.general.huggingFaceToken")}
|
||||
description={t("settings.general.huggingFaceTokenDescription")}
|
||||
>
|
||||
<div className="relative w-[260px]">
|
||||
<Input
|
||||
|
|
@ -156,7 +161,11 @@ export function GeneralTab() {
|
|||
type="button"
|
||||
onClick={() => setShowToken((s) => !s)}
|
||||
className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={showToken ? "Hide token" : "Show token"}
|
||||
aria-label={
|
||||
showToken
|
||||
? t("settings.general.hideToken")
|
||||
: t("settings.general.showToken")
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showToken ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
||||
|
|
@ -165,20 +174,20 @@ export function GeneralTab() {
|
|||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Chat defaults">
|
||||
<SettingsSection title={t("settings.general.chatDefaults")}>
|
||||
<SettingsRow
|
||||
label="Auto-title new chats"
|
||||
description="Generate a short title from the first message."
|
||||
label={t("settings.general.autoTitleNewChats")}
|
||||
description={t("settings.general.autoTitleNewChatsDescription")}
|
||||
>
|
||||
<Switch checked={autoTitle} onCheckedChange={setAutoTitle} />
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
{!chatOnly && (
|
||||
<SettingsSection title="Getting started">
|
||||
<SettingsSection title={t("settings.general.gettingStarted")}>
|
||||
<SettingsRow
|
||||
label="Start onboarding"
|
||||
description="Open the setup wizard again without changing your account."
|
||||
label={t("settings.general.startOnboarding")}
|
||||
description={t("settings.general.startOnboardingDescription")}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -189,17 +198,17 @@ export function GeneralTab() {
|
|||
navigate({ to: "/onboarding", search: { redirectTo } });
|
||||
}}
|
||||
>
|
||||
Start onboarding
|
||||
{t("settings.general.startOnboardingAction")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection title="Danger zone">
|
||||
<SettingsSection title={t("settings.general.resetPreferences.sectionTitle")}>
|
||||
<SettingsRow
|
||||
destructive
|
||||
label="Reset all local preferences"
|
||||
description="Clears local-only preferences. Chats, API access, and DB-backed chat settings are not affected."
|
||||
label={t("settings.general.resetPreferences.label")}
|
||||
description={t("settings.general.resetPreferences.description")}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -207,7 +216,7 @@ export function GeneralTab() {
|
|||
onClick={() => setConfirmOpen(true)}
|
||||
className="text-destructive hover:text-destructive hover:border-destructive/60"
|
||||
>
|
||||
Reset preferences
|
||||
{t("settings.general.resetPreferences.action")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
|
@ -215,21 +224,22 @@ export function GeneralTab() {
|
|||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset all local preferences?</DialogTitle>
|
||||
<DialogTitle>
|
||||
{t("settings.general.resetPreferences.confirmTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This clears local-only preferences, then reloads Studio. Chats,
|
||||
API access, and DB-backed chat settings are not affected.
|
||||
{t("settings.general.resetPreferences.confirmDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={resetAllPrefs}
|
||||
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||||
>
|
||||
Reset and reload
|
||||
{t("settings.general.resetPreferences.confirmAction")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -2,14 +2,19 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { ProfilePersonalizationPanel } from "@/features/profile";
|
||||
import { useT } from "@/i18n";
|
||||
|
||||
export function ProfileTab() {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">Profile</h1>
|
||||
<h1 className="text-lg font-semibold font-heading">
|
||||
{t("settings.profile.title")}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Update how your profile appears in Studio.
|
||||
{t("settings.profile.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,18 @@ import { parseBackendTrainingMethod } from "@/features/training/lib/training-met
|
|||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import { ChartsSection } from "./sections/charts-section";
|
||||
import { ProgressSection } from "./sections/progress-section";
|
||||
import { translate, useT } from "@/i18n";
|
||||
|
||||
type StudioT = ReturnType<typeof useT>;
|
||||
|
||||
interface HistoricalTrainingViewProps {
|
||||
runId: string;
|
||||
}
|
||||
|
||||
function mapToViewData(detail: TrainingRunDetailResponse): TrainingViewData {
|
||||
function mapToViewData(
|
||||
detail: TrainingRunDetailResponse,
|
||||
t: StudioT,
|
||||
): TrainingViewData {
|
||||
const { run, metrics } = detail;
|
||||
|
||||
const lossHistory = metrics.loss_step_history
|
||||
|
|
@ -62,12 +68,12 @@ function mapToViewData(detail: TrainingRunDetailResponse): TrainingViewData {
|
|||
evalEnabled: evalLossHistory.length > 0,
|
||||
message:
|
||||
run.status === "completed"
|
||||
? "Training completed"
|
||||
? t("studio.history.message.completed")
|
||||
: run.status === "stopped"
|
||||
? "Training stopped"
|
||||
? t("studio.history.message.stopped")
|
||||
: run.status === "running"
|
||||
? "Training in progress"
|
||||
: run.error_message ?? "Training errored",
|
||||
? t("studio.history.message.running")
|
||||
: run.error_message ?? t("studio.history.message.errored"),
|
||||
error: run.status === "error" ? run.error_message : null,
|
||||
isTrainingRunning: false,
|
||||
modelName: run.display_name ?? run.model_name,
|
||||
|
|
@ -85,6 +91,7 @@ function mapToViewData(detail: TrainingRunDetailResponse): TrainingViewData {
|
|||
export function HistoricalTrainingView({
|
||||
runId,
|
||||
}: HistoricalTrainingViewProps): ReactElement {
|
||||
const t = useT();
|
||||
const [detail, setDetail] = useState<TrainingRunDetailResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -99,7 +106,11 @@ export function HistoricalTrainingView({
|
|||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load run");
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translate("studio.history.loadingRun"),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
controller.abort();
|
||||
|
|
@ -120,7 +131,7 @@ export function HistoricalTrainingView({
|
|||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-8 text-sm text-muted-foreground">
|
||||
Loading training run...
|
||||
{t("studio.history.loadingRun")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -128,12 +139,12 @@ export function HistoricalTrainingView({
|
|||
if (error || !detail) {
|
||||
return (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-8 text-sm text-red-500">
|
||||
{error ?? "Run not found"}
|
||||
{error ?? t("studio.history.runNotFound")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const viewData = mapToViewData(detail);
|
||||
const viewData = mapToViewData(detail, t);
|
||||
const configOverride = detail.config
|
||||
? {
|
||||
epochs: detail.config.num_epochs as number | undefined,
|
||||
|
|
|
|||
|
|
@ -29,40 +29,46 @@ import { Delete02Icon } from "@hugeicons/core-free-icons";
|
|||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { translate, useT } from "@/i18n";
|
||||
|
||||
type StudioT = ReturnType<typeof useT>;
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
const RUNNING_POLL_INTERVAL_MS = 5000;
|
||||
|
||||
const statusBadge: Record<
|
||||
string,
|
||||
{ label: string; className: string }
|
||||
{ className: string }
|
||||
> = {
|
||||
completed: {
|
||||
label: "Completed",
|
||||
className:
|
||||
"bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400",
|
||||
},
|
||||
stopped: {
|
||||
label: "Stopped",
|
||||
className:
|
||||
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400",
|
||||
},
|
||||
error: {
|
||||
label: "Error",
|
||||
className: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
|
||||
},
|
||||
running: {
|
||||
label: "Running",
|
||||
className:
|
||||
"bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400",
|
||||
},
|
||||
resumed_later: {
|
||||
label: "Continued",
|
||||
className:
|
||||
"bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400",
|
||||
},
|
||||
};
|
||||
|
||||
function formatStatusLabel(status: string, t: StudioT): string {
|
||||
if (status === "completed") return t("studio.history.status.completed");
|
||||
if (status === "stopped") return t("studio.history.status.stopped");
|
||||
if (status === "running") return t("studio.history.status.running");
|
||||
if (status === "resumed_later") return t("studio.history.status.continued");
|
||||
return t("studio.history.status.error");
|
||||
}
|
||||
|
||||
function wasContinuedInVisibleRuns(
|
||||
run: TrainingRunSummary,
|
||||
runs: TrainingRunSummary[],
|
||||
|
|
@ -97,7 +103,15 @@ function catmullRomPath(points: { x: number; y: number }[]): string {
|
|||
return d.join(" ");
|
||||
}
|
||||
|
||||
function Sparkline({ values, id }: { values: number[]; id: string }): ReactElement | null {
|
||||
function Sparkline({
|
||||
values,
|
||||
id,
|
||||
ariaLabel,
|
||||
}: {
|
||||
values: number[];
|
||||
id: string;
|
||||
ariaLabel: string;
|
||||
}): ReactElement | null {
|
||||
if (!values || values.length < 2) return null;
|
||||
let min = values[0]!;
|
||||
let max = values[0]!;
|
||||
|
|
@ -123,7 +137,7 @@ function Sparkline({ values, id }: { values: number[]; id: string }): ReactEleme
|
|||
const fillPath = `${linePath} L${last.x.toFixed(1)},${h} L${first.x.toFixed(1)},${h} Z`;
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className="h-8 w-full" preserveAspectRatio="none" role="img" aria-label="Loss trend sparkline">
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className="h-8 w-full" preserveAspectRatio="none" role="img" aria-label={ariaLabel}>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity="0.12" />
|
||||
|
|
@ -148,15 +162,15 @@ function Sparkline({ values, id }: { values: number[]; id: string }): ReactEleme
|
|||
);
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoDate: string): string {
|
||||
function formatRelativeTime(isoDate: string, t: StudioT): string {
|
||||
const diff = Date.now() - new Date(isoDate).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
if (mins < 1) return t("studio.history.relativeJustNow");
|
||||
if (mins < 60) return t("studio.history.relativeMinutesAgo", { count: mins });
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
if (hrs < 24) return t("studio.history.relativeHoursAgo", { count: hrs });
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
return t("studio.history.relativeDaysAgo", { count: days });
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -169,6 +183,7 @@ export function HistoryCardGrid({
|
|||
onSelectRun,
|
||||
onResumeStarted,
|
||||
}: HistoryCardGridProps): ReactElement {
|
||||
const t = useT();
|
||||
const [runs, setRuns] = useState<TrainingRunSummary[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -209,7 +224,7 @@ export function HistoryCardGrid({
|
|||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
if (fetchIdRef.current !== id) return;
|
||||
if (!append) setError("Failed to load training runs");
|
||||
if (!append) setError(translate("studio.history.loadError"));
|
||||
} finally {
|
||||
if (fetchIdRef.current === id) {
|
||||
setLoading(false);
|
||||
|
|
@ -286,7 +301,7 @@ export function HistoryCardGrid({
|
|||
// Refresh failed — card is already removed, no stale display
|
||||
});
|
||||
} catch {
|
||||
setDeleteError("Failed to delete training run. Please try again.");
|
||||
setDeleteError(translate("studio.history.deleteError"));
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
|
@ -305,10 +320,13 @@ export function HistoryCardGrid({
|
|||
|
||||
if (!loading && error && runs.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-16 text-center">
|
||||
<div
|
||||
className="flex flex-col items-center gap-2 py-16 text-center"
|
||||
aria-label={t("studio.history.title")}
|
||||
>
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void fetchRuns(0)}>
|
||||
Retry
|
||||
{t("studio.history.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -316,17 +334,19 @@ export function HistoryCardGrid({
|
|||
|
||||
if (!loading && runs.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-16 text-center">
|
||||
<div
|
||||
className="flex flex-col items-center gap-2 py-16 text-center"
|
||||
aria-label={t("studio.history.title")}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No training runs yet. Start your first training run in the Configure
|
||||
tab.
|
||||
{t("studio.history.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="contents" aria-label={t("studio.history.title")}>
|
||||
{deleteError && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||
{deleteError}
|
||||
|
|
@ -370,10 +390,10 @@ export function HistoryCardGrid({
|
|||
)}
|
||||
>
|
||||
{isRunning && <Spinner className="size-2.5" />}
|
||||
{badge.label}
|
||||
{formatStatusLabel(wasContinued ? "resumed_later" : run.status, t)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatRelativeTime(run.started_at)}
|
||||
{formatRelativeTime(run.started_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
{canResume && (
|
||||
|
|
@ -388,7 +408,7 @@ export function HistoryCardGrid({
|
|||
void handleResume(run.id);
|
||||
}}
|
||||
>
|
||||
{isResuming ? "Resuming..." : "Resume training"}
|
||||
{isResuming ? t("studio.history.resuming") : t("studio.history.resumeTraining")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
|
|
@ -415,16 +435,20 @@ export function HistoryCardGrid({
|
|||
</div>
|
||||
{run.loss_sparkline && run.loss_sparkline.length >= 2 && (
|
||||
<div className={cn(canResume && "h-7 overflow-hidden")}>
|
||||
<Sparkline values={run.loss_sparkline} id={run.id} />
|
||||
<Sparkline
|
||||
values={run.loss_sparkline}
|
||||
id={run.id}
|
||||
ariaLabel={t("studio.history.lossTrendSparkline")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
Loss:{" "}
|
||||
{t("studio.history.loss")}:{" "}
|
||||
{run.final_loss != null ? run.final_loss.toFixed(4) : "--"}
|
||||
</span>
|
||||
<span>
|
||||
Steps: {run.final_step ?? 0}/{run.total_steps ?? "--"}
|
||||
{t("studio.history.steps")}: {run.final_step ?? 0}/{run.total_steps ?? "--"}
|
||||
</span>
|
||||
<span>{formatDuration(run.duration_seconds)}</span>
|
||||
</div>
|
||||
|
|
@ -432,7 +456,7 @@ export function HistoryCardGrid({
|
|||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-3 rounded-md p-1 text-muted-foreground/50 opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100 focus-visible:opacity-100"
|
||||
aria-label="Delete run"
|
||||
aria-label={t("studio.history.deleteRun")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(run.id);
|
||||
|
|
@ -453,7 +477,7 @@ export function HistoryCardGrid({
|
|||
onClick={() => void fetchRuns(runs.length, true)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Loading..." : "Load more"}
|
||||
{loading ? t("studio.history.loading") : t("studio.history.loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -475,23 +499,22 @@ export function HistoryCardGrid({
|
|||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete training run?</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("studio.history.deleteTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this training run and all its metrics.
|
||||
This action cannot be undone.
|
||||
{t("studio.history.deleteDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => void handleDelete()}
|
||||
>
|
||||
Delete
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from "@/components/ui/sheet";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useT } from "@/i18n";
|
||||
import { Settings02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { type ReactElement, useState } from "react";
|
||||
|
|
@ -82,25 +83,29 @@ function ScaleSection({
|
|||
outlierMode: OutlierMode;
|
||||
setOutlierMode: (value: OutlierMode) => void;
|
||||
}): ReactElement {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="text-xs text-muted-foreground">Scale and cleanup</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("studio.charts.scaleAndCleanup")}
|
||||
</p>
|
||||
</div>
|
||||
<ChoiceButtons
|
||||
options={[
|
||||
{ label: "Linear", value: "linear" },
|
||||
{ label: "Log", value: "log" },
|
||||
{ label: t("studio.charts.linear"), value: "linear" },
|
||||
{ label: t("studio.charts.log"), value: "log" },
|
||||
]}
|
||||
value={scale}
|
||||
onChange={setScale}
|
||||
/>
|
||||
<ChoiceButtons
|
||||
options={[
|
||||
{ label: "No clip", value: "none" },
|
||||
{ label: "Clip p99", value: "p99" },
|
||||
{ label: "Clip p95", value: "p95" },
|
||||
{ label: t("studio.charts.noClip"), value: "none" },
|
||||
{ label: t("studio.charts.clipP99"), value: "p99" },
|
||||
{ label: t("studio.charts.clipP95"), value: "p95" },
|
||||
]}
|
||||
value={outlierMode}
|
||||
onChange={setOutlierMode}
|
||||
|
|
@ -110,6 +115,7 @@ function ScaleSection({
|
|||
}
|
||||
|
||||
export function ChartSettingsSheet(): ReactElement {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const {
|
||||
availableSteps,
|
||||
|
|
@ -181,7 +187,7 @@ export function ChartSettingsSheet(): ReactElement {
|
|||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open chart settings"
|
||||
aria-label={t("studio.charts.openSettings")}
|
||||
>
|
||||
<HugeiconsIcon icon={Settings02Icon} className="size-4" />
|
||||
</Button>
|
||||
|
|
@ -191,24 +197,26 @@ export function ChartSettingsSheet(): ReactElement {
|
|||
overlayClassName="bg-transparent backdrop-blur-0"
|
||||
>
|
||||
<SheetHeader className="pb-4">
|
||||
<SheetTitle>Chart Settings</SheetTitle>
|
||||
<SheetTitle>{t("studio.charts.settings")}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Tune chart presentation while training keeps running.
|
||||
{t("studio.charts.settingsDescription")}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 space-y-6 overflow-y-auto px-6 pb-6">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">View window</p>
|
||||
<p className="text-sm font-medium">
|
||||
{t("studio.charts.viewWindow")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Show latest steps only or the full history.
|
||||
{t("studio.charts.viewWindowDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Window</span>
|
||||
<span>{t("studio.charts.window")}</span>
|
||||
<span className="tabular-nums">
|
||||
{showingAll ? "All" : effectiveWindowSize}
|
||||
{showingAll ? t("studio.charts.all") : effectiveWindowSize}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
|
|
@ -224,14 +232,16 @@ export function ChartSettingsSheet(): ReactElement {
|
|||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Training loss</p>
|
||||
<p className="text-sm font-medium">
|
||||
{t("studio.charts.trainingLoss")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Control overlays and EMA smoothing.
|
||||
{t("studio.charts.trainingLossDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Smoothing</span>
|
||||
<span>{t("studio.charts.smoothing")}</span>
|
||||
<span className="tabular-nums">{smoothing.toFixed(2)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
|
|
@ -242,17 +252,17 @@ export function ChartSettingsSheet(): ReactElement {
|
|||
step={0.01}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Move right for more smoothing. `0` = raw.
|
||||
{t("studio.charts.smoothingDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<SettingRow
|
||||
label="Show raw loss"
|
||||
label={t("studio.charts.showRawLoss")}
|
||||
control={
|
||||
<Switch checked={showRaw} onCheckedChange={setShowRaw} />
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="Show smoothed loss"
|
||||
label={t("studio.charts.showSmoothedLoss")}
|
||||
control={
|
||||
<Switch
|
||||
checked={showSmoothed}
|
||||
|
|
@ -261,7 +271,7 @@ export function ChartSettingsSheet(): ReactElement {
|
|||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="Show average line"
|
||||
label={t("studio.charts.showAverageLine")}
|
||||
control={
|
||||
<Switch
|
||||
checked={showAvgLine}
|
||||
|
|
@ -272,7 +282,7 @@ export function ChartSettingsSheet(): ReactElement {
|
|||
</div>
|
||||
<Separator />
|
||||
<ScaleSection
|
||||
title="Loss axis"
|
||||
title={t("studio.charts.lossAxis")}
|
||||
scale={lossScale}
|
||||
setScale={setLossScale}
|
||||
outlierMode={lossOutlierMode}
|
||||
|
|
@ -280,7 +290,7 @@ export function ChartSettingsSheet(): ReactElement {
|
|||
/>
|
||||
<Separator />
|
||||
<ScaleSection
|
||||
title="Gradient norm axis"
|
||||
title={t("studio.charts.gradientNormAxis")}
|
||||
scale={gradScale}
|
||||
setScale={setGradScale}
|
||||
outlierMode={gradOutlierMode}
|
||||
|
|
@ -288,7 +298,7 @@ export function ChartSettingsSheet(): ReactElement {
|
|||
/>
|
||||
<Separator />
|
||||
<ScaleSection
|
||||
title="Learning rate axis"
|
||||
title={t("studio.charts.learningRateAxis")}
|
||||
scale={lrScale}
|
||||
setScale={setLrScale}
|
||||
outlierMode={lrOutlierMode}
|
||||
|
|
@ -302,10 +312,10 @@ export function ChartSettingsSheet(): ReactElement {
|
|||
size="sm"
|
||||
onClick={resetPreferences}
|
||||
>
|
||||
Reset defaults
|
||||
{t("studio.charts.resetDefaults")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={() => setOpen(false)}>
|
||||
Done
|
||||
{t("common.done")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import { useT } from "@/i18n";
|
||||
import { ChartAverageIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { ReactElement } from "react";
|
||||
|
|
@ -24,10 +25,6 @@ import {
|
|||
placeholderEvalData,
|
||||
} from "./utils";
|
||||
|
||||
const evalLossConfig = {
|
||||
loss: { label: "Eval Loss", color: "#ef4444" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function EvalLossChartCard({
|
||||
data,
|
||||
domain,
|
||||
|
|
@ -41,11 +38,16 @@ export function EvalLossChartCard({
|
|||
isTraining: boolean;
|
||||
evalEnabled: boolean;
|
||||
}): ReactElement {
|
||||
const t = useT();
|
||||
const evalLossConfig = {
|
||||
loss: { label: t("studio.charts.evalLoss"), color: "#ef4444" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
return (
|
||||
<Card data-tour="studio-eval-loss" size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className={`text-sm${data.length > 0 ? "" : " text-muted-foreground"}`}>
|
||||
Eval Loss
|
||||
{t("studio.charts.evalLoss")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -87,11 +89,13 @@ export function EvalLossChartCard({
|
|||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_value, payload) =>
|
||||
`Step ${payload?.[0]?.payload?.step ?? ""}`
|
||||
t("studio.charts.step", {
|
||||
step: payload?.[0]?.payload?.step ?? "",
|
||||
})
|
||||
}
|
||||
formatter={(_value, _name, item) => [
|
||||
formatMetric(Number(item?.payload?.loss)),
|
||||
"Eval Loss",
|
||||
t("studio.charts.evalLoss"),
|
||||
]}
|
||||
/>
|
||||
}
|
||||
|
|
@ -156,13 +160,13 @@ export function EvalLossChartCard({
|
|||
/>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{isTraining && evalEnabled
|
||||
? "Waiting for first evaluation step…"
|
||||
: "Evaluation not configured"}
|
||||
? t("studio.charts.waitingForFirstEvaluationStep")
|
||||
: t("studio.charts.evaluationNotConfigured")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
{isTraining && evalEnabled
|
||||
? "Chart will appear once eval_steps is reached"
|
||||
: "Set eval dataset & eval_steps to track eval loss"}
|
||||
? t("studio.charts.evalChartWillAppear")
|
||||
: t("studio.charts.setEvalDatasetAndSteps")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import { useT } from "@/i18n";
|
||||
import type { ReactElement } from "react";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import type { ScaleMode } from "./types";
|
||||
|
|
@ -24,10 +25,6 @@ import {
|
|||
fromLog1p,
|
||||
} from "./utils";
|
||||
|
||||
const gradNormConfig = {
|
||||
displayGradNorm: { label: "Grad Norm", color: "#f97316" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
interface GradNormPoint {
|
||||
step: number;
|
||||
gradNorm: number;
|
||||
|
|
@ -47,12 +44,18 @@ export function GradNormChartCard({
|
|||
xAxisTicks: number[];
|
||||
scale: ScaleMode;
|
||||
}): ReactElement {
|
||||
const t = useT();
|
||||
const gradNormConfig = {
|
||||
displayGradNorm: { label: t("studio.charts.gradNorm"), color: "#f97316" },
|
||||
} satisfies ChartConfig;
|
||||
const showPoint = data.length <= 1 ? { r: 3, strokeWidth: 0 } : false;
|
||||
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Gradient Norm</CardTitle>
|
||||
<CardTitle className="text-sm">
|
||||
{t("studio.charts.gradientNorm")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={gradNormConfig} className={CHART_CONTAINER_CLASS}>
|
||||
|
|
@ -101,11 +104,13 @@ export function GradNormChartCard({
|
|||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_value, payload) =>
|
||||
`Step ${payload?.[0]?.payload?.step ?? ""}`
|
||||
t("studio.charts.step", {
|
||||
step: payload?.[0]?.payload?.step ?? "",
|
||||
})
|
||||
}
|
||||
formatter={(_value, _name, item) => {
|
||||
const raw = Number(item?.payload?.gradNorm);
|
||||
return [formatMetric(raw), "Grad Norm"];
|
||||
return [formatMetric(raw), t("studio.charts.gradNorm")];
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import { useT } from "@/i18n";
|
||||
import type { ReactElement } from "react";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import type { ScaleMode } from "./types";
|
||||
|
|
@ -22,10 +23,6 @@ import {
|
|||
fromLog1p,
|
||||
} from "./utils";
|
||||
|
||||
const lrConfig = {
|
||||
displayLr: { label: "LR", color: "#8b5cf6" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
interface LearningRatePoint {
|
||||
step: number;
|
||||
lr: number;
|
||||
|
|
@ -45,12 +42,18 @@ export function LearningRateChartCard({
|
|||
xAxisTicks: number[];
|
||||
scale: ScaleMode;
|
||||
}): ReactElement {
|
||||
const t = useT();
|
||||
const lrConfig = {
|
||||
displayLr: { label: t("studio.charts.lr"), color: "#8b5cf6" },
|
||||
} satisfies ChartConfig;
|
||||
const showPoint = data.length <= 1 ? { r: 3, strokeWidth: 0 } : false;
|
||||
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Learning Rate</CardTitle>
|
||||
<CardTitle className="text-sm">
|
||||
{t("studio.charts.learningRate")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={lrConfig} className={CHART_CONTAINER_CLASS}>
|
||||
|
|
@ -99,13 +102,15 @@ export function LearningRateChartCard({
|
|||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_value, payload) =>
|
||||
`Step ${payload?.[0]?.payload?.step ?? ""}`
|
||||
t("studio.charts.step", {
|
||||
step: payload?.[0]?.payload?.step ?? "",
|
||||
})
|
||||
}
|
||||
formatter={(_value, _name, item) => {
|
||||
const raw = Number(item?.payload?.lr);
|
||||
return [
|
||||
Number.isFinite(raw) ? raw.toExponential(3) : "0e+0",
|
||||
"LR",
|
||||
t("studio.charts.lr"),
|
||||
];
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import { useT } from "@/i18n";
|
||||
import type { ReactElement } from "react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
|
|
@ -31,11 +32,6 @@ import {
|
|||
fromLog1p,
|
||||
} from "./utils";
|
||||
|
||||
const lossConfig = {
|
||||
displayLoss: { label: "Loss", color: "#3b82f6" },
|
||||
displaySmoothed: { label: "Smoothed", color: "#f59e0b" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
interface LossChartPoint {
|
||||
step: number;
|
||||
loss: number;
|
||||
|
|
@ -67,12 +63,17 @@ export function TrainingLossChartCard({
|
|||
showAvgLine: boolean;
|
||||
scale: ScaleMode;
|
||||
}): ReactElement {
|
||||
const t = useT();
|
||||
const lossConfig = {
|
||||
displayLoss: { label: t("studio.charts.loss"), color: "#3b82f6" },
|
||||
displaySmoothed: { label: t("studio.charts.smoothed"), color: "#f59e0b" },
|
||||
} satisfies ChartConfig;
|
||||
const showPoint = data.length <= 1 ? { r: 3, strokeWidth: 0 } : false;
|
||||
|
||||
return (
|
||||
<Card data-tour="studio-training-loss" size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Training Loss</CardTitle>
|
||||
<CardTitle className="text-sm">{t("studio.charts.trainingLoss")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={lossConfig} className={CHART_CONTAINER_CLASS}>
|
||||
|
|
@ -121,16 +122,21 @@ export function TrainingLossChartCard({
|
|||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_value, payload) =>
|
||||
`Step ${payload?.[0]?.payload?.step ?? ""}`
|
||||
t("studio.charts.step", {
|
||||
step: payload?.[0]?.payload?.step ?? "",
|
||||
})
|
||||
}
|
||||
formatter={(_value, name, item) => {
|
||||
if (name === "displaySmoothed") {
|
||||
return [
|
||||
formatMetric(Number(item?.payload?.smoothed)),
|
||||
"Smoothed",
|
||||
t("studio.charts.smoothed"),
|
||||
];
|
||||
}
|
||||
return [formatMetric(Number(item?.payload?.loss)), "Loss"];
|
||||
return [
|
||||
formatMetric(Number(item?.payload?.loss)),
|
||||
t("studio.charts.loss"),
|
||||
];
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
|
@ -142,7 +148,9 @@ export function TrainingLossChartCard({
|
|||
strokeDasharray="4 4"
|
||||
strokeOpacity={0.5}
|
||||
label={{
|
||||
value: `avg ${formatMetric(avgRaw)}`,
|
||||
value: t("studio.charts.averageValue", {
|
||||
value: formatMetric(avgRaw),
|
||||
}),
|
||||
position: "insideTopRight",
|
||||
fontSize: 10,
|
||||
fill: "#3b82f6",
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ import {
|
|||
import { toast } from "@/lib/toast";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { DocumentUploadRedirectDialog } from "./document-upload-redirect-dialog";
|
||||
import { translate, useT } from "@/i18n";
|
||||
|
||||
const TRAINING_UPLOAD_EXTENSIONS = [
|
||||
".csv",
|
||||
|
|
@ -129,6 +130,7 @@ function normalizeSliceInput(value: string): string | null {
|
|||
}
|
||||
|
||||
export function DatasetSection() {
|
||||
const t = useT();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
dataset,
|
||||
|
|
@ -204,7 +206,9 @@ export function DatasetSection() {
|
|||
setLocalDatasets(response.datasets ?? []);
|
||||
} catch (error) {
|
||||
setLocalError(
|
||||
error instanceof Error ? error.message : "Failed to load local datasets.",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate("studio.dataset.failedToLoadLocalDatasets"),
|
||||
);
|
||||
} finally {
|
||||
setHasLoadedLocalDatasets(true);
|
||||
|
|
@ -398,8 +402,8 @@ export function DatasetSection() {
|
|||
onSuccess(uploaded.stored_path);
|
||||
toast.success(successMessage, { description: uploaded.filename });
|
||||
} catch (error) {
|
||||
toast.error("Upload failed", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
toast.error(t("studio.dataset.uploadFailed"), {
|
||||
description: error instanceof Error ? error.message : t("studio.dataset.unknownError"),
|
||||
});
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
|
|
@ -409,8 +413,10 @@ export function DatasetSection() {
|
|||
const handleDatasetFile = async (file: File) => {
|
||||
const extension = getFileExtension(file.name);
|
||||
if (!TRAINING_UPLOAD_EXTENSION_SET.has(extension)) {
|
||||
toast.error("Unsupported file type", {
|
||||
description: `Upload one ${TRAINING_UPLOAD_LABEL} file.`,
|
||||
toast.error(t("studio.dataset.unsupportedFileType"), {
|
||||
description: t("studio.dataset.uploadOneFileType", {
|
||||
types: TRAINING_UPLOAD_LABEL,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -421,7 +427,7 @@ export function DatasetSection() {
|
|||
return;
|
||||
}
|
||||
|
||||
await handleFileUpload(file, selectLocalDataset, "Dataset uploaded");
|
||||
await handleFileUpload(file, selectLocalDataset, t("studio.dataset.datasetUploaded"));
|
||||
};
|
||||
|
||||
const handleDatasetFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -441,8 +447,8 @@ export function DatasetSection() {
|
|||
if (files.length === 0) return;
|
||||
|
||||
if (files.length > 1) {
|
||||
toast.error("Upload one file at a time", {
|
||||
description: "Training dataset upload accepts a single file.",
|
||||
toast.error(t("studio.dataset.uploadOneFileAtATime"), {
|
||||
description: t("studio.dataset.uploadSingleFileDescription"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -467,7 +473,7 @@ export function DatasetSection() {
|
|||
event.target.value = "";
|
||||
if (!file) return;
|
||||
|
||||
await handleFileUpload(file, setUploadedEvalFile, "Eval dataset uploaded");
|
||||
await handleFileUpload(file, setUploadedEvalFile, t("studio.dataset.evalDatasetUploaded"));
|
||||
};
|
||||
|
||||
const handleOpenLearningRecipes = useCallback(() => {
|
||||
|
|
@ -480,8 +486,8 @@ export function DatasetSection() {
|
|||
<div data-tour="studio-dataset" className="min-w-0">
|
||||
<SectionCard
|
||||
icon={<HugeiconsIcon icon={Database02Icon} className="size-5" />}
|
||||
title="Dataset"
|
||||
description="Select or upload training data"
|
||||
title={t("studio.dataset.title")}
|
||||
description={t("studio.dataset.description")}
|
||||
accent="indigo"
|
||||
className={`dark:shadow-border ${
|
||||
advancedOpen || (datasetSource === "upload" && uploadedFile)
|
||||
|
|
@ -492,9 +498,9 @@ export function DatasetSection() {
|
|||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Choose dataset
|
||||
{t("studio.dataset.chooseDataset")}
|
||||
<span className="rounded-full border border-border/70 bg-muted/40 px-2 py-0.5 text-[10px] font-medium text-foreground/80">
|
||||
{datasetSource === "upload" ? "Local" : "Hugging Face"}
|
||||
{datasetSource === "upload" ? t("studio.dataset.localTab") : "Hugging Face"}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
|
|
@ -509,15 +515,14 @@ export function DatasetSection() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Use the popup tabs to switch between Hugging Face and local
|
||||
recipe outputs.{" "}
|
||||
{t("studio.dataset.chooseDatasetTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/datasets-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -590,8 +595,8 @@ export function DatasetSection() {
|
|||
<ComboboxInput
|
||||
placeholder={
|
||||
pickerTab === "huggingface"
|
||||
? "Search Hugging Face datasets..."
|
||||
: "Search local datasets..."
|
||||
? t("studio.dataset.searchHuggingFaceDatasets")
|
||||
: t("studio.dataset.searchLocalDatasets")
|
||||
}
|
||||
className="w-full min-w-0 overflow-hidden leading-5"
|
||||
showClear={true}
|
||||
|
|
@ -612,16 +617,16 @@ export function DatasetSection() {
|
|||
>
|
||||
<TabsList className=" w-full">
|
||||
<TabsTrigger value="huggingface">Hugging Face</TabsTrigger>
|
||||
<TabsTrigger value="local">Local</TabsTrigger>
|
||||
<TabsTrigger value="local">{t("studio.dataset.localTab")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="huggingface" className="m-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground">
|
||||
<Spinner className="size-4" /> Searching...
|
||||
<Spinner className="size-4" /> {t("studio.dataset.searching")}
|
||||
</div>
|
||||
) : (
|
||||
<ComboboxEmpty>No datasets found</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("studio.dataset.noDatasetsFound")}</ComboboxEmpty>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
|
|
@ -660,7 +665,7 @@ export function DatasetSection() {
|
|||
<TabsContent value="local" className="m-0">
|
||||
{localLoading ? (
|
||||
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground">
|
||||
<Spinner className="size-4" /> Loading local datasets...
|
||||
<Spinner className="size-4" /> {t("studio.dataset.loadingLocalDatasets")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -671,12 +676,12 @@ export function DatasetSection() {
|
|||
<div className="flex w-full flex-col items-center gap-2 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{localDatasets.length === 0
|
||||
? "No local datasets yet."
|
||||
: "No local datasets match search."}
|
||||
? t("studio.dataset.noLocalDatasetsYet")
|
||||
: t("studio.dataset.noLocalDatasetsMatchSearch")}
|
||||
</p>
|
||||
{localDatasets.length === 0 ? (
|
||||
<Button asChild={true} size="sm" variant="outline">
|
||||
<a href="/data-recipes">Open Data Recipes</a>
|
||||
<a href="/data-recipes">{t("studio.dataset.openDataRecipes")}</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -724,17 +729,27 @@ export function DatasetSection() {
|
|||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Get or update token
|
||||
{t("studio.dataset.getOrUpdateToken")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{isCheckingToken && (
|
||||
<p className="text-xs text-muted-foreground">Checking token…</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("studio.dataset.checkingToken")}
|
||||
</p>
|
||||
)}
|
||||
{pickerTab !== activeSourceTab && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Browsing {pickerTab === "local" ? "Local datasets" : "Hugging Face"}.
|
||||
Current selection stays {datasetSource === "upload" ? "Local" : "Hugging Face"}.
|
||||
{t("studio.dataset.browsingSource", {
|
||||
browsing:
|
||||
pickerTab === "local"
|
||||
? t("studio.dataset.localDatasets")
|
||||
: "Hugging Face",
|
||||
current:
|
||||
datasetSource === "upload"
|
||||
? t("studio.dataset.localTab")
|
||||
: "Hugging Face",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -770,10 +785,10 @@ export function DatasetSection() {
|
|||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Local dataset metadata
|
||||
{t("studio.dataset.localDatasetMetadata")}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground/80">
|
||||
Data Recipe output.
|
||||
{t("studio.dataset.dataRecipeOutput")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -781,7 +796,7 @@ export function DatasetSection() {
|
|||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
|
||||
<MetadataRow
|
||||
label="Rows"
|
||||
label={t("studio.dataset.rows")}
|
||||
value={
|
||||
typeof selectedLocalRows === "number"
|
||||
? selectedLocalRows.toLocaleString()
|
||||
|
|
@ -789,7 +804,7 @@ export function DatasetSection() {
|
|||
}
|
||||
/>
|
||||
<MetadataRow
|
||||
label="Columns"
|
||||
label={t("studio.dataset.columns")}
|
||||
value={
|
||||
selectedLocalColumns.length > 0
|
||||
? String(selectedLocalColumns.length)
|
||||
|
|
@ -797,7 +812,7 @@ export function DatasetSection() {
|
|||
}
|
||||
/>
|
||||
<MetadataRow
|
||||
label="Batches"
|
||||
label={t("studio.dataset.batches")}
|
||||
value={
|
||||
typeof selectedLocalMetadata?.num_completed_batches === "number" &&
|
||||
typeof selectedLocalMetadata?.total_num_batches === "number"
|
||||
|
|
@ -806,7 +821,7 @@ export function DatasetSection() {
|
|||
}
|
||||
/>
|
||||
<MetadataRow
|
||||
label="Updated"
|
||||
label={t("studio.dataset.updated")}
|
||||
value={formatUpdatedDate(selectedLocalUpdatedAt)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -817,7 +832,7 @@ export function DatasetSection() {
|
|||
{datasetSource === "upload" && uploadedFile && (
|
||||
<div className="rounded-lg border bg-muted/20 px-3.5 py-3">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
Eval dataset
|
||||
{t("studio.dataset.evalDataset")}
|
||||
</p>
|
||||
{uploadedEvalFile ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
|
|
@ -850,10 +865,12 @@ export function DatasetSection() {
|
|||
) : (
|
||||
<HugeiconsIcon icon={CloudUploadIcon} className="size-3.5" />
|
||||
)}
|
||||
{isUploading ? "Uploading..." : "Upload eval file"}
|
||||
{isUploading
|
||||
? t("studio.dataset.uploading")
|
||||
: t("studio.dataset.uploadEvalFile")}
|
||||
</Button>
|
||||
<p className="text-[10px] text-muted-foreground/80">
|
||||
Optional. If not provided, a small portion will be split from the training data.
|
||||
{t("studio.dataset.evalDatasetDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -866,13 +883,13 @@ export function DatasetSection() {
|
|||
icon={ArrowDown01Icon}
|
||||
className={`size-3.5 transition-transform ${advancedOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
Advanced
|
||||
{t("studio.dataset.advanced")}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3 data-[state=open]:overflow-visible">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Target Format
|
||||
{t("studio.dataset.targetFormat")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -886,15 +903,14 @@ export function DatasetSection() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Format of your training data. Auto-detect works for most
|
||||
datasets.{" "}
|
||||
{t("studio.dataset.targetFormatTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/datasets-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -909,18 +925,18 @@ export function DatasetSection() {
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="auto">{t("studio.dataset.auto")}</SelectItem>
|
||||
<SelectItem value="alpaca">Alpaca</SelectItem>
|
||||
<SelectItem value="chatml">ChatML</SelectItem>
|
||||
<SelectItem value="sharegpt">ShareGPT</SelectItem>
|
||||
<SelectItem value="raw">Raw Text</SelectItem>
|
||||
<SelectItem value="raw">{t("studio.dataset.rawText")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Train Split Start
|
||||
{t("studio.dataset.trainSplitStart")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -934,9 +950,7 @@ export function DatasetSection() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Only train on a subset of your training split by
|
||||
specifying a start row index (inclusive, 0-based).
|
||||
Leave empty to start from the first row.
|
||||
{t("studio.dataset.trainSplitStartTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
|
|
@ -954,7 +968,7 @@ export function DatasetSection() {
|
|||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Train Split End
|
||||
{t("studio.dataset.trainSplitEnd")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -968,10 +982,7 @@ export function DatasetSection() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Last row index to include from the training split
|
||||
(inclusive, 0-based). For example, set Start to 0 and
|
||||
End to 99 to train on the first 100 rows. Leave empty
|
||||
to use all remaining rows.
|
||||
{t("studio.dataset.trainSplitEndTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
|
|
@ -980,7 +991,7 @@ export function DatasetSection() {
|
|||
inputMode="numeric"
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="End"
|
||||
placeholder={t("studio.dataset.endPlaceholder")}
|
||||
value={datasetSliceEnd ?? ""}
|
||||
onChange={(e) =>
|
||||
setDatasetSliceEnd(normalizeSliceInput(e.target.value))
|
||||
|
|
@ -1012,17 +1023,19 @@ export function DatasetSection() {
|
|||
{datasetSource === "upload" ? (
|
||||
uploadedFile ? (
|
||||
<>
|
||||
Local dataset
|
||||
{t("studio.dataset.localDataset")}
|
||||
{selectedLocalRows != null
|
||||
? ` / ${selectedLocalRows.toLocaleString()} rows`
|
||||
? t("studio.dataset.localDatasetRows", {
|
||||
count: selectedLocalRows.toLocaleString(),
|
||||
})
|
||||
: ""}
|
||||
</>
|
||||
) : (
|
||||
"Local dataset"
|
||||
t("studio.dataset.localDataset")
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
Hugging Face Dataset
|
||||
{t("studio.dataset.huggingFaceDataset")}
|
||||
{datasetSubset && ` / ${datasetSubset}`}
|
||||
{datasetSplit && ` / ${datasetSplit}`}
|
||||
</>
|
||||
|
|
@ -1035,7 +1048,7 @@ export function DatasetSection() {
|
|||
className="shrink-0 text-xs"
|
||||
onClick={() => clearSelectionForTab(activeSourceTab)}
|
||||
>
|
||||
Clear
|
||||
{t("studio.dataset.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -1058,7 +1071,7 @@ export function DatasetSection() {
|
|||
/>
|
||||
<span className="pointer-events-none min-w-0">
|
||||
<span className="block text-xs font-medium text-foreground">
|
||||
Drop 1 file here or click to upload
|
||||
{t("studio.dataset.dropFileOrClick")}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[10px] text-muted-foreground">
|
||||
{TRAINING_UPLOAD_LABEL}
|
||||
|
|
@ -1080,7 +1093,7 @@ export function DatasetSection() {
|
|||
) : (
|
||||
<HugeiconsIcon icon={CloudUploadIcon} className="size-3.5" />
|
||||
)}
|
||||
{isUploading ? "Uploading..." : "Upload"}
|
||||
{isUploading ? t("studio.dataset.uploading") : t("studio.dataset.upload")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -1090,7 +1103,7 @@ export function DatasetSection() {
|
|||
onClick={() => openPreview()}
|
||||
>
|
||||
<HugeiconsIcon icon={ViewIcon} className="size-3.5" />
|
||||
View dataset
|
||||
{t("studio.dataset.viewDataset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import {
|
|||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { translate, useT } from "@/i18n";
|
||||
|
||||
const METHOD_DOTS: Record<string, string> = {
|
||||
qlora: "bg-emerald-400",
|
||||
|
|
@ -85,6 +86,7 @@ function extractParamLabel(id: string): string | null {
|
|||
}
|
||||
|
||||
export function ModelSection() {
|
||||
const t = useT();
|
||||
const gpu = useGpuInfo();
|
||||
|
||||
const {
|
||||
|
|
@ -157,7 +159,7 @@ export function ModelSection() {
|
|||
setLocalModelsError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to load local models",
|
||||
: translate("studio.model.failedToLoadLocalModels"),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
|
|
@ -272,11 +274,11 @@ export function ModelSection() {
|
|||
<div data-tour="studio-model" className="w-full min-w-0">
|
||||
<SectionCard
|
||||
icon={<HugeiconsIcon icon={ChipIcon} className="size-5" />}
|
||||
title="Model"
|
||||
description="Select base model and training method"
|
||||
title={t("studio.model.title")}
|
||||
description={t("studio.model.description")}
|
||||
accent="emerald"
|
||||
featured={true}
|
||||
badge="2x Faster Training"
|
||||
badge={t("studio.model.fasterTrainingBadge")}
|
||||
className="shadow-border ring-border"
|
||||
>
|
||||
<div className="grid min-w-0 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
|
|
@ -285,7 +287,7 @@ export function ModelSection() {
|
|||
className="flex min-w-0 flex-col gap-2"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Local Model
|
||||
{t("studio.model.localModel")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -299,7 +301,7 @@ export function ModelSection() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Path to a locally downloaded model or a custom HF repo.
|
||||
{t("studio.model.localModelTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
|
|
@ -321,7 +323,7 @@ export function ModelSection() {
|
|||
<ComboboxInput
|
||||
placeholder={
|
||||
isLoadingLocalModels
|
||||
? "Scanning local and cached models..."
|
||||
? t("studio.model.scanningLocalAndCachedModels")
|
||||
: "./models/my-model"
|
||||
}
|
||||
className="w-full bg-foreground text-background [&_input]:text-background [&_input]:placeholder:text-background/40 [&_svg]:text-background/50 hover:bg-foreground/90"
|
||||
|
|
@ -342,26 +344,26 @@ export function ModelSection() {
|
|||
>
|
||||
{isLoadingLocalModels ? (
|
||||
<div className="flex items-center justify-center gap-2 py-4 text-xs text-muted-foreground">
|
||||
<Spinner className="size-4" /> Scanning...
|
||||
<Spinner className="size-4" /> {t("studio.model.scanning")}
|
||||
</div>
|
||||
) : localModelsError ? (
|
||||
<div className="px-3 py-2 text-xs text-red-500">
|
||||
{localModelsError}
|
||||
</div>
|
||||
) : (
|
||||
<ComboboxEmpty>No local models found</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("studio.model.noLocalModelsFound")}</ComboboxEmpty>
|
||||
)}
|
||||
<ComboboxList className="p-1">
|
||||
{(id: string) => {
|
||||
const model = localMetaById.get(id);
|
||||
const source =
|
||||
model?.source === "hf_cache"
|
||||
? "HF cache"
|
||||
? t("studio.model.hfCache")
|
||||
: model?.source === "lmstudio"
|
||||
? "LM Studio"
|
||||
: model?.source === "custom"
|
||||
? "Custom Folders"
|
||||
: "Local dir";
|
||||
? t("studio.model.customFolders")
|
||||
: t("studio.model.localDir");
|
||||
return (
|
||||
<ComboboxItem key={id} value={id} className="gap-2">
|
||||
<Tooltip>
|
||||
|
|
@ -389,15 +391,17 @@ export function ModelSection() {
|
|||
</div>
|
||||
{isLoadingLocalModels ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Scanning local models...
|
||||
{t("studio.model.scanningLocalModels")}
|
||||
</p>
|
||||
) : localModelsError ? (
|
||||
<p className="text-[10px] text-red-500">{localModelsError}</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{trainableLocalModels.length > 0
|
||||
? `${trainableLocalModels.length} local/cached models found`
|
||||
: "No local models found. Enter path manually."}
|
||||
? t("studio.model.localModelsFound", {
|
||||
count: trainableLocalModels.length,
|
||||
})
|
||||
: t("studio.model.noLocalModelsFoundManual")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -407,7 +411,7 @@ export function ModelSection() {
|
|||
className="flex min-w-0 flex-col gap-2"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Hugging Face Model
|
||||
{t("studio.model.huggingFaceModel")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -421,14 +425,14 @@ export function ModelSection() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Search Hugging Face models or pick from our recommended list.{" "}
|
||||
{t("studio.model.huggingFaceModelTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/what-model-should-i-use"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.model.readMore")}
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -459,7 +463,7 @@ export function ModelSection() {
|
|||
autoHighlight={true}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Search models..."
|
||||
placeholder={t("studio.model.searchModels")}
|
||||
className="w-full leading-5"
|
||||
>
|
||||
<InputGroupAddon>
|
||||
|
|
@ -469,10 +473,10 @@ export function ModelSection() {
|
|||
<ComboboxContent anchor={comboboxAnchorRef}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground">
|
||||
<Spinner className="size-4" /> Searching…
|
||||
<Spinner className="size-4" /> {t("studio.model.searching")}
|
||||
</div>
|
||||
) : (
|
||||
<ComboboxEmpty>No models found</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("studio.model.noModelsFound")}</ComboboxEmpty>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
|
|
@ -510,10 +514,18 @@ export function ModelSection() {
|
|||
gpu.available && (
|
||||
<span className="block text-[10px] mt-1">
|
||||
{exceeds
|
||||
? `Needs ~${vramEst}GB VRAM (GPU: ${gpu.memoryTotalGb}GB)`
|
||||
? t("studio.model.needsVram", {
|
||||
vram: vramEst,
|
||||
gpu: gpu.memoryTotalGb,
|
||||
})
|
||||
: fitStatus === "tight"
|
||||
? `~${vramEst}GB VRAM (tight fit on ${gpu.memoryTotalGb}GB)`
|
||||
: `~${vramEst}GB VRAM`}
|
||||
? t("studio.model.tightVram", {
|
||||
vram: vramEst,
|
||||
gpu: gpu.memoryTotalGb,
|
||||
})
|
||||
: t("studio.model.vramEstimate", {
|
||||
vram: vramEst,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</TooltipContent>
|
||||
|
|
@ -556,7 +568,7 @@ export function ModelSection() {
|
|||
className="flex min-w-0 flex-col gap-2"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Method
|
||||
{t("studio.model.method")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -570,17 +582,14 @@ export function ModelSection() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
QLoRA uses 4-bit quantization for lowest VRAM. LoRA uses
|
||||
16-bit. Full updates all weights. CPT (Continued Pretraining)
|
||||
trains on raw text to adapt the model to a new domain without
|
||||
chat formatting.{" "}
|
||||
{t("studio.model.methodTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.model.readMore")}
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -617,7 +626,7 @@ export function ModelSection() {
|
|||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${METHOD_DOTS.full}`}
|
||||
/>
|
||||
Full Fine-tune
|
||||
{t("studio.model.fullFineTune")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="cpt">
|
||||
|
|
@ -625,7 +634,7 @@ export function ModelSection() {
|
|||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${METHOD_DOTS.cpt}`}
|
||||
/>
|
||||
Continued Pretraining
|
||||
{t("studio.model.continuedPretraining")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
|
|
@ -634,7 +643,7 @@ export function ModelSection() {
|
|||
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Hugging Face Token (Optional)
|
||||
{t("studio.model.huggingFaceTokenOptional")}
|
||||
</span>
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
|
|
@ -659,12 +668,14 @@ export function ModelSection() {
|
|||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Get or update token
|
||||
{t("studio.model.getOrUpdateToken")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{isCheckingToken && (
|
||||
<p className="text-xs text-muted-foreground">Checking token…</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("studio.model.checkingToken")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ import {
|
|||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { type ReactElement, type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { useT } from "@/i18n";
|
||||
|
||||
type StudioT = ReturnType<typeof useT>;
|
||||
|
||||
function Row({
|
||||
label,
|
||||
|
|
@ -126,19 +129,66 @@ function SliderRow({
|
|||
);
|
||||
}
|
||||
|
||||
function formatOptimizerLabel(
|
||||
value: string,
|
||||
fallback: string,
|
||||
t: StudioT,
|
||||
): string {
|
||||
switch (value) {
|
||||
case "adamw_8bit":
|
||||
return t("studio.params.optimizerOptions.adamw8bit");
|
||||
case "paged_adamw_8bit":
|
||||
return t("studio.params.optimizerOptions.pagedAdamw8bit");
|
||||
case "adamw_bnb_8bit":
|
||||
return t("studio.params.optimizerOptions.adamwBnb8bit");
|
||||
case "paged_adamw_32bit":
|
||||
return t("studio.params.optimizerOptions.pagedAdamw32bit");
|
||||
case "adamw_torch":
|
||||
return t("studio.params.optimizerOptions.adamwTorch");
|
||||
case "adamw_torch_fused":
|
||||
return t("studio.params.optimizerOptions.adamwTorchFused");
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSchedulerLabel(
|
||||
value: string,
|
||||
fallback: string,
|
||||
t: StudioT,
|
||||
): string {
|
||||
switch (value) {
|
||||
case "linear":
|
||||
return t("studio.params.lrSchedulerOptions.linear");
|
||||
case "cosine":
|
||||
return t("studio.params.lrSchedulerOptions.cosine");
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function ParamsSection(): ReactElement {
|
||||
const t = useT();
|
||||
const store = useTrainingConfigStore();
|
||||
const platformDeviceType = usePlatformStore((s) => s.deviceType);
|
||||
const isLora = isAdapterMethod(store.trainingMethod);
|
||||
const isCpt = store.trainingMethod === "cpt";
|
||||
const isRawText = isRawTextDatasetFormat(store.datasetFormat);
|
||||
const showVisionLora = store.isVisionModel && store.isDatasetImage === true;
|
||||
// DeepSeek OCR uses a coupled preset; backend ignores user image size.
|
||||
const _selectedModelLower = (store.selectedModel ?? "").toLowerCase();
|
||||
const isDeepseekOcr =
|
||||
_selectedModelLower.includes("deepseek") &&
|
||||
_selectedModelLower.includes("ocr");
|
||||
const showVisionImageSize = showVisionLora && !isDeepseekOcr;
|
||||
const [loraOpen, setLoraOpen] = useState(false);
|
||||
const [hyperOpen, setHyperOpen] = useState(false);
|
||||
const needsExpandedHeight = isCpt || (isLora && loraOpen) || hyperOpen;
|
||||
const [ctxInput, setCtxInput] = useState(String(store.contextLength));
|
||||
const ctxAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const ctxItems = CONTEXT_LENGTHS.map(String);
|
||||
// Backend validator allows [256, 2048]; offer the full span.
|
||||
const visionImageSizePresets = [256, 384, 512, 768, 1024, 1536, 2048];
|
||||
|
||||
// Keep input in sync when the store value changes externally
|
||||
// (e.g. model defaults being applied after model selection).
|
||||
|
|
@ -171,8 +221,8 @@ export function ParamsSection(): ReactElement {
|
|||
<div data-tour="studio-params" className="min-w-0">
|
||||
<SectionCard
|
||||
icon={<HugeiconsIcon icon={Settings04Icon} className="size-5" />}
|
||||
title="Parameters"
|
||||
description="Configure training hyperparameters"
|
||||
title={t("studio.params.title")}
|
||||
description={t("studio.params.description")}
|
||||
accent="orange"
|
||||
className={`${needsExpandedHeight
|
||||
? "min-h-studio-config-column"
|
||||
|
|
@ -187,7 +237,7 @@ export function ParamsSection(): ReactElement {
|
|||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
{useEpochs ? "Epochs" : "Max Steps"}
|
||||
{useEpochs ? t("studio.params.epochs") : t("studio.params.maxSteps")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -202,15 +252,15 @@ export function ParamsSection(): ReactElement {
|
|||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{useEpochs
|
||||
? "Number of full passes over the dataset."
|
||||
: "Override total optimizer steps."}{" "}
|
||||
? t("studio.params.epochsTooltip")
|
||||
: t("studio.params.maxStepsTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -221,7 +271,7 @@ export function ParamsSection(): ReactElement {
|
|||
onClick={toggleUseEpochs}
|
||||
className="text-xs text-primary underline cursor-pointer"
|
||||
>
|
||||
{useEpochs ? "Use Max Steps" : "Use Epochs"}
|
||||
{useEpochs ? t("studio.params.useMaxSteps") : t("studio.params.useEpochs")}
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
|
|
@ -261,8 +311,8 @@ export function ParamsSection(): ReactElement {
|
|||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{useEpochs
|
||||
? "Each epoch is one full pass over your dataset."
|
||||
: "Limits training to a fixed number of optimizer steps."}
|
||||
? t("studio.params.epochsDescription")
|
||||
: t("studio.params.maxStepsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -270,7 +320,7 @@ export function ParamsSection(): ReactElement {
|
|||
{/* Context length */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Context Length
|
||||
{t("studio.params.contextLength")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -284,14 +334,14 @@ export function ParamsSection(): ReactElement {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Maximum number of tokens per training sample.{" "}
|
||||
{t("studio.params.contextLengthTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -330,7 +380,7 @@ export function ParamsSection(): ReactElement {
|
|||
}}
|
||||
/>
|
||||
<ComboboxContent anchor={ctxAnchorRef}>
|
||||
<ComboboxEmpty>Enter a custom value</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("studio.params.customContextLength")}</ComboboxEmpty>
|
||||
<ComboboxList className="p-1">
|
||||
{(id: string) => (
|
||||
<ComboboxItem key={id} value={id} className="font-mono">
|
||||
|
|
@ -342,14 +392,14 @@ export function ParamsSection(): ReactElement {
|
|||
</Combobox>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Max sequence length for training samples
|
||||
{t("studio.params.contextLengthDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Learning Rate */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Learning Rate
|
||||
{t("studio.params.learningRate")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -363,15 +413,14 @@ export function ParamsSection(): ReactElement {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Step size for weight updates. Lower values train slower but more
|
||||
stably.{" "}
|
||||
{t("studio.params.learningRateTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -384,7 +433,7 @@ export function ParamsSection(): ReactElement {
|
|||
className="w-full font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Recommended: 2e-4 for LoRA, 5e-5 for CPT, 2e-5 for full fine-tune
|
||||
{t("studio.params.learningRateDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -392,7 +441,7 @@ export function ParamsSection(): ReactElement {
|
|||
{isCpt && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Embedding Learning Rate
|
||||
{t("studio.params.embeddingLearningRate")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -406,12 +455,7 @@ export function ParamsSection(): ReactElement {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Only used when CPT is training <code>embed_tokens</code>.
|
||||
Embeddings are easier to destabilize than LoRA weights, so
|
||||
they usually need a smaller LR. Leave blank to use
|
||||
<code>lr/10</code>; typical working range is 2x-10x smaller
|
||||
than the main LR. Increase it only if vocabulary or
|
||||
domain-token adaptation is too slow.
|
||||
{t("studio.params.embeddingLearningRateTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
|
|
@ -434,8 +478,7 @@ export function ParamsSection(): ReactElement {
|
|||
className="w-full font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Leave blank to use lr/10 (recommended). Typical range is
|
||||
2x-10x smaller than the main learning rate.
|
||||
{t("studio.params.embeddingLearningRateDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -448,22 +491,22 @@ export function ParamsSection(): ReactElement {
|
|||
icon={ArrowDown01Icon}
|
||||
className={`size-3.5 transition-transform ${loraOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
LoRA Settings
|
||||
{t("studio.params.loraSettings")}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3 data-[state=open]:overflow-visible">
|
||||
<div className="pt-1.5 flex flex-col gap-4">
|
||||
<SliderRow
|
||||
label="Rank"
|
||||
label={t("studio.params.rank")}
|
||||
tooltip={
|
||||
<>
|
||||
Dimension of the low-rank matrices. Higher = more capacity.{" "}
|
||||
{t("studio.params.rankTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -474,17 +517,17 @@ export function ParamsSection(): ReactElement {
|
|||
step={4}
|
||||
/>
|
||||
<SliderRow
|
||||
label="Alpha"
|
||||
label={t("studio.params.alpha")}
|
||||
tooltip={
|
||||
<>
|
||||
Scaling factor for LoRA updates. Usually 2x rank.{" "}
|
||||
{t("studio.params.alphaTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -495,17 +538,17 @@ export function ParamsSection(): ReactElement {
|
|||
step={4}
|
||||
/>
|
||||
<SliderRow
|
||||
label="Dropout"
|
||||
label={t("studio.params.dropout")}
|
||||
tooltip={
|
||||
<>
|
||||
Dropout probability for LoRA layers to reduce overfitting.{" "}
|
||||
{t("studio.params.dropoutTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -524,25 +567,25 @@ export function ParamsSection(): ReactElement {
|
|||
[
|
||||
[
|
||||
"finetuneVisionLayers",
|
||||
"Vision layers",
|
||||
t("studio.params.visionLayers"),
|
||||
store.finetuneVisionLayers,
|
||||
store.setFinetuneVisionLayers,
|
||||
],
|
||||
[
|
||||
"finetuneLanguageLayers",
|
||||
"Language layers",
|
||||
t("studio.params.languageLayers"),
|
||||
store.finetuneLanguageLayers,
|
||||
store.setFinetuneLanguageLayers,
|
||||
],
|
||||
[
|
||||
"finetuneAttentionModules",
|
||||
"Attention modules",
|
||||
t("studio.params.attentionModules"),
|
||||
store.finetuneAttentionModules,
|
||||
store.setFinetuneAttentionModules,
|
||||
],
|
||||
[
|
||||
"finetuneMLPModules",
|
||||
"MLP modules",
|
||||
t("studio.params.mlpModules"),
|
||||
store.finetuneMLPModules,
|
||||
store.setFinetuneMLPModules,
|
||||
],
|
||||
|
|
@ -571,7 +614,7 @@ export function ParamsSection(): ReactElement {
|
|||
{!showVisionLora && (
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Target Modules
|
||||
{t("studio.params.targetModules")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(isCpt ? CPT_TARGET_MODULES : TARGET_MODULES).map((mod) => {
|
||||
|
|
@ -606,14 +649,14 @@ export function ParamsSection(): ReactElement {
|
|||
[
|
||||
{
|
||||
value: "lora",
|
||||
label: "Enable LoRA",
|
||||
desc: "Train with LoRA",
|
||||
label: t("studio.params.enableLora"),
|
||||
desc: t("studio.params.trainWithLora"),
|
||||
},
|
||||
{ value: "rslora", label: "RS-LoRA", desc: "Stable Rank" },
|
||||
{ value: "rslora", label: "RS-LoRA", desc: t("studio.params.stableRank") },
|
||||
{
|
||||
value: "loftq",
|
||||
label: "LoftQ",
|
||||
desc: "Memory Efficient",
|
||||
desc: t("studio.params.memoryEfficient"),
|
||||
},
|
||||
] as const
|
||||
).map((opt) => (
|
||||
|
|
@ -645,7 +688,7 @@ export function ParamsSection(): ReactElement {
|
|||
icon={ArrowDown01Icon}
|
||||
className={`size-3.5 transition-transform ${hyperOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
Training Hyperparameters
|
||||
{t("studio.params.trainingHyperparameters")}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3 data-[state=open]:overflow-visible">
|
||||
<Tabs defaultValue="optimization" className="w-full">
|
||||
|
|
@ -654,19 +697,19 @@ export function ParamsSection(): ReactElement {
|
|||
value="optimization"
|
||||
className="flex-1 !corner-squircle text-xs cursor-pointer"
|
||||
>
|
||||
Optimization
|
||||
{t("studio.params.optimization")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="schedule"
|
||||
className="flex-1 text-xs cursor-pointer"
|
||||
>
|
||||
Schedule
|
||||
{t("studio.params.schedule")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="memory"
|
||||
className="flex-1 text-xs cursor-pointer"
|
||||
>
|
||||
Memory
|
||||
{t("studio.params.memory")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -675,18 +718,17 @@ export function ParamsSection(): ReactElement {
|
|||
className="mt-3 flex flex-col gap-3"
|
||||
>
|
||||
<Row
|
||||
label="Optimizer"
|
||||
label={t("studio.params.optimizer")}
|
||||
tooltip={
|
||||
<>
|
||||
Optimization algorithm. 8-bit variants reduce memory usage.
|
||||
Fused is recommended for vision models.{" "}
|
||||
{t("studio.params.optimizerTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -704,25 +746,24 @@ export function ParamsSection(): ReactElement {
|
|||
key={opt.value}
|
||||
value={opt.value}
|
||||
>
|
||||
{opt.label}
|
||||
{formatOptimizerLabel(opt.value, opt.label, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Row>
|
||||
<Row
|
||||
label="LR scheduler"
|
||||
label={t("studio.params.lrScheduler")}
|
||||
tooltip={
|
||||
<>
|
||||
How the learning rate changes over training. Linear decays
|
||||
steadily; cosine decays in a curve.{" "}
|
||||
{t("studio.params.lrSchedulerTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -740,24 +781,24 @@ export function ParamsSection(): ReactElement {
|
|||
key={opt.value}
|
||||
value={opt.value}
|
||||
>
|
||||
{opt.label}
|
||||
{formatSchedulerLabel(opt.value, opt.label, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Row>
|
||||
<SliderRow
|
||||
label="Batch Size"
|
||||
label={t("studio.params.batchSize")}
|
||||
tooltip={
|
||||
<>
|
||||
Samples processed per step. Higher uses more VRAM.{" "}
|
||||
{t("studio.params.batchSizeTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -768,17 +809,17 @@ export function ParamsSection(): ReactElement {
|
|||
step={1}
|
||||
/>
|
||||
<SliderRow
|
||||
label="Grad Accum"
|
||||
label={t("studio.params.gradAccum")}
|
||||
tooltip={
|
||||
<>
|
||||
Simulates larger batch sizes without extra VRAM.{" "}
|
||||
{t("studio.params.gradAccumTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -789,17 +830,17 @@ export function ParamsSection(): ReactElement {
|
|||
step={1}
|
||||
/>
|
||||
<Row
|
||||
label="Weight Decay"
|
||||
label={t("studio.params.weightDecay")}
|
||||
tooltip={
|
||||
<>
|
||||
L2 regularization to prevent overfitting.{" "}
|
||||
{t("studio.params.weightDecayTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -821,17 +862,17 @@ export function ParamsSection(): ReactElement {
|
|||
className="mt-3 flex flex-col gap-3"
|
||||
>
|
||||
<SliderRow
|
||||
label="Warmup Steps"
|
||||
label={t("studio.params.warmupSteps")}
|
||||
tooltip={
|
||||
<>
|
||||
Gradually increase LR at training start for stability.{" "}
|
||||
{t("studio.params.warmupStepsTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -843,18 +884,17 @@ export function ParamsSection(): ReactElement {
|
|||
/>
|
||||
{!useEpochs && (
|
||||
<SliderRow
|
||||
label="Epochs"
|
||||
label={t("studio.params.epochs")}
|
||||
tooltip={
|
||||
<>
|
||||
Number of full passes over the dataset. Set 0 to run by
|
||||
max steps.{" "}
|
||||
{t("studio.params.scheduleEpochsTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -866,17 +906,17 @@ export function ParamsSection(): ReactElement {
|
|||
/>
|
||||
)}
|
||||
<Row
|
||||
label="Save Steps"
|
||||
label={t("studio.params.saveSteps")}
|
||||
tooltip={
|
||||
<>
|
||||
Save a checkpoint every N steps. 0 to disable.{" "}
|
||||
{t("studio.params.saveStepsTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -889,8 +929,8 @@ export function ParamsSection(): ReactElement {
|
|||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label="Eval Steps"
|
||||
tooltip="Fraction of total training steps between evaluations (0-1). Set to 0 to disable evaluation. E.g. 0.01 = evaluate every 1% of steps."
|
||||
label={t("studio.params.evalSteps")}
|
||||
tooltip={t("studio.params.evalStepsTooltip")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
|
|
@ -902,7 +942,7 @@ export function ParamsSection(): ReactElement {
|
|||
className="w-28 font-mono"
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Seed" tooltip="Random seed for reproducibility.">
|
||||
<Row label={t("studio.params.seed")} tooltip={t("studio.params.seedTooltip")}>
|
||||
<Input
|
||||
type="number"
|
||||
value={store.randomSeed}
|
||||
|
|
@ -915,18 +955,74 @@ export function ParamsSection(): ReactElement {
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="memory" className="mt-3 flex flex-col gap-3">
|
||||
{showVisionImageSize && (
|
||||
<Row
|
||||
label="Image Size"
|
||||
tooltip={
|
||||
<>
|
||||
Resize images by maximum side length. Default uses the
|
||||
model image size. Larger images use up more context. Does not upscale or change aspect ratio.{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/basics/vision-fine-tuning"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
value={
|
||||
store.visionImageSize == null
|
||||
? "default"
|
||||
: String(store.visionImageSize)
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
if (value === "default") {
|
||||
store.setVisionImageSize(null);
|
||||
return;
|
||||
}
|
||||
store.setVisionImageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
{store.visionImageSize != null &&
|
||||
!visionImageSizePresets.includes(
|
||||
store.visionImageSize,
|
||||
) && (
|
||||
<SelectItem
|
||||
value={String(store.visionImageSize)}
|
||||
>
|
||||
{store.visionImageSize}
|
||||
</SelectItem>
|
||||
)}
|
||||
{visionImageSizePresets.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Row>
|
||||
)}
|
||||
<Row
|
||||
label="Grad Checkpoint"
|
||||
label={t("studio.params.gradCheckpoint")}
|
||||
tooltip={
|
||||
<>
|
||||
Trade compute for memory by recomputing activations.{" "}
|
||||
{t("studio.params.gradCheckpointTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -941,8 +1037,8 @@ export function ParamsSection(): ReactElement {
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="true">Standard</SelectItem>
|
||||
<SelectItem value="none">{t("studio.params.none")}</SelectItem>
|
||||
<SelectItem value="true">{t("studio.params.standard")}</SelectItem>
|
||||
{platformDeviceType === "mac" ? (
|
||||
<SelectItem value="mlx">MLX</SelectItem>
|
||||
) : (
|
||||
|
|
@ -962,7 +1058,7 @@ export function ParamsSection(): ReactElement {
|
|||
htmlFor="packing"
|
||||
className="text-xs cursor-pointer text-muted-foreground"
|
||||
>
|
||||
Enable packing
|
||||
{t("studio.params.enablePacking")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -977,7 +1073,7 @@ export function ParamsSection(): ReactElement {
|
|||
htmlFor="trainOnCompletions"
|
||||
className="text-xs cursor-pointer text-muted-foreground"
|
||||
>
|
||||
Assistant completions only
|
||||
{t("studio.params.assistantCompletionsOnly")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,6 @@
|
|||
|
||||
import type { TrainingPhase } from "@/features/training";
|
||||
|
||||
export const phaseLabel: Record<TrainingPhase, string> = {
|
||||
idle: "Idle",
|
||||
downloading_model: "Downloading model",
|
||||
downloading_dataset: "Downloading dataset",
|
||||
loading_model: "Loading model",
|
||||
loading_dataset: "Loading dataset",
|
||||
configuring: "Configuring",
|
||||
training: "Training",
|
||||
completed: "Completed",
|
||||
error: "Error",
|
||||
stopped: "Stopped",
|
||||
};
|
||||
|
||||
export const phaseColors: Record<TrainingPhase, string> = {
|
||||
idle: "bg-muted text-muted-foreground",
|
||||
downloading_model:
|
||||
|
|
|
|||
|
|
@ -48,14 +48,27 @@ import {
|
|||
formatDuration,
|
||||
formatNumber,
|
||||
phaseColors,
|
||||
phaseLabel,
|
||||
} from "./progress-section-lib";
|
||||
import { useT, type TranslationKey } from "@/i18n";
|
||||
|
||||
type ConfigGroup = {
|
||||
section: string;
|
||||
rows: [string, string | number | null | undefined][];
|
||||
};
|
||||
|
||||
const phaseLabelKeys = {
|
||||
idle: "studio.progress.phase.idle",
|
||||
downloading_model: "studio.progress.phase.downloadingModel",
|
||||
downloading_dataset: "studio.progress.phase.downloadingDataset",
|
||||
loading_model: "studio.progress.phase.loadingModel",
|
||||
loading_dataset: "studio.progress.phase.loadingDataset",
|
||||
configuring: "studio.progress.phase.configuring",
|
||||
training: "studio.progress.phase.training",
|
||||
completed: "studio.progress.phase.completed",
|
||||
error: "studio.progress.phase.error",
|
||||
stopped: "studio.progress.phase.stopped",
|
||||
} satisfies Record<TrainingViewData["phase"], TranslationKey>;
|
||||
|
||||
function configRow(
|
||||
label: string,
|
||||
value: string | number | null | undefined,
|
||||
|
|
@ -86,6 +99,7 @@ export function ProgressSection({
|
|||
isHistorical = false,
|
||||
configOverride,
|
||||
}: ProgressSectionProps): ReactElement {
|
||||
const t = useT();
|
||||
const navigate = useNavigate();
|
||||
const trainingMethodLabel = getTrainingMethodLabel(data.trainingMethod);
|
||||
|
||||
|
|
@ -171,15 +185,15 @@ export function ProgressSection({
|
|||
|
||||
const configItems: ConfigGroup[] = [
|
||||
{
|
||||
section: "Hyperparams",
|
||||
section: t("studio.progress.hyperparams"),
|
||||
rows: [
|
||||
configRow("Epochs", cfgEpochs),
|
||||
configRow("Batch size", cfgBatchSize),
|
||||
configRow("Learning rate", cfgLearningRate),
|
||||
configRow("Optimizer", optimizerLabel),
|
||||
configRow("Max steps", cfgMaxSteps),
|
||||
configRow("Context length", cfgContextLength),
|
||||
configRow("Warmup steps", cfgWarmupSteps),
|
||||
configRow(t("studio.progress.epochs"), cfgEpochs),
|
||||
configRow(t("studio.progress.batchSize"), cfgBatchSize),
|
||||
configRow(t("studio.progress.learningRate"), cfgLearningRate),
|
||||
configRow(t("studio.progress.optimizer"), optimizerLabel),
|
||||
configRow(t("studio.progress.maxSteps"), cfgMaxSteps),
|
||||
configRow(t("studio.progress.contextLength"), cfgContextLength),
|
||||
configRow(t("studio.progress.warmupSteps"), cfgWarmupSteps),
|
||||
],
|
||||
},
|
||||
...(data.trainingMethod !== "full"
|
||||
|
|
@ -187,10 +201,10 @@ export function ProgressSection({
|
|||
{
|
||||
section: "LoRA",
|
||||
rows: [
|
||||
configRow("Rank", cfgLoraRank),
|
||||
configRow("Alpha", cfgLoraAlpha),
|
||||
configRow("Dropout", cfgLoraDropout),
|
||||
configRow("Variant", cfgLoraVariant),
|
||||
configRow(t("studio.progress.rank"), cfgLoraRank),
|
||||
configRow(t("studio.progress.alpha"), cfgLoraAlpha),
|
||||
configRow(t("studio.progress.dropout"), cfgLoraDropout),
|
||||
configRow(t("studio.progress.variant"), cfgLoraVariant),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
@ -200,8 +214,8 @@ export function ProgressSection({
|
|||
return (
|
||||
<SectionCard
|
||||
icon={<HugeiconsIcon icon={ChartAverageIcon} className="size-5" />}
|
||||
title="Training Progress"
|
||||
description={data.message || "Live training metrics"}
|
||||
title={t("studio.progress.title")}
|
||||
description={data.message || t("studio.progress.liveMetrics")}
|
||||
accent="emerald"
|
||||
className="shadow-border border border-border/60 bg-card/90 ring-0 backdrop-blur-sm"
|
||||
headerAction={
|
||||
|
|
@ -225,20 +239,25 @@ export function ProgressSection({
|
|||
<span
|
||||
className={`rounded-full px-2.5 py-1 text-[10px] font-semibold ${phaseColors[data.phase]}`}
|
||||
>
|
||||
{phaseLabel[data.phase]}
|
||||
{t(phaseLabelKeys[data.phase])}
|
||||
</span>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">
|
||||
Epoch {formatNumber(data.currentEpoch, 2)}
|
||||
{t("studio.progress.epoch", {
|
||||
value: formatNumber(data.currentEpoch, 2),
|
||||
})}
|
||||
</span>
|
||||
<span className="rounded-full border border-border/60 px-2.5 py-1 text-[10px] font-medium tabular-nums text-muted-foreground">
|
||||
{pct}% complete
|
||||
{t("studio.progress.percentComplete", { percent: pct })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Step {data.currentStep} / {data.totalSteps || "--"}
|
||||
{t("studio.progress.stepProgress", {
|
||||
current: data.currentStep,
|
||||
total: data.totalSteps || "--",
|
||||
})}
|
||||
</span>
|
||||
<span>{pct}%</span>
|
||||
</div>
|
||||
|
|
@ -261,33 +280,37 @@ export function ProgressSection({
|
|||
|
||||
<div className="grid gap-x-4 gap-y-3 pt-1 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<MetricStat
|
||||
label="Loss"
|
||||
label={t("studio.progress.loss")}
|
||||
valueClassName="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{stoppedLoss != null ? stoppedLoss.toFixed(4) : "--"}
|
||||
</MetricStat>
|
||||
<MetricStat label="LR">{stoppedLr != null ? stoppedLr.toExponential(2) : "--"}</MetricStat>
|
||||
<MetricStat label="Grad Norm">
|
||||
<MetricStat label={t("studio.progress.lr")}>{stoppedLr != null ? stoppedLr.toExponential(2) : "--"}</MetricStat>
|
||||
<MetricStat label={t("studio.progress.gradNorm")}>
|
||||
{formatNumber(stoppedGradNorm, 3)}
|
||||
</MetricStat>
|
||||
<MetricStat label="Model" valueClassName="truncate">
|
||||
<MetricStat label={t("studio.progress.model")} valueClassName="truncate">
|
||||
{data.modelName || "--"}
|
||||
</MetricStat>
|
||||
<MetricStat label="Method">
|
||||
<MetricStat label={t("studio.progress.method")}>
|
||||
{trainingMethodLabel}
|
||||
</MetricStat>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>Elapsed: {formatDuration(elapsed)}</span>
|
||||
{!isHistorical && <span>ETA: {formatDuration(eta)}</span>}
|
||||
<span>{t("studio.progress.elapsed", { value: formatDuration(elapsed) })}</span>
|
||||
{!isHistorical && (
|
||||
<span>{t("studio.progress.eta", { value: formatDuration(eta) })}</span>
|
||||
)}
|
||||
<span>
|
||||
{stepsPerSecond == null
|
||||
? "-- steps/s"
|
||||
: `${stepsPerSecond.toFixed(2)} steps/s`}
|
||||
? t("studio.progress.noStepsPerSecond")
|
||||
: t("studio.progress.stepsPerSecond", {
|
||||
value: stepsPerSecond.toFixed(2),
|
||||
})}
|
||||
</span>
|
||||
{data.currentNumTokens != null && (
|
||||
<span>Tokens: {data.currentNumTokens}</span>
|
||||
<span>{t("studio.progress.tokens", { value: data.currentNumTokens })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -305,19 +328,22 @@ function LiveGpuPanel({
|
|||
}: {
|
||||
isTrainingRunning: boolean;
|
||||
}): ReactElement {
|
||||
const t = useT();
|
||||
const gpu = useGpuUtilization(isTrainingRunning);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
GPU Monitor
|
||||
{t("studio.progress.gpuMonitor")}
|
||||
</p>
|
||||
<span className="text-[11px] text-muted-foreground">Live</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("studio.progress.live")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<GpuStat
|
||||
label="Utilization"
|
||||
label={t("studio.progress.utilization")}
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={DashboardSpeed01Icon}
|
||||
|
|
@ -332,7 +358,7 @@ function LiveGpuPanel({
|
|||
pct={gpu.gpu_utilization_pct ?? 0}
|
||||
/>
|
||||
<GpuStat
|
||||
label="Temperature"
|
||||
label={t("studio.progress.temperature")}
|
||||
icon={
|
||||
<HugeiconsIcon icon={TemperatureIcon} className="size-3.5" />
|
||||
}
|
||||
|
|
@ -343,7 +369,7 @@ function LiveGpuPanel({
|
|||
max={100}
|
||||
/>
|
||||
<GpuStat
|
||||
label="VRAM"
|
||||
label={t("studio.progress.vram")}
|
||||
icon={<HugeiconsIcon icon={RamMemoryIcon} className="size-3.5" />}
|
||||
value={
|
||||
gpu.vram_used_gb != null && gpu.vram_total_gb != null
|
||||
|
|
@ -353,7 +379,7 @@ function LiveGpuPanel({
|
|||
pct={gpu.vram_utilization_pct ?? 0}
|
||||
/>
|
||||
<GpuStat
|
||||
label="Power"
|
||||
label={t("studio.progress.power")}
|
||||
icon={<HugeiconsIcon icon={ZapIcon} className="size-3.5" />}
|
||||
value={
|
||||
gpu.power_draw_w != null
|
||||
|
|
@ -417,6 +443,7 @@ function ConfigPopoverButton({
|
|||
}: {
|
||||
configItems: ConfigGroup[];
|
||||
}): ReactElement {
|
||||
const t = useT();
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild={true}>
|
||||
|
|
@ -425,14 +452,14 @@ function ConfigPopoverButton({
|
|||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Open training config"
|
||||
aria-label={t("studio.progress.openConfig")}
|
||||
>
|
||||
<HugeiconsIcon icon={Notebook01Icon} className="size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="end">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold">Training Config</p>
|
||||
<p className="text-xs font-semibold">{t("studio.progress.configLabel")}</p>
|
||||
{configItems.map((group) => (
|
||||
<div key={group.section} className="flex flex-col gap-1">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
|
|
@ -469,6 +496,7 @@ function TrainingHeaderActions({
|
|||
stopDialogOpen: boolean;
|
||||
stopRequested: boolean;
|
||||
}): ReactElement {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ConfigPopoverButton configItems={configItems} />
|
||||
|
|
@ -486,25 +514,25 @@ function TrainingHeaderActions({
|
|||
disabled={!isTrainingRunning || stopRequested}
|
||||
>
|
||||
<HugeiconsIcon icon={StopIcon} className="size-3" />
|
||||
{stopRequested ? "Stopping…" : "Stop"}
|
||||
{stopRequested ? t("studio.training.stopping") : t("studio.training.stopAction")}
|
||||
</Button>
|
||||
<AlertDialogContent overlayClassName="bg-background/40 supports-backdrop-filter:backdrop-blur-[1px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Stop Training</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("studio.training.stopTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Choose how you want to stop the current training run.
|
||||
{t("studio.training.stopDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Continue Training</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t("studio.training.continueAction")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => onRequestStop(false)}
|
||||
>
|
||||
Cancel Training
|
||||
{t("studio.training.cancelAction")}
|
||||
</AlertDialogAction>
|
||||
<AlertDialogAction onClick={() => onRequestStop(true)}>
|
||||
Stop and Save
|
||||
{t("studio.training.stopAndSave")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
@ -522,6 +550,7 @@ function MilestoneCallout({
|
|||
showHalfwayHint: boolean;
|
||||
onCompareInChat: () => Promise<void>;
|
||||
}): ReactElement | null {
|
||||
const t = useT();
|
||||
if (!(showHalfwayHint || showCompletedHint)) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -532,7 +561,7 @@ function MilestoneCallout({
|
|||
<div className="min-w-0">
|
||||
{!showCompletedHint && (
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
|
||||
Milestone
|
||||
{t("studio.training.milestone")}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
|
|
@ -542,8 +571,8 @@ function MilestoneCallout({
|
|||
)}
|
||||
>
|
||||
{showCompletedHint
|
||||
? "Training done. Next step: compare base vs fine-tuned outputs."
|
||||
: "Halfway done. Training is past 50%."}
|
||||
? t("studio.training.doneNextStep")
|
||||
: t("studio.training.halfwayDone")}
|
||||
</p>
|
||||
</div>
|
||||
{!showCompletedHint && (
|
||||
|
|
@ -555,10 +584,10 @@ function MilestoneCallout({
|
|||
{showCompletedHint && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button size="xs" onClick={onCompareInChat}>
|
||||
Compare in Chat
|
||||
{t("studio.training.compareInChat")}
|
||||
</Button>
|
||||
<Button asChild={true} size="xs" variant="outline">
|
||||
<Link to="/export">Export Model</Link>
|
||||
<Link to="/export">{t("studio.training.exportModel")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -28,10 +28,7 @@ import { HugeiconsIcon } from "@hugeicons/react";
|
|||
import { useRef } from "react";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
|
||||
const chartConfig = {
|
||||
loss: { label: "Loss", color: "#3b82f6" },
|
||||
} satisfies ChartConfig;
|
||||
import { useT } from "@/i18n";
|
||||
|
||||
const placeholderData = [
|
||||
{ step: 0, loss: 2.5 },
|
||||
|
|
@ -43,6 +40,10 @@ const placeholderData = [
|
|||
];
|
||||
|
||||
export function TrainingSection() {
|
||||
const t = useT();
|
||||
const chartConfig = {
|
||||
loss: { label: t("studio.charts.loss"), color: "#3b82f6" },
|
||||
} satisfies ChartConfig;
|
||||
const store = useTrainingConfigStore();
|
||||
const { isStarting, startError, startTrainingRun } = useTrainingActions();
|
||||
const isLoadingModel = store.isLoadingModelDefaults || store.isCheckingVision;
|
||||
|
|
@ -65,22 +66,39 @@ export function TrainingSection() {
|
|||
try {
|
||||
const config = parseYamlConfig(reader.result as string);
|
||||
store.applyConfigPatch(config);
|
||||
toast.success("Config loaded", { description: file.name });
|
||||
toast.success(t("studio.training.configLoaded"), { description: file.name });
|
||||
} catch (err) {
|
||||
toast.error("Failed to load config", {
|
||||
toast.error(t("studio.training.failedToLoadConfig"), {
|
||||
description:
|
||||
err instanceof Error ? err.message : "Invalid YAML file",
|
||||
err instanceof Error ? err.message : t("studio.training.invalidYamlFile"),
|
||||
});
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to read file");
|
||||
toast.error(t("studio.training.failedToReadFile"));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleSaveConfig = () => {
|
||||
const yamlStr = serializeConfigToYaml(store, store.isVisionModel);
|
||||
// isDatasetImage is null in three windows: before a dataset check
|
||||
// completes, after dataset edits, and on import. Treat all three as
|
||||
// "save it" so the user's choice is never silently dropped while we
|
||||
// wait to confirm the dataset type. Only a confirmed text-only dataset
|
||||
// (=== false) suppresses the vision fields.
|
||||
const includeVisionFields =
|
||||
store.isVisionModel && store.isDatasetImage !== false;
|
||||
// DeepSeek OCR ignores vision_image_size; don't emit it to YAML either,
|
||||
// or a later import on a non-DeepSeek model would activate the stale value.
|
||||
const selectedModelLower = (store.selectedModel ?? "").toLowerCase();
|
||||
const isDeepseekOcr =
|
||||
selectedModelLower.includes("deepseek") &&
|
||||
selectedModelLower.includes("ocr");
|
||||
const yamlStr = serializeConfigToYaml(
|
||||
store,
|
||||
includeVisionFields,
|
||||
includeVisionFields && !isDeepseekOcr,
|
||||
);
|
||||
const blob = new Blob([yamlStr], { type: "text/yaml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
|
|
@ -98,15 +116,15 @@ export function TrainingSection() {
|
|||
|
||||
const handleResetConfig = () => {
|
||||
store.resetToModelDefaults();
|
||||
toast.success("Parameters reset to model defaults");
|
||||
toast.success(t("studio.training.parametersReset"));
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-tour="studio-training" className="min-w-0">
|
||||
<SectionCard
|
||||
icon={<HugeiconsIcon icon={ChartAverageIcon} className="size-5" />}
|
||||
title="Training"
|
||||
description="Monitor and control training"
|
||||
title={t("studio.training.title")}
|
||||
description={t("studio.training.description")}
|
||||
accent="blue"
|
||||
className={hasMessage ? "min-h-studio-config-column" : "h-studio-config-column"}
|
||||
>
|
||||
|
|
@ -147,10 +165,10 @@ export function TrainingSection() {
|
|||
className="size-5 text-muted-foreground/50"
|
||||
/>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
No training data yet
|
||||
{t("studio.training.chartNoDataTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Start training to see loss progress
|
||||
{t("studio.training.chartNoDataDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -163,7 +181,13 @@ export function TrainingSection() {
|
|||
disabled={isStarting || isIncompatible || store.isCheckingDataset || isLoadingModel || !configValidation.ok}
|
||||
>
|
||||
<HugeiconsIcon icon={Rocket01Icon} className="size-4" />
|
||||
{isStarting ? "Starting..." : isLoadingModel ? "Loading model..." : store.isCheckingDataset ? "Checking dataset..." : "Start Training"}
|
||||
{isStarting
|
||||
? t("studio.training.starting")
|
||||
: isLoadingModel
|
||||
? t("studio.training.loadingModel")
|
||||
: store.isCheckingDataset
|
||||
? t("studio.training.checkingDataset")
|
||||
: t("studio.training.startTraining")}
|
||||
</Button>
|
||||
{startError && (
|
||||
<p className="text-xs text-red-500 leading-relaxed">{startError}</p>
|
||||
|
|
@ -171,8 +195,8 @@ export function TrainingSection() {
|
|||
{isIncompatible && (
|
||||
<p className="text-xs text-red-500 leading-relaxed">
|
||||
{!store.isAudioModel && store.isDatasetAudio === true
|
||||
? "This model does not support audio. Switch to an audio-capable model or choose a non-audio dataset."
|
||||
: "Text model is not compatible with a multimodal dataset. Switch to a vision model or choose a text-only dataset."}
|
||||
? t("studio.training.audioIncompatible")
|
||||
: t("studio.training.visionIncompatible")}
|
||||
</p>
|
||||
)}
|
||||
{!configValidation.ok && configValidation.message && !isIncompatible && (
|
||||
|
|
@ -180,7 +204,7 @@ export function TrainingSection() {
|
|||
)}
|
||||
|
||||
{/* Upload / Save / Reset */}
|
||||
<p className="text-xs text-muted-foreground">Training Config</p>
|
||||
<p className="text-xs text-muted-foreground">{t("studio.training.configLabel")}</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -191,10 +215,10 @@ export function TrainingSection() {
|
|||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<HugeiconsIcon icon={CloudUploadIcon} className="size-3.5" />
|
||||
Upload
|
||||
{t("studio.training.upload")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Load a saved YAML config</TooltipContent>
|
||||
<TooltipContent>{t("studio.training.uploadConfigTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -206,10 +230,10 @@ export function TrainingSection() {
|
|||
onClick={handleSaveConfig}
|
||||
>
|
||||
<HugeiconsIcon icon={Archive04Icon} className="size-3.5" />
|
||||
Save
|
||||
{t("studio.training.save")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Download current config as YAML</TooltipContent>
|
||||
<TooltipContent>{t("studio.training.saveConfigTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -221,10 +245,10 @@ export function TrainingSection() {
|
|||
disabled={!store.selectedModel}
|
||||
>
|
||||
<HugeiconsIcon icon={CleanIcon} className="size-3.5" />
|
||||
Reset
|
||||
{t("studio.training.reset")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reset to model defaults</TooltipContent>
|
||||
<TooltipContent>{t("studio.training.resetConfigTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -24,8 +24,10 @@ import { TrainingSection } from "./sections/training-section";
|
|||
import { LiveTrainingView } from "./live-training-view";
|
||||
import { HistoricalTrainingView } from "./historical-training-view";
|
||||
import { HistoryCardGrid } from "./history-card-grid";
|
||||
import { useT } from "@/i18n";
|
||||
|
||||
export function StudioPage(): ReactElement {
|
||||
const t = useT();
|
||||
useTrainingRuntimeLifecycle();
|
||||
const showTrainingView = useTrainingRuntimeStore(shouldShowTrainingView);
|
||||
const isTrainingRunning = useTrainingRuntimeStore((state) => state.isTrainingRunning);
|
||||
|
|
@ -120,10 +122,13 @@ export function StudioPage(): ReactElement {
|
|||
}
|
||||
|
||||
const subtitle = (() => {
|
||||
if (activeTab === "current-run") return runtimeMessage || "Training in progress";
|
||||
if (activeTab === "current-run")
|
||||
return runtimeMessage || t("studio.subtitles.trainingInProgress");
|
||||
if (activeTab === "history")
|
||||
return selectedHistoryRunId ? "Viewing past run" : "View past training runs";
|
||||
return "Configure and start training";
|
||||
return selectedHistoryRunId
|
||||
? t("studio.subtitles.viewingPastRun")
|
||||
: t("studio.subtitles.viewPastRuns");
|
||||
return t("studio.subtitles.configure");
|
||||
})();
|
||||
|
||||
return (
|
||||
|
|
@ -150,14 +155,14 @@ export function StudioPage(): ReactElement {
|
|||
|
||||
<div className="mb-6 flex flex-col gap-0.5 sm:mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Fine-tuning Studio
|
||||
{t("studio.title")}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
|
||||
{!hasHydratedRuntime && isHydratingRuntime ? (
|
||||
<div className="rounded-xl border bg-card p-8 text-sm text-muted-foreground">
|
||||
Loading training runtime...
|
||||
{t("studio.loadingRuntime")}
|
||||
</div>
|
||||
) : (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
|
|
@ -168,19 +173,19 @@ export function StudioPage(): ReactElement {
|
|||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground"
|
||||
onClick={() => setSelectedHistoryRunId(null)}
|
||||
aria-label="Back to history"
|
||||
aria-label={t("studio.backToHistory")}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="configure" disabled={isTrainingRunning}>
|
||||
Configure
|
||||
{t("studio.tabs.configure")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="current-run" disabled={!showTrainingView}>
|
||||
Current Run
|
||||
{t("studio.tabs.currentRun")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history">History</TabsTrigger>
|
||||
<TabsTrigger value="history">{t("studio.tabs.history")}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
import { Cancel01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEffect, useState, type ReactElement } from "react";
|
||||
import { useT } from "@/i18n";
|
||||
|
||||
const HF_REPO_REGEX = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
||||
|
||||
|
|
@ -171,6 +172,7 @@ type DownloadRowProps = {
|
|||
};
|
||||
|
||||
function DownloadRow({ label, state }: DownloadRowProps): ReactElement | null {
|
||||
const t = useT();
|
||||
// Compute a rolling-window rate + ETA from the same cumulative-byte
|
||||
// series the poll hook already produces, so we can show
|
||||
// "5.2 / 20.7 GB • 85.3 MB/s • 3m 12s left" instead of just the pair.
|
||||
|
|
@ -179,22 +181,25 @@ function DownloadRow({ label, state }: DownloadRowProps): ReactElement | null {
|
|||
if (state.downloadedBytes <= 0 && !state.cachePath) return null;
|
||||
const isComplete = state.totalBytes > 0 && state.percent >= 100;
|
||||
const statusLabel = isComplete
|
||||
? "Ready"
|
||||
? t("studio.trainingStart.ready")
|
||||
: state.totalBytes > 0
|
||||
? "Downloading"
|
||||
? t("studio.trainingStart.downloading")
|
||||
: state.downloadedBytes === 0
|
||||
? "Preparing"
|
||||
? t("studio.trainingStart.preparing")
|
||||
: null;
|
||||
const showRate = stats.stable && !isComplete;
|
||||
const rateSuffix = showRate ? ` • ${formatRate(stats.rateBytesPerSecond)}` : "";
|
||||
const etaStr =
|
||||
showRate && state.totalBytes > 0 ? formatEta(stats.etaSeconds) : "--";
|
||||
const etaSuffix = etaStr !== "--" ? ` • ${etaStr} left` : "";
|
||||
const etaSuffix =
|
||||
etaStr !== "--" ? ` • ${t("studio.trainingStart.left", { eta: etaStr })}` : "";
|
||||
const sizeLabel =
|
||||
state.totalBytes > 0
|
||||
? `${formatBytes(state.downloadedBytes)} / ${formatBytes(state.totalBytes)}${rateSuffix}${etaSuffix}`
|
||||
: state.downloadedBytes > 0
|
||||
? `${formatBytes(state.downloadedBytes)} downloaded${rateSuffix}`
|
||||
? `${t("studio.trainingStart.downloaded", {
|
||||
size: formatBytes(state.downloadedBytes),
|
||||
})}${rateSuffix}`
|
||||
: null;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 rounded-md border border-border/50 bg-muted/20 px-3 py-2">
|
||||
|
|
@ -245,6 +250,7 @@ export function TrainingStartOverlay({
|
|||
message,
|
||||
currentStep,
|
||||
}: TrainingStartOverlayProps): ReactElement {
|
||||
const t = useT();
|
||||
const { stopTrainingRun, dismissTrainingRun } = useTrainingActions();
|
||||
const isStarting = useTrainingRuntimeStore((s) => s.isStarting);
|
||||
const phase = useTrainingRuntimeStore((s) => s.phase);
|
||||
|
|
@ -273,8 +279,8 @@ export function TrainingStartOverlay({
|
|||
: null;
|
||||
const displayMessage =
|
||||
startFromResume && !isDownloadPhase && /^download/i.test(message)
|
||||
? "Resuming training..."
|
||||
: message || "starting training...";
|
||||
? t("studio.trainingStart.resumingTraining")
|
||||
: message || t("studio.trainingStart.startingTraining");
|
||||
const rawModelDownload = useModelDownloadProgress(modelName);
|
||||
const rawDatasetDownload = useDatasetDownloadProgress(datasetName);
|
||||
const modelDownload = isDownloadPhase
|
||||
|
|
@ -297,7 +303,7 @@ export function TrainingStartOverlay({
|
|||
<div className="pointer-events-auto relative flex w-[860px] max-w-[calc(100%-2rem)] flex-col items-center gap-4">
|
||||
<img
|
||||
src="/unsloth-gem.png"
|
||||
alt="Unsloth mascot"
|
||||
alt="Unsloth Studio"
|
||||
className="size-24 object-contain"
|
||||
/>
|
||||
<div className="relative w-full">
|
||||
|
|
@ -313,13 +319,13 @@ export function TrainingStartOverlay({
|
|||
</Button>
|
||||
<AlertDialogContent overlayClassName="bg-background/40 supports-backdrop-filter:backdrop-blur-[1px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel Training</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("studio.training.cancelTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Do you want to cancel the current training run?
|
||||
{t("studio.training.cancelDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Continue Training</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t("studio.training.continueAction")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
|
|
@ -335,7 +341,7 @@ export function TrainingStartOverlay({
|
|||
});
|
||||
}}
|
||||
>
|
||||
Cancel Training
|
||||
{t("studio.training.cancelAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
@ -348,24 +354,27 @@ export function TrainingStartOverlay({
|
|||
duration={36}
|
||||
className="bg-gradient-to-r from-emerald-300 via-lime-300 to-teal-300 bg-clip-text font-semibold text-transparent"
|
||||
>
|
||||
{"> unsloth training starts..."}
|
||||
{t("studio.trainingStart.terminalStart")}
|
||||
</TypingAnimation>
|
||||
<AnimatedSpan className="my-2">
|
||||
<pre className="whitespace-pre text-muted-foreground inline-block">{`==((====))==\n \\\\ /|\nO^O/ \\_/ \\\n\\ /\n "-____-"`}</pre>
|
||||
</AnimatedSpan>
|
||||
<TypingAnimation duration={44}>
|
||||
{"> Preparing model and dataset..."}
|
||||
{t("studio.trainingStart.preparingResources")}
|
||||
</TypingAnimation>
|
||||
<TypingAnimation duration={44}>
|
||||
{"> We are getting everything ready for your run..."}
|
||||
{t("studio.trainingStart.gettingReady")}
|
||||
</TypingAnimation>
|
||||
<AnimatedSpan className="mt-2 text-muted-foreground">
|
||||
{`> ${displayMessage} | waiting for first step... (${currentStep})`}
|
||||
{t("studio.trainingStart.waitingForFirstStep", {
|
||||
message: displayMessage,
|
||||
step: currentStep,
|
||||
})}
|
||||
</AnimatedSpan>
|
||||
{datasetDownload.downloadedBytes > 0 || datasetDownload.cachePath ? (
|
||||
<AnimatedSpan className="mt-3">
|
||||
<DownloadRow
|
||||
label="Dataset"
|
||||
label={t("studio.trainingStart.dataset")}
|
||||
state={datasetDownload}
|
||||
/>
|
||||
</AnimatedSpan>
|
||||
|
|
@ -373,7 +382,7 @@ export function TrainingStartOverlay({
|
|||
{modelDownload.downloadedBytes > 0 || modelDownload.cachePath ? (
|
||||
<AnimatedSpan className="mt-3">
|
||||
<DownloadRow
|
||||
label="Model weights"
|
||||
label={t("studio.trainingStart.modelWeights")}
|
||||
state={modelDownload}
|
||||
/>
|
||||
</AnimatedSpan>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,12 @@ export function buildTrainingStartPayload(
|
|||
const isCpt = config.trainingMethod === "cpt";
|
||||
const adapterMethod = config.trainingMethod !== "full";
|
||||
const isQloraMethod = config.trainingMethod === "qlora";
|
||||
const isFourBitModel = (config.selectedModel ?? "").toLowerCase().includes("4bit");
|
||||
const _selectedModelLower = (config.selectedModel ?? "").toLowerCase();
|
||||
const isFourBitModel = _selectedModelLower.includes("4bit");
|
||||
// DeepSeek OCR ignores user-selected image size; do not send it.
|
||||
const isDeepseekOcr =
|
||||
_selectedModelLower.includes("deepseek") &&
|
||||
_selectedModelLower.includes("ocr");
|
||||
const isEmbedding = config.isEmbeddingModel;
|
||||
const isRawText = isRawTextDatasetFormat(config.datasetFormat);
|
||||
const hfDataset = config.datasetSource === "huggingface" ? config.dataset : null;
|
||||
|
|
@ -55,6 +60,10 @@ export function buildTrainingStartPayload(
|
|||
hf_token: config.hfToken.trim() || null,
|
||||
load_in_4bit: (adapterMethod && isQloraMethod) || (isCpt && isFourBitModel),
|
||||
max_seq_length: config.contextLength,
|
||||
vision_image_size:
|
||||
config.isVisionModel && config.isDatasetImage === true && !isDeepseekOcr
|
||||
? config.visionImageSize
|
||||
: null,
|
||||
trust_remote_code: config.trustRemoteCode ?? false,
|
||||
hf_dataset: hfDataset,
|
||||
subset: hfDataset ? config.datasetSubset : null,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ interface BackendTrainingDefaults {
|
|||
eval_steps?: number;
|
||||
weight_decay?: number;
|
||||
random_seed?: number;
|
||||
vision_image_size?: number | string | null;
|
||||
packing?: boolean;
|
||||
train_on_completions?: boolean;
|
||||
gradient_checkpointing?: "none" | "true" | "unsloth";
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ export {
|
|||
export { useTrainingActions } from "./hooks/use-training-actions";
|
||||
export { useTrainingHistorySidebarItems } from "./hooks/use-training-history-sidebar";
|
||||
export { useTrainingRuntimeLifecycle } from "./hooks/use-training-runtime-lifecycle";
|
||||
export { removeTrainingUnloadGuard } from "./hooks/use-training-unload-guard";
|
||||
export {
|
||||
removeTrainingUnloadGuard,
|
||||
useTrainingUnloadGuard,
|
||||
} from "./hooks/use-training-unload-guard";
|
||||
export { useMaxStepsEpochsToggle } from "./hooks/use-max-steps-epochs-toggle";
|
||||
export { HfDatasetSubsetSplitSelectors } from "./components/hf-dataset-subset-split-selectors";
|
||||
export { useDatasetPreviewDialogStore } from "./stores/dataset-preview-dialog-store";
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ type ModelDefaultsPatch = Partial<
|
|||
| "trainOnCompletions"
|
||||
| "gradientCheckpointing"
|
||||
| "randomSeed"
|
||||
| "visionImageSize"
|
||||
| "enableWandb"
|
||||
| "wandbProject"
|
||||
| "enableTensorboard"
|
||||
|
|
@ -129,6 +130,25 @@ export function mapBackendModelConfigToTrainingPatch(
|
|||
const randomSeed = toNumber(training?.random_seed);
|
||||
if (randomSeed !== undefined) patch.randomSeed = randomSeed;
|
||||
|
||||
// Only patch when the config carries the key; model-switch reset lives in
|
||||
// setSelectedModel so same-model reloads don't wipe a user's choice.
|
||||
if (Object.hasOwn(training ?? {}, "vision_image_size")) {
|
||||
const raw = training?.vision_image_size;
|
||||
if (raw == null) {
|
||||
patch.visionImageSize = null;
|
||||
} else {
|
||||
// Mirror studio/backend/models/training.py:_check_vision_image_size:
|
||||
// drop anything outside [_MIN_VISION_IMAGE_SIZE, _MAX_VISION_IMAGE_SIZE]
|
||||
// so the store/UI never show a value the backend would reject.
|
||||
const n = toNumber(raw);
|
||||
if (n !== undefined && Number.isInteger(n) && n >= 256 && n <= 2048) {
|
||||
patch.visionImageSize = n;
|
||||
} else {
|
||||
patch.visionImageSize = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const packing = toBoolean(training?.packing);
|
||||
if (packing !== undefined) patch.packing = packing;
|
||||
|
||||
|
|
|
|||
|
|
@ -27,8 +27,27 @@ export function parseYamlConfig(text: string): BackendModelConfig {
|
|||
console.warn("Ignored unknown YAML keys:", unknownKeys.join(", "));
|
||||
}
|
||||
|
||||
// File import is authoritative: forge vision_image_size = null when the
|
||||
// training section is missing, malformed, or missing the key, so a stale
|
||||
// store value cannot survive an import. (Same-model defaults reloads
|
||||
// preserve user choice via Object.hasOwn in model-defaults.ts.)
|
||||
const rawTraining = raw.training;
|
||||
const isPlainTrainingObject =
|
||||
rawTraining != null &&
|
||||
typeof rawTraining === "object" &&
|
||||
!Array.isArray(rawTraining);
|
||||
let trainingObj: Record<string, unknown>;
|
||||
if (!isPlainTrainingObject) {
|
||||
trainingObj = { vision_image_size: null };
|
||||
} else {
|
||||
trainingObj = { ...(rawTraining as Record<string, unknown>) };
|
||||
if (!Object.hasOwn(trainingObj, "vision_image_size")) {
|
||||
trainingObj.vision_image_size = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
training: (raw.training ?? undefined) as BackendModelConfig["training"],
|
||||
training: trainingObj as BackendModelConfig["training"],
|
||||
lora: (raw.lora ?? undefined) as BackendModelConfig["lora"],
|
||||
logging: (raw.logging ?? undefined) as BackendModelConfig["logging"],
|
||||
};
|
||||
|
|
@ -41,6 +60,7 @@ export function parseYamlConfig(text: string): BackendModelConfig {
|
|||
export function serializeConfigToYaml(
|
||||
state: TrainingConfigState,
|
||||
includeVisionFields: boolean,
|
||||
includeVisionImageSize: boolean = includeVisionFields,
|
||||
): string {
|
||||
const lora: Record<string, unknown> = {
|
||||
lora_r: state.loraRank,
|
||||
|
|
@ -58,25 +78,31 @@ export function serializeConfigToYaml(
|
|||
lora.finetune_mlp_modules = state.finetuneMLPModules;
|
||||
}
|
||||
|
||||
const training: Record<string, unknown> = {
|
||||
max_seq_length: state.contextLength,
|
||||
num_epochs: state.epochs,
|
||||
learning_rate: state.learningRate,
|
||||
batch_size: state.batchSize,
|
||||
gradient_accumulation_steps: state.gradientAccumulation,
|
||||
warmup_steps: state.warmupSteps,
|
||||
max_steps: state.maxSteps,
|
||||
save_steps: state.saveSteps,
|
||||
eval_steps: state.evalSteps,
|
||||
weight_decay: state.weightDecay,
|
||||
random_seed: state.randomSeed,
|
||||
packing: state.packing,
|
||||
train_on_completions: state.trainOnCompletions,
|
||||
gradient_checkpointing: state.gradientCheckpointing,
|
||||
optim: state.optimizerType,
|
||||
lr_scheduler_type: state.lrSchedulerType,
|
||||
};
|
||||
|
||||
if (includeVisionImageSize) {
|
||||
training.vision_image_size = state.visionImageSize;
|
||||
}
|
||||
|
||||
const config = {
|
||||
training: {
|
||||
max_seq_length: state.contextLength,
|
||||
num_epochs: state.epochs,
|
||||
learning_rate: state.learningRate,
|
||||
batch_size: state.batchSize,
|
||||
gradient_accumulation_steps: state.gradientAccumulation,
|
||||
warmup_steps: state.warmupSteps,
|
||||
max_steps: state.maxSteps,
|
||||
save_steps: state.saveSteps,
|
||||
eval_steps: state.evalSteps,
|
||||
weight_decay: state.weightDecay,
|
||||
random_seed: state.randomSeed,
|
||||
packing: state.packing,
|
||||
train_on_completions: state.trainOnCompletions,
|
||||
gradient_checkpointing: state.gradientCheckpointing,
|
||||
optim: state.optimizerType,
|
||||
lr_scheduler_type: state.lrSchedulerType,
|
||||
},
|
||||
training,
|
||||
lora,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -390,6 +390,8 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
|
|||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to load model defaults",
|
||||
// Defaults load failed; reset so no prior model's value lingers.
|
||||
visionImageSize: DEFAULT_HYPERPARAMS.visionImageSize,
|
||||
});
|
||||
|
||||
// Fallback vision check if config endpoint fails.
|
||||
|
|
@ -498,7 +500,16 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
|
|||
},
|
||||
setSelectedModel: (selectedModel) => {
|
||||
const previousModel = get().selectedModel;
|
||||
set({ selectedModel, modelDefaultsError: null });
|
||||
// Reset vision_image_size on a true switch only; same-model reloads
|
||||
// go through the mapper, which preserves the user's choice.
|
||||
const patch: { selectedModel: string | null; modelDefaultsError: null; visionImageSize?: number | null } = {
|
||||
selectedModel,
|
||||
modelDefaultsError: null,
|
||||
};
|
||||
if (selectedModel !== previousModel) {
|
||||
patch.visionImageSize = DEFAULT_HYPERPARAMS.visionImageSize;
|
||||
}
|
||||
set(patch);
|
||||
|
||||
if (!selectedModel) {
|
||||
_modelConfigController?.abort();
|
||||
|
|
@ -701,6 +712,7 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
|
|||
}),
|
||||
setEpochs: (epochs) => set({ epochs }),
|
||||
setContextLength: (contextLength) => set({ contextLength }),
|
||||
setVisionImageSize: (visionImageSize) => set({ visionImageSize }),
|
||||
setLearningRate: (learningRate) => {
|
||||
_learningRateManuallySet = true;
|
||||
set({ learningRate });
|
||||
|
|
@ -755,7 +767,10 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
|
|||
resetToModelDefaults: () => {
|
||||
const { selectedModel } = get();
|
||||
if (!selectedModel) return;
|
||||
set({ modelDefaultsAppliedFor: null });
|
||||
set({
|
||||
modelDefaultsAppliedFor: null,
|
||||
visionImageSize: DEFAULT_HYPERPARAMS.visionImageSize,
|
||||
});
|
||||
loadAndApplyModelDefaults(selectedModel);
|
||||
},
|
||||
applyConfigPatch: (config: BackendModelConfig) => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface TrainingStartRequest {
|
|||
hf_token: string | null;
|
||||
load_in_4bit: boolean;
|
||||
max_seq_length: number;
|
||||
vision_image_size?: number | null;
|
||||
/** Allow loading models with custom code. Only enable for repos you trust. */
|
||||
trust_remote_code?: boolean;
|
||||
hf_dataset: string | null;
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export interface TrainingConfigState {
|
|||
finetuneMLPModules: boolean;
|
||||
targetModules: string[];
|
||||
maxPositionEmbeddings: number | null;
|
||||
visionImageSize: number | null;
|
||||
}
|
||||
|
||||
export interface TrainingConfigActions {
|
||||
|
|
@ -115,6 +116,7 @@ export interface TrainingConfigActions {
|
|||
setUploadedEvalFile: (file: string | null) => void;
|
||||
setEpochs: (epochs: number) => void;
|
||||
setContextLength: (length: number) => void;
|
||||
setVisionImageSize: (size: number | null) => void;
|
||||
setLearningRate: (rate: number) => void;
|
||||
setEmbeddingLearningRate: (rate: number | null) => void;
|
||||
setOptimizerType: (value: string) => void;
|
||||
|
|
|
|||
11
studio/frontend/src/i18n/AGENTS.md
Normal file
11
studio/frontend/src/i18n/AGENTS.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# i18n Contribution Instructions
|
||||
|
||||
- `locales/en.ts` is the complete baseline message file.
|
||||
- Non-English locale files may be partial. Missing keys must fall back to English at runtime.
|
||||
- Use BCP 47 locale tags for new languages, for example `zh-CN`, `ja-JP`, and `ko-KR`.
|
||||
- Do not change fallback logic to hide missing translations.
|
||||
- Do not add automatic DOM translation, MutationObserver text replacement, or runtime guess-based translation.
|
||||
- Preserve interpolation variables exactly, for example `{count}`, `{model}`, and `{provider}`.
|
||||
- Keep product and technical names unchanged unless there is an established localized name, for example `Unsloth Studio`, `LoRA`, `GGUF`, and `Hugging Face`.
|
||||
- Keep translation changes small and reviewable. Prefer separate commits for runtime changes, UI migration, and locale text.
|
||||
- When adding user-facing Studio UI text, add the English message key first and add non-English overrides only when the translation is clear.
|
||||
111
studio/frontend/src/i18n/check-parity.ts
Normal file
111
studio/frontend/src/i18n/check-parity.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
// Parity check between en.ts and every non-English locale.
|
||||
// - Locale files may be partial; missing keys must fall back to English.
|
||||
// - All zh-CN keys must exist in en (no extras).
|
||||
// - Placeholder set must match per leaf between en and the overlay.
|
||||
//
|
||||
// Run: npx tsx src/i18n/check-parity.ts
|
||||
|
||||
import { en } from "./locales/en.ts";
|
||||
import { zhCN } from "./locales/zh-CN.ts";
|
||||
|
||||
type Tree = { readonly [k: string]: string | Tree };
|
||||
|
||||
function isTree(v: unknown): v is Tree {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function placeholders(s: string): string[] {
|
||||
const out: string[] = [];
|
||||
const re = /\{([a-zA-Z0-9_]+)\}/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(s))) out.push(m[1]);
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
function checkOverlay(
|
||||
enNode: Tree,
|
||||
overlay: Tree | undefined,
|
||||
path: string,
|
||||
errors: string[],
|
||||
missing: string[],
|
||||
): void {
|
||||
for (const [k, v] of Object.entries(enNode)) {
|
||||
const subPath = path ? `${path}.${k}` : k;
|
||||
if (typeof v === "string") {
|
||||
if (overlay === undefined) {
|
||||
missing.push(subPath);
|
||||
continue;
|
||||
}
|
||||
const overlayV = overlay[k];
|
||||
if (overlayV === undefined) {
|
||||
missing.push(subPath);
|
||||
continue;
|
||||
}
|
||||
if (typeof overlayV !== "string") {
|
||||
errors.push(`${subPath} should be string, got ${typeof overlayV}`);
|
||||
continue;
|
||||
}
|
||||
const enP = placeholders(v);
|
||||
const ovP = placeholders(overlayV);
|
||||
if (JSON.stringify(enP) !== JSON.stringify(ovP)) {
|
||||
errors.push(
|
||||
`${subPath}: placeholder mismatch en={${enP.join(",")}} overlay={${ovP.join(",")}}`,
|
||||
);
|
||||
}
|
||||
} else if (isTree(v)) {
|
||||
const overlaySub = overlay === undefined ? undefined : overlay[k];
|
||||
if (overlaySub !== undefined && !isTree(overlaySub)) {
|
||||
errors.push(`${subPath} should be an object, got ${typeof overlaySub}`);
|
||||
continue;
|
||||
}
|
||||
checkOverlay(v, overlaySub, subPath, errors, missing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkExtras(
|
||||
overlay: Tree,
|
||||
enNode: Tree,
|
||||
path: string,
|
||||
errors: string[],
|
||||
): void {
|
||||
for (const [k, v] of Object.entries(overlay)) {
|
||||
const subPath = path ? `${path}.${k}` : k;
|
||||
if (!(k in enNode)) {
|
||||
errors.push(`${subPath} exists in overlay but not in en`);
|
||||
continue;
|
||||
}
|
||||
const enV = enNode[k];
|
||||
if (isTree(v) && isTree(enV)) {
|
||||
checkExtras(v, enV, subPath, errors);
|
||||
} else if (isTree(v) !== isTree(enV)) {
|
||||
errors.push(`${subPath}: shape mismatch (en=${typeof enV}, overlay=${typeof v})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const overlays: Record<string, Tree> = { "zh-CN": zhCN as unknown as Tree };
|
||||
let anyError = false;
|
||||
|
||||
for (const [locale, overlay] of Object.entries(overlays)) {
|
||||
const errors: string[] = [];
|
||||
const missing: string[] = [];
|
||||
checkOverlay(en as unknown as Tree, overlay, "", errors, missing);
|
||||
checkExtras(overlay, en as unknown as Tree, "", errors);
|
||||
|
||||
console.log(`\n=== ${locale} ===`);
|
||||
console.log(`Missing keys (will fall back to en): ${missing.length}`);
|
||||
if (errors.length) {
|
||||
anyError = true;
|
||||
console.error(`Errors (${errors.length}):`);
|
||||
for (const e of errors) console.error(` - ${e}`);
|
||||
} else {
|
||||
console.log("No errors.");
|
||||
}
|
||||
}
|
||||
|
||||
if (anyError) process.exit(1);
|
||||
console.log("\nAll locale overlays pass parity.");
|
||||
44
studio/frontend/src/i18n/index.ts
Normal file
44
studio/frontend/src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// 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 { useCallback } from "react";
|
||||
import { useLocale } from "./locale-store";
|
||||
import { translate } from "./messages";
|
||||
import type { InterpolationValues } from "./types";
|
||||
import type { TranslationKey } from "./messages";
|
||||
|
||||
export {
|
||||
DEFAULT_LOCALE,
|
||||
LOCALE_STORAGE_KEY,
|
||||
getLocale,
|
||||
initializeLocale,
|
||||
setLocale,
|
||||
subscribeLocale,
|
||||
useLocale,
|
||||
} from "./locale-store";
|
||||
export {
|
||||
LOCALES,
|
||||
isSupportedLocale,
|
||||
messages,
|
||||
translate,
|
||||
} from "./messages";
|
||||
export type { Locale, TranslationKey } from "./messages";
|
||||
export type {
|
||||
DeepPartialMessageTree,
|
||||
InterpolationValues,
|
||||
MessageKey,
|
||||
MessageTree,
|
||||
} from "./types";
|
||||
|
||||
export function useT(): (
|
||||
key: TranslationKey,
|
||||
values?: InterpolationValues,
|
||||
) => string {
|
||||
const locale = useLocale();
|
||||
|
||||
return useCallback(
|
||||
(key: TranslationKey, values?: InterpolationValues) =>
|
||||
translate(key, values, locale),
|
||||
[locale],
|
||||
);
|
||||
}
|
||||
130
studio/frontend/src/i18n/locale-store.ts
Normal file
130
studio/frontend/src/i18n/locale-store.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// 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";
|
||||
import { isSupportedLocale, type Locale } from "./messages";
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = "en";
|
||||
export const LOCALE_STORAGE_KEY = "unsloth_locale";
|
||||
|
||||
const subscribers = new Set<() => void>();
|
||||
|
||||
let currentLocale: Locale = DEFAULT_LOCALE;
|
||||
let isStorageListenerActive = false;
|
||||
|
||||
function normalizeLocale(value: unknown): Locale {
|
||||
return isSupportedLocale(value) ? value : DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
function readStoredLocale(): Locale {
|
||||
try {
|
||||
const stored = globalThis.localStorage?.getItem(LOCALE_STORAGE_KEY) ?? null;
|
||||
return normalizeLocale(stored);
|
||||
} catch {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredLocale(locale: Locale): void {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(LOCALE_STORAGE_KEY, locale);
|
||||
} catch {
|
||||
// localStorage 可能被禁用;失败只影响持久化,不影响当前会话语言。
|
||||
}
|
||||
}
|
||||
|
||||
function syncDocumentLang(locale: Locale): void {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.lang = locale;
|
||||
}
|
||||
|
||||
function notifySubscribers(): void {
|
||||
for (const subscriber of subscribers) subscriber();
|
||||
}
|
||||
|
||||
function updateCurrentLocale(locale: Locale): void {
|
||||
if (locale === currentLocale) return;
|
||||
currentLocale = locale;
|
||||
syncDocumentLang(locale);
|
||||
notifySubscribers();
|
||||
}
|
||||
|
||||
function isLocaleStorageEvent(event: StorageEvent): boolean {
|
||||
if (event.key !== LOCALE_STORAGE_KEY && event.key !== null) return false;
|
||||
if (!event.storageArea || typeof window === "undefined") return true;
|
||||
// Accessing window.localStorage can throw in privacy-restricted contexts
|
||||
// where storage is blocked; mirror the try/catch in readStoredLocale/
|
||||
// writeStoredLocale so storage-event handling is just as resilient.
|
||||
try {
|
||||
return event.storageArea === window.localStorage;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleStorageEvent(event: StorageEvent): void {
|
||||
if (!isLocaleStorageEvent(event)) return;
|
||||
const nextLocale =
|
||||
event.key === null ? DEFAULT_LOCALE : normalizeLocale(event.newValue);
|
||||
updateCurrentLocale(nextLocale);
|
||||
}
|
||||
|
||||
function startStorageListener(): void {
|
||||
if (isStorageListenerActive || typeof window === "undefined") return;
|
||||
window.addEventListener("storage", handleStorageEvent);
|
||||
isStorageListenerActive = true;
|
||||
}
|
||||
|
||||
function stopStorageListener(): void {
|
||||
if (!isStorageListenerActive || typeof window === "undefined") return;
|
||||
window.removeEventListener("storage", handleStorageEvent);
|
||||
isStorageListenerActive = false;
|
||||
}
|
||||
|
||||
function getLocaleSnapshot(): Locale {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
function getServerLocaleSnapshot(): Locale {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function subscribeLocale(listener: () => void): () => void {
|
||||
const shouldStartStorageListener = subscribers.size === 0;
|
||||
subscribers.add(listener);
|
||||
if (shouldStartStorageListener) startStorageListener();
|
||||
|
||||
return () => {
|
||||
subscribers.delete(listener);
|
||||
if (subscribers.size === 0) stopStorageListener();
|
||||
};
|
||||
}
|
||||
|
||||
export function initializeLocale(): Locale {
|
||||
const nextLocale = readStoredLocale();
|
||||
currentLocale = nextLocale;
|
||||
syncDocumentLang(nextLocale);
|
||||
notifySubscribers();
|
||||
return nextLocale;
|
||||
}
|
||||
|
||||
export function getLocale(): Locale {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
export function setLocale(locale: Locale): void {
|
||||
const requestedLocale = normalizeLocale(locale);
|
||||
writeStoredLocale(requestedLocale);
|
||||
|
||||
currentLocale = requestedLocale;
|
||||
syncDocumentLang(requestedLocale);
|
||||
notifySubscribers();
|
||||
}
|
||||
|
||||
export function useLocale(): Locale {
|
||||
return useSyncExternalStore(
|
||||
subscribeLocale,
|
||||
getLocaleSnapshot,
|
||||
getServerLocaleSnapshot,
|
||||
);
|
||||
}
|
||||
731
studio/frontend/src/i18n/locales/en.ts
Normal file
731
studio/frontend/src/i18n/locales/en.ts
Normal file
|
|
@ -0,0 +1,731 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export const en = {
|
||||
common: {
|
||||
cancel: "Cancel",
|
||||
close: "Close",
|
||||
delete: "Delete",
|
||||
done: "Done",
|
||||
error: "Error",
|
||||
export: "Export",
|
||||
help: "Help",
|
||||
loading: "Loading...",
|
||||
new: "New",
|
||||
rename: "Rename",
|
||||
save: "Save",
|
||||
search: "Search",
|
||||
shutdown: "Shutdown",
|
||||
},
|
||||
shell: {
|
||||
beta: "BETA",
|
||||
brand: "unsloth",
|
||||
product: "Unsloth Studio",
|
||||
accountMenu: "{name} account menu",
|
||||
aria: {
|
||||
home: "Unsloth home",
|
||||
closeSidebar: "Close sidebar",
|
||||
openSidebar: "Open sidebar",
|
||||
chatOptions: "Chat options",
|
||||
runOptions: "Run options",
|
||||
},
|
||||
navigation: {
|
||||
newChat: "New Chat",
|
||||
compare: "Compare",
|
||||
search: "Search",
|
||||
train: "Train",
|
||||
recipes: "Recipes",
|
||||
export: "Export",
|
||||
recents: "Recents",
|
||||
settings: "Settings",
|
||||
api: "API",
|
||||
lightMode: "Light Mode",
|
||||
darkMode: "Dark Mode",
|
||||
guidedTour: "Guided Tour",
|
||||
help: "Help",
|
||||
logOut: "Log out",
|
||||
shutdown: "Shutdown",
|
||||
},
|
||||
notFound: {
|
||||
title: "Page not found",
|
||||
description: "{path} does not exist.",
|
||||
backToChat: "Back to chat",
|
||||
},
|
||||
dialog: {
|
||||
deleteChat: {
|
||||
title: "Delete chat",
|
||||
description: "Are you sure you want to delete this chat \"{name}\"?",
|
||||
},
|
||||
deleteRun: {
|
||||
title: "Delete training run",
|
||||
description: "Are you sure you want to delete this run \"{name}\"?",
|
||||
},
|
||||
renameChat: {
|
||||
title: "Rename chat",
|
||||
placeholder: "Chat title",
|
||||
},
|
||||
renameRun: {
|
||||
title: "Rename run",
|
||||
placeholder: "Run name",
|
||||
},
|
||||
},
|
||||
toast: {
|
||||
cannotDeleteRunningRun: "Cannot delete a running training run",
|
||||
failedToDeleteChat: "Failed to delete chat",
|
||||
failedToDeleteRun: "Failed to delete run",
|
||||
failedToRenameChat: "Failed to rename chat",
|
||||
failedToRenameRun: "Failed to rename run",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: "Settings",
|
||||
dialog: {
|
||||
title: "Settings",
|
||||
description: "Manage your Unsloth Studio preferences.",
|
||||
closeAriaLabel: "Close settings",
|
||||
},
|
||||
tabs: {
|
||||
general: "General",
|
||||
profile: "Profile",
|
||||
appearance: "Appearance",
|
||||
chat: "Chat",
|
||||
connections: "Connections",
|
||||
apiKeys: "API",
|
||||
about: "Help",
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
description: "Global preferences for Unsloth Studio.",
|
||||
account: "Account",
|
||||
huggingFaceToken: "Hugging Face token",
|
||||
huggingFaceTokenDescription:
|
||||
"Used to load gated models and push artifacts.",
|
||||
hideToken: "Hide token",
|
||||
showToken: "Show token",
|
||||
chatDefaults: "Chat defaults",
|
||||
autoTitleNewChats: "Auto-title new chats",
|
||||
autoTitleNewChatsDescription:
|
||||
"Generate a short title from the first message.",
|
||||
gettingStarted: "Getting started",
|
||||
startOnboarding: "Start onboarding",
|
||||
startOnboardingDescription:
|
||||
"Open the setup wizard again without changing your account.",
|
||||
startOnboardingAction: "Start onboarding",
|
||||
resetPreferences: {
|
||||
sectionTitle: "Danger zone",
|
||||
label: "Reset all local preferences",
|
||||
description:
|
||||
"Clears local-only preferences. Chats, API access, and DB-backed chat settings are not affected.",
|
||||
action: "Reset preferences",
|
||||
confirmTitle: "Reset all local preferences?",
|
||||
confirmDescription:
|
||||
"This clears local-only preferences, then reloads Studio. Chats, API access, and DB-backed chat settings are not affected.",
|
||||
confirmAction: "Reset and reload",
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
title: "Profile",
|
||||
description: "Update how your profile appears in Studio.",
|
||||
changePicture: "Change profile picture",
|
||||
displayName: "Display name",
|
||||
nameSaved: "Profile name saved",
|
||||
namePersistErrorTitle: "Could not persist profile name",
|
||||
namePersistErrorDescription:
|
||||
"Name updated for this session, but may not persist after reload.",
|
||||
photoUpdated: "Profile photo updated",
|
||||
photoPersistErrorTitle: "Could not persist profile photo",
|
||||
photoPersistErrorDescription:
|
||||
"Photo updated for this session, but may not persist after reload.",
|
||||
photoUpdateErrorTitle: "Could not update profile photo",
|
||||
imageUseError: "Could not use this image.",
|
||||
},
|
||||
appearance: {
|
||||
title: "Appearance",
|
||||
description: "How Unsloth Studio looks on this device.",
|
||||
theme: {
|
||||
title: "Theme",
|
||||
label: "Color scheme",
|
||||
description: "Choose light, dark, or follow your system.",
|
||||
system: "System",
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
},
|
||||
language: {
|
||||
title: "Language",
|
||||
label: "Display language",
|
||||
description: "Choose the language used by Studio.",
|
||||
},
|
||||
layout: {
|
||||
title: "Layout",
|
||||
compactSidebar: "Pin sidebar by default",
|
||||
compactSidebarDescription:
|
||||
"Keep the sidebar expanded instead of collapsing to icons.",
|
||||
},
|
||||
},
|
||||
chat: {
|
||||
title: "Chat",
|
||||
description: "Manage your chat history stored on this device.",
|
||||
data: "Data",
|
||||
exportHistory: "Export chat history",
|
||||
exportHistoryDescription:
|
||||
"Download all chats and messages as a JSON file.",
|
||||
exportAction: "Export",
|
||||
exportingAction: "Exporting...",
|
||||
clearHistory: "Clear chat history",
|
||||
clearHistoryDescription: "Delete local chat history from this device.",
|
||||
clearAction: "Clear",
|
||||
clearAllChats: "Clear all chats",
|
||||
clearAllChatsDescription:
|
||||
"Permanently delete every chat on this device.",
|
||||
noChatsToClear: "No chats to clear.",
|
||||
clearOneChatDescription:
|
||||
"Permanently delete the only chat on this device.",
|
||||
clearChatCountDescription:
|
||||
"Permanently delete all {count} chats on this device.",
|
||||
clearChatsAction: "Clear chats",
|
||||
clearOneChatTitle: "Clear 1 chat?",
|
||||
clearChatsTitle: "Clear {count} chats?",
|
||||
clearChatsConfirmDescription:
|
||||
"This permanently deletes every chat and message stored on this device. This cannot be undone.",
|
||||
clearingAction: "Clearing...",
|
||||
clearOneChatAction: "Clear 1 chat",
|
||||
clearChatCountAction: "Clear {count} chats",
|
||||
clearedAllChats: "Cleared all chats",
|
||||
clearedOneChat: "Cleared 1 chat",
|
||||
clearedChatCount: "Cleared {count} chats",
|
||||
someChatsCouldNotBeCleared: "Some chats could not be cleared",
|
||||
chatsClearedRemainOne:
|
||||
"{clearedCount} chats cleared; 1 chat remains. Please retry.",
|
||||
chatsClearedRemain:
|
||||
"{clearedCount} chats cleared; {remainingCount} chats remain. Please retry.",
|
||||
oneChatClearedRemain:
|
||||
"1 chat cleared; {remainingCount} chats remain. Please retry.",
|
||||
oneChatClearedRemainOne:
|
||||
"1 chat cleared; 1 chat remains. Please retry.",
|
||||
storageClearFailedOne:
|
||||
"A storage clear failed; 1 chat may remain. Please retry.",
|
||||
storageClearFailed:
|
||||
"A storage clear failed; {count} chats may remain. Please retry.",
|
||||
failedToClearChats: "Failed to clear chats",
|
||||
},
|
||||
connections: {
|
||||
title: "Connections",
|
||||
description: "Manage providers and external service connections.",
|
||||
},
|
||||
apiKeys: {
|
||||
title: "API",
|
||||
description: "Access Unsloth programmatically via the OpenAI-compatible API.",
|
||||
readDocs: "Read the API docs",
|
||||
noAccess: "No API access yet.",
|
||||
newBadge: "New",
|
||||
accessTokens: "Access tokens",
|
||||
loadError: "Couldn't load API access.",
|
||||
createError: "Couldn't create access token.",
|
||||
revokeError: "Couldn't revoke access token.",
|
||||
never: "Never",
|
||||
tokenNamePlaceholder: "Token name (e.g. production)",
|
||||
newAccessTokenName: "New access token name",
|
||||
createToken: "Create token",
|
||||
creating: "Creating...",
|
||||
newTokenCreated: "New access token created",
|
||||
accessTokenCopied: "Access token copied",
|
||||
copyAccessToken: "Copy access token",
|
||||
copyNow: "Copy now - this won't be shown again.",
|
||||
usageExamples: "Usage examples",
|
||||
usageTools: "Tools",
|
||||
copySnippet: "Copy snippet",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
setupDocs: "Setup docs:",
|
||||
relativeNever: "never",
|
||||
relativeJustNow: "just now",
|
||||
relativeHoursAgo: "{count}h ago",
|
||||
relativeDaysAgo: "{count}d ago",
|
||||
relativeMonthsAgo: "{count}mo ago",
|
||||
relativeYearsAgo: "{count}y ago",
|
||||
expired: "expired",
|
||||
today: "today",
|
||||
inDays: "in {count}d",
|
||||
created: "Created {value}",
|
||||
used: "Used {value}",
|
||||
expires: "Expires {value}",
|
||||
actionsFor: "Actions for {name}",
|
||||
copyPrefix: "Copy prefix",
|
||||
revokeToken: "Revoke token",
|
||||
revokeTitle: "Revoke access token \"{name}\"?",
|
||||
revokeDescription:
|
||||
"Applications using this token will 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",
|
||||
packageVersion: "Package Version",
|
||||
updates: "Updates",
|
||||
help: "Help",
|
||||
documentation: "Documentation",
|
||||
releaseNotes: "Release notes",
|
||||
whatsNew: "What's new",
|
||||
feedback: "Feedback",
|
||||
reportIssue: "Report an issue",
|
||||
dangerZone: "Danger zone",
|
||||
shutDownStudio: "Shut down Unsloth Studio",
|
||||
shutDownStudioDescription:
|
||||
"Stops the Studio server process 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...",
|
||||
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:",
|
||||
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.",
|
||||
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.",
|
||||
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",
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
studio: {
|
||||
routeTitle: "Train",
|
||||
title: "Fine-tuning Studio",
|
||||
subtitles: {
|
||||
configure: "Configure and start training",
|
||||
trainingInProgress: "Training in progress",
|
||||
viewPastRuns: "View past training runs",
|
||||
viewingPastRun: "Viewing past run",
|
||||
},
|
||||
tabs: {
|
||||
configure: "Configure",
|
||||
currentRun: "Current Run",
|
||||
history: "History",
|
||||
},
|
||||
loadingRuntime: "Loading training runtime...",
|
||||
backToHistory: "Back to history",
|
||||
sections: {
|
||||
model: "Model",
|
||||
dataset: "Dataset",
|
||||
params: "Parameters",
|
||||
training: "Training",
|
||||
charts: "Charts",
|
||||
progress: "Training Progress",
|
||||
},
|
||||
configure: {
|
||||
title: "Configure",
|
||||
description: "Choose a model, dataset, and training settings.",
|
||||
startTraining: "Start Training",
|
||||
starting: "Starting...",
|
||||
loadingModel: "Loading model...",
|
||||
checkingDataset: "Checking dataset...",
|
||||
trainingConfig: "Training Config",
|
||||
},
|
||||
model: {
|
||||
title: "Model",
|
||||
description: "Select base model and training method",
|
||||
fasterTrainingBadge: "2x Faster Training",
|
||||
baseModel: "Base model",
|
||||
localModel: "Local Model",
|
||||
localModelTooltip: "Path to a locally downloaded model or a custom HF repo.",
|
||||
scanningLocalAndCachedModels: "Scanning local and cached models...",
|
||||
scanning: "Scanning...",
|
||||
scanningLocalModels: "Scanning local models...",
|
||||
noLocalModelsFound: "No local models found",
|
||||
noLocalModelsFoundManual: "No local models found. Enter path manually.",
|
||||
failedToLoadLocalModels: "Failed to load local models",
|
||||
hfCache: "HF cache",
|
||||
customFolders: "Custom Folders",
|
||||
localDir: "Local dir",
|
||||
huggingFaceModel: "Hugging Face Model",
|
||||
huggingFaceModelTooltip:
|
||||
"Search Hugging Face models or pick from our recommended list.",
|
||||
searchModels: "Search models...",
|
||||
searching: "Searching...",
|
||||
noModelsFound: "No models found",
|
||||
needsVram: "Needs ~{vram}GB VRAM (GPU: {gpu}GB)",
|
||||
tightVram: "~{vram}GB VRAM (tight fit on {gpu}GB)",
|
||||
vramEstimate: "~{vram}GB VRAM",
|
||||
method: "Method",
|
||||
methodTooltip:
|
||||
"QLoRA uses 4-bit quantization for lowest VRAM. LoRA uses 16-bit. Full updates all weights. CPT (Continued Pretraining) trains on raw text to adapt the model to a new domain without chat formatting.",
|
||||
readMore: "Read more",
|
||||
fullFineTune: "Full Fine-tune",
|
||||
checkingToken: "Checking token...",
|
||||
getOrUpdateToken: "Get or update token",
|
||||
huggingFaceTokenOptional: "Hugging Face Token (Optional)",
|
||||
continuedPretraining: "Continued Pretraining",
|
||||
localModels: "Local models",
|
||||
localModelsFound: "{count} local/cached models found",
|
||||
loadingLocalModels: "Loading local models...",
|
||||
},
|
||||
dataset: {
|
||||
title: "Dataset",
|
||||
description: "Select or upload training data",
|
||||
source: "Dataset source",
|
||||
chooseDataset: "Choose dataset",
|
||||
chooseDatasetTooltip:
|
||||
"Use the popup tabs to switch between Hugging Face and local recipe outputs.",
|
||||
localTab: "Local",
|
||||
searchHuggingFaceDatasets: "Search Hugging Face datasets...",
|
||||
searchLocalDatasets: "Search local datasets...",
|
||||
searching: "Searching...",
|
||||
noDatasetsFound: "No datasets found",
|
||||
loadingLocalDatasets: "Loading local datasets...",
|
||||
failedToLoadLocalDatasets: "Failed to load local datasets.",
|
||||
noLocalDatasetsYet: "No local datasets yet.",
|
||||
noLocalDatasetsMatchSearch: "No local datasets match search.",
|
||||
openDataRecipes: "Open Data Recipes",
|
||||
browsingSource:
|
||||
"Browsing {browsing}. Current selection stays {current}.",
|
||||
localDatasets: "Local datasets",
|
||||
localDataset: "Local dataset",
|
||||
localDatasetRows: " / {count} rows",
|
||||
huggingFaceDataset: "Hugging Face Dataset",
|
||||
localDatasetMetadata: "Local dataset metadata",
|
||||
dataRecipeOutput: "Data Recipe output.",
|
||||
rows: "Rows",
|
||||
columns: "Columns",
|
||||
batches: "Batches",
|
||||
updated: "Updated",
|
||||
evalDataset: "Eval dataset",
|
||||
uploading: "Uploading...",
|
||||
upload: "Upload",
|
||||
uploadEvalFile: "Upload eval file",
|
||||
evalDatasetDescription:
|
||||
"Optional. If not provided, a small portion will be split from the training data.",
|
||||
advanced: "Advanced",
|
||||
targetFormat: "Target Format",
|
||||
targetFormatTooltip:
|
||||
"Format of your training data. Auto-detect works for most datasets.",
|
||||
auto: "Auto",
|
||||
rawText: "Raw Text",
|
||||
trainSplitStart: "Train Split Start",
|
||||
trainSplitStartTooltip:
|
||||
"Only train on a subset of your training split by specifying a start row index (inclusive, 0-based). Leave empty to start from the first row.",
|
||||
trainSplitEnd: "Train Split End",
|
||||
trainSplitEndTooltip:
|
||||
"Last row index to include from the training split (inclusive, 0-based). For example, set Start to 0 and End to 99 to train on the first 100 rows. Leave empty to use all remaining rows.",
|
||||
endPlaceholder: "End",
|
||||
clear: "Clear",
|
||||
dropFileOrClick: "Drop 1 file here or click to upload",
|
||||
viewDataset: "View dataset",
|
||||
uploadFailed: "Upload failed",
|
||||
unknownError: "Unknown error",
|
||||
unsupportedFileType: "Unsupported file type",
|
||||
uploadOneFileType: "Upload one {types} file.",
|
||||
datasetUploaded: "Dataset uploaded",
|
||||
evalDatasetUploaded: "Eval dataset uploaded",
|
||||
uploadOneFileAtATime: "Upload one file at a time",
|
||||
uploadSingleFileDescription:
|
||||
"Training dataset upload accepts a single file.",
|
||||
checkingToken: "Checking token...",
|
||||
getOrUpdateToken: "Get or update token",
|
||||
preview: "Preview dataset",
|
||||
split: "Split",
|
||||
subset: "Subset",
|
||||
},
|
||||
params: {
|
||||
title: "Parameters",
|
||||
description: "Configure training hyperparameters",
|
||||
loraSettings: "LoRA Settings",
|
||||
trainingHyperparameters: "Training Hyperparameters",
|
||||
maxSteps: "Max Steps",
|
||||
epochs: "Epochs",
|
||||
useMaxSteps: "Use Max Steps",
|
||||
useEpochs: "Use Epochs",
|
||||
maxStepsTooltip: "Override total optimizer steps.",
|
||||
epochsTooltip: "Number of full passes over the dataset.",
|
||||
epochsDescription: "Each epoch is one full pass over your dataset.",
|
||||
maxStepsDescription: "Limits training to a fixed number of optimizer steps.",
|
||||
contextLength: "Context Length",
|
||||
contextLengthTooltip: "Maximum number of tokens per training sample.",
|
||||
customContextLength: "Enter a custom value",
|
||||
contextLengthDescription: "Max sequence length for training samples",
|
||||
learningRate: "Learning Rate",
|
||||
learningRateTooltip:
|
||||
"Step size for weight updates. Lower values train slower but more stably.",
|
||||
learningRateDescription:
|
||||
"Recommended: 2e-4 for LoRA, 5e-5 for CPT, 2e-5 for full fine-tune",
|
||||
embeddingLearningRate: "Embedding Learning Rate",
|
||||
embeddingLearningRateTooltip:
|
||||
"Only used when CPT is training embed_tokens. Embeddings are easier to destabilize than LoRA weights, so they usually need a smaller LR. Leave blank to use lr/10; typical working range is 2x-10x smaller than the main LR. Increase it only if vocabulary or domain-token adaptation is too slow.",
|
||||
embeddingLearningRateDescription:
|
||||
"Leave blank to use lr/10 (recommended). Typical range is 2x-10x smaller than the main learning rate.",
|
||||
rank: "Rank",
|
||||
rankTooltip: "Dimension of the low-rank matrices. Higher = more capacity.",
|
||||
alpha: "Alpha",
|
||||
alphaTooltip: "Scaling factor for LoRA updates. Usually 2x rank.",
|
||||
dropout: "Dropout",
|
||||
dropoutTooltip: "Dropout probability for LoRA layers to reduce overfitting.",
|
||||
visionLayers: "Vision layers",
|
||||
languageLayers: "Language layers",
|
||||
attentionModules: "Attention modules",
|
||||
mlpModules: "MLP modules",
|
||||
targetModules: "Target Modules",
|
||||
enableLora: "Enable LoRA",
|
||||
trainWithLora: "Train with LoRA",
|
||||
stableRank: "Stable Rank",
|
||||
memoryEfficient: "Memory Efficient",
|
||||
optimization: "Optimization",
|
||||
schedule: "Schedule",
|
||||
memory: "Memory",
|
||||
optimizer: "Optimizer",
|
||||
optimizerTooltip:
|
||||
"Optimization algorithm. 8-bit variants reduce memory usage. Fused is recommended for vision models.",
|
||||
lrScheduler: "LR scheduler",
|
||||
lrSchedulerTooltip:
|
||||
"How the learning rate changes over training. Linear decays steadily; cosine decays in a curve.",
|
||||
optimizerOptions: {
|
||||
adamw8bit: "AdamW 8-bit",
|
||||
pagedAdamw8bit: "Paged AdamW 8-bit",
|
||||
adamwBnb8bit: "AdamW BNB 8-bit",
|
||||
pagedAdamw32bit: "Paged AdamW 32-bit",
|
||||
adamwTorch: "AdamW (PyTorch)",
|
||||
adamwTorchFused: "AdamW (PyTorch Fused)",
|
||||
},
|
||||
lrSchedulerOptions: {
|
||||
linear: "Linear",
|
||||
cosine: "Cosine",
|
||||
},
|
||||
batchSize: "Batch Size",
|
||||
batchSizeTooltip: "Samples processed per step. Higher uses more VRAM.",
|
||||
gradAccum: "Grad Accum",
|
||||
gradAccumTooltip: "Simulates larger batch sizes without extra VRAM.",
|
||||
weightDecay: "Weight Decay",
|
||||
weightDecayTooltip: "L2 regularization to prevent overfitting.",
|
||||
warmupSteps: "Warmup Steps",
|
||||
warmupStepsTooltip: "Gradually increase LR at training start for stability.",
|
||||
scheduleEpochsTooltip:
|
||||
"Number of full passes over the dataset. Set 0 to run by max steps.",
|
||||
saveSteps: "Save Steps",
|
||||
saveStepsTooltip: "Save a checkpoint every N steps. 0 to disable.",
|
||||
evalSteps: "Eval Steps",
|
||||
evalStepsTooltip:
|
||||
"Fraction of total training steps between evaluations (0-1). Set to 0 to disable evaluation. E.g. 0.01 = evaluate every 1% of steps.",
|
||||
seed: "Seed",
|
||||
seedTooltip: "Random seed for reproducibility.",
|
||||
gradCheckpoint: "Grad Checkpoint",
|
||||
gradCheckpointTooltip:
|
||||
"Trade compute for memory by recomputing activations.",
|
||||
none: "None",
|
||||
standard: "Standard",
|
||||
enablePacking: "Enable packing",
|
||||
assistantCompletionsOnly: "Assistant completions only",
|
||||
readMore: "Read more",
|
||||
},
|
||||
training: {
|
||||
title: "Training",
|
||||
description: "Monitor and control training",
|
||||
chartNoDataTitle: "No training data yet",
|
||||
chartNoDataDescription: "Start training to see loss progress",
|
||||
startTraining: "Start Training",
|
||||
starting: "Starting...",
|
||||
loadingModel: "Loading model...",
|
||||
checkingDataset: "Checking dataset...",
|
||||
configLabel: "Training Config",
|
||||
upload: "Upload",
|
||||
uploadConfigTooltip: "Load a saved YAML config",
|
||||
save: "Save",
|
||||
saveConfigTooltip: "Download current config as YAML",
|
||||
reset: "Reset",
|
||||
resetConfigTooltip: "Reset to model defaults",
|
||||
configLoaded: "Config loaded",
|
||||
failedToLoadConfig: "Failed to load config",
|
||||
invalidYamlFile: "Invalid YAML file",
|
||||
failedToReadFile: "Failed to read file",
|
||||
parametersReset: "Parameters reset to model defaults",
|
||||
audioIncompatible:
|
||||
"This model does not support audio. Switch to an audio-capable model or choose a non-audio dataset.",
|
||||
visionIncompatible:
|
||||
"Text model is not compatible with a multimodal dataset. Switch to a vision model or choose a text-only dataset.",
|
||||
cancelTitle: "Cancel Training",
|
||||
cancelDescription: "Do you want to cancel the current training run?",
|
||||
continueAction: "Continue Training",
|
||||
cancelAction: "Cancel Training",
|
||||
stopTitle: "Stop Training",
|
||||
stopDescription: "Choose how you want to stop the current training run.",
|
||||
stopAction: "Stop",
|
||||
stopping: "Stopping...",
|
||||
stopAndSave: "Stop and Save",
|
||||
compareInChat: "Compare in Chat",
|
||||
exportModel: "Export Model",
|
||||
milestone: "Milestone",
|
||||
halfwayDone: "Halfway done. Training is past 50%.",
|
||||
doneNextStep: "Training done. Next step: compare base vs fine-tuned outputs.",
|
||||
},
|
||||
history: {
|
||||
title: "History",
|
||||
emptyTitle: "No training runs yet",
|
||||
emptyDescription:
|
||||
"No training runs yet. Start your first training run in the Configure tab.",
|
||||
loadError: "Failed to load training runs",
|
||||
deleteError: "Failed to delete training run. Please try again.",
|
||||
retry: "Retry",
|
||||
loadMore: "Load more",
|
||||
loading: "Loading...",
|
||||
loadingRun: "Loading training run...",
|
||||
runNotFound: "Run not found",
|
||||
deleteTitle: "Delete training run?",
|
||||
deleteDescription:
|
||||
"This will permanently delete this training run and all its metrics. This action cannot be undone.",
|
||||
runCount: "{count} runs",
|
||||
oneRun: "1 run",
|
||||
resume: "Resume",
|
||||
resumeTraining: "Resume training",
|
||||
resuming: "Resuming...",
|
||||
deleteRun: "Delete run",
|
||||
loss: "Loss",
|
||||
steps: "Steps",
|
||||
lossTrendSparkline: "Loss trend sparkline",
|
||||
relativeJustNow: "just now",
|
||||
relativeMinutesAgo: "{count}m ago",
|
||||
relativeHoursAgo: "{count}h ago",
|
||||
relativeDaysAgo: "{count}d ago",
|
||||
status: {
|
||||
completed: "Completed",
|
||||
stopped: "Stopped",
|
||||
error: "Error",
|
||||
running: "Running",
|
||||
continued: "Continued",
|
||||
},
|
||||
message: {
|
||||
completed: "Training completed",
|
||||
stopped: "Training stopped",
|
||||
running: "Training in progress",
|
||||
errored: "Training errored",
|
||||
},
|
||||
},
|
||||
charts: {
|
||||
settings: "Chart Settings",
|
||||
settingsDescription: "Tune chart presentation while training keeps running.",
|
||||
openSettings: "Open chart settings",
|
||||
viewWindow: "View window",
|
||||
viewWindowDescription: "Show latest steps only or the full history.",
|
||||
window: "Window",
|
||||
all: "All",
|
||||
trainingLoss: "Training Loss",
|
||||
trainingLossDescription: "Control overlays and EMA smoothing.",
|
||||
smoothing: "Smoothing",
|
||||
smoothingDescription: "Move right for more smoothing. `0` = raw.",
|
||||
showRawLoss: "Show raw loss",
|
||||
showSmoothedLoss: "Show smoothed loss",
|
||||
showAverageLine: "Show average line",
|
||||
scaleAndCleanup: "Scale and cleanup",
|
||||
linear: "Linear",
|
||||
log: "Log",
|
||||
noClip: "No clip",
|
||||
clipP99: "Clip p99",
|
||||
clipP95: "Clip p95",
|
||||
lossAxis: "Loss axis",
|
||||
gradientNormAxis: "Gradient norm axis",
|
||||
learningRateAxis: "Learning rate axis",
|
||||
resetDefaults: "Reset defaults",
|
||||
loss: "Loss",
|
||||
smoothed: "Smoothed",
|
||||
evalLoss: "Eval Loss",
|
||||
learningRate: "Learning Rate",
|
||||
lr: "LR",
|
||||
gradNorm: "Grad Norm",
|
||||
gradientNorm: "Gradient Norm",
|
||||
step: "Step {step}",
|
||||
averageValue: "avg {value}",
|
||||
waitingForFirstEvaluationStep: "Waiting for first evaluation step...",
|
||||
evaluationNotConfigured: "Evaluation not configured",
|
||||
evalChartWillAppear: "Chart will appear once eval_steps is reached",
|
||||
setEvalDatasetAndSteps: "Set eval dataset & eval_steps to track eval loss",
|
||||
},
|
||||
progress: {
|
||||
title: "Training Progress",
|
||||
liveMetrics: "Live training metrics",
|
||||
openConfig: "Open training config",
|
||||
configLabel: "Training Config",
|
||||
hyperparams: "Hyperparams",
|
||||
epochs: "Epochs",
|
||||
batchSize: "Batch size",
|
||||
learningRate: "Learning rate",
|
||||
optimizer: "Optimizer",
|
||||
maxSteps: "Max steps",
|
||||
contextLength: "Context length",
|
||||
warmupSteps: "Warmup steps",
|
||||
rank: "Rank",
|
||||
alpha: "Alpha",
|
||||
dropout: "Dropout",
|
||||
variant: "Variant",
|
||||
epoch: "Epoch {value}",
|
||||
percentComplete: "{percent}% complete",
|
||||
stepProgress: "Step {current} / {total}",
|
||||
loss: "Loss",
|
||||
lr: "LR",
|
||||
gradNorm: "Grad Norm",
|
||||
model: "Model",
|
||||
method: "Method",
|
||||
elapsed: "Elapsed: {value}",
|
||||
eta: "ETA: {value}",
|
||||
stepsPerSecond: "{value} steps/s",
|
||||
noStepsPerSecond: "-- steps/s",
|
||||
tokens: "Tokens: {value}",
|
||||
gpuMonitor: "GPU Monitor",
|
||||
live: "Live",
|
||||
utilization: "Utilization",
|
||||
temperature: "Temperature",
|
||||
vram: "VRAM",
|
||||
power: "Power",
|
||||
phase: {
|
||||
idle: "Idle",
|
||||
downloadingModel: "Downloading model",
|
||||
downloadingDataset: "Downloading dataset",
|
||||
loadingModel: "Loading model",
|
||||
loadingDataset: "Loading dataset",
|
||||
configuring: "Configuring",
|
||||
training: "Training",
|
||||
completed: "Completed",
|
||||
error: "Error",
|
||||
stopped: "Stopped",
|
||||
},
|
||||
},
|
||||
trainingStart: {
|
||||
ready: "Ready",
|
||||
downloading: "Downloading",
|
||||
preparing: "Preparing",
|
||||
left: "{eta} left",
|
||||
downloaded: "{size} downloaded",
|
||||
terminalStart: "> unsloth training starts...",
|
||||
preparingResources: "> Preparing model and dataset...",
|
||||
gettingReady: "> We are getting everything ready for your run...",
|
||||
waitingForFirstStep: "> {message} | waiting for first step... ({step})",
|
||||
resumingTraining: "Resuming training...",
|
||||
startingTraining: "starting training...",
|
||||
dataset: "Dataset",
|
||||
modelWeights: "Model weights",
|
||||
},
|
||||
tour: {
|
||||
guidedTour: "Guided Tour",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
712
studio/frontend/src/i18n/locales/zh-CN.ts
Normal file
712
studio/frontend/src/i18n/locales/zh-CN.ts
Normal file
|
|
@ -0,0 +1,712 @@
|
|||
// 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 type { DeepPartialMessageTree } from "../types";
|
||||
import type { en } from "./en";
|
||||
|
||||
export const zhCN = {
|
||||
common: {
|
||||
cancel: "取消",
|
||||
close: "关闭",
|
||||
delete: "删除",
|
||||
done: "完成",
|
||||
error: "错误",
|
||||
export: "导出",
|
||||
help: "帮助",
|
||||
loading: "加载中...",
|
||||
new: "新增",
|
||||
rename: "重命名",
|
||||
save: "保存",
|
||||
search: "搜索",
|
||||
shutdown: "关闭服务",
|
||||
},
|
||||
shell: {
|
||||
accountMenu: "{name} 账号菜单",
|
||||
aria: {
|
||||
home: "Unsloth 首页",
|
||||
closeSidebar: "关闭侧边栏",
|
||||
openSidebar: "打开侧边栏",
|
||||
chatOptions: "聊天选项",
|
||||
runOptions: "训练选项",
|
||||
},
|
||||
navigation: {
|
||||
newChat: "新聊天",
|
||||
compare: "对比",
|
||||
search: "搜索",
|
||||
train: "训练",
|
||||
recipes: "配方",
|
||||
export: "导出",
|
||||
recents: "最近",
|
||||
settings: "设置",
|
||||
api: "API",
|
||||
lightMode: "浅色模式",
|
||||
darkMode: "深色模式",
|
||||
guidedTour: "引导教程",
|
||||
help: "帮助",
|
||||
logOut: "退出登录",
|
||||
shutdown: "关闭服务",
|
||||
},
|
||||
notFound: {
|
||||
title: "页面未找到",
|
||||
description: "{path} 不存在。",
|
||||
backToChat: "返回聊天",
|
||||
},
|
||||
dialog: {
|
||||
deleteChat: {
|
||||
title: "删除聊天",
|
||||
description: "确定要删除聊天“{name}”吗?",
|
||||
},
|
||||
deleteRun: {
|
||||
title: "删除训练运行",
|
||||
description: "确定要删除运行“{name}”吗?",
|
||||
},
|
||||
renameChat: {
|
||||
title: "重命名聊天",
|
||||
placeholder: "聊天标题",
|
||||
},
|
||||
renameRun: {
|
||||
title: "重命名运行",
|
||||
placeholder: "运行名称",
|
||||
},
|
||||
},
|
||||
toast: {
|
||||
cannotDeleteRunningRun: "不能删除正在运行的训练",
|
||||
failedToDeleteChat: "删除聊天失败",
|
||||
failedToDeleteRun: "删除运行失败",
|
||||
failedToRenameChat: "重命名聊天失败",
|
||||
failedToRenameRun: "重命名运行失败",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: "设置",
|
||||
dialog: {
|
||||
title: "设置",
|
||||
description: "管理你的 Unsloth Studio 偏好设置。",
|
||||
closeAriaLabel: "关闭设置",
|
||||
},
|
||||
tabs: {
|
||||
general: "通用",
|
||||
profile: "个人资料",
|
||||
appearance: "外观",
|
||||
chat: "聊天",
|
||||
connections: "连接",
|
||||
apiKeys: "API",
|
||||
about: "帮助",
|
||||
},
|
||||
general: {
|
||||
title: "通用",
|
||||
description: "Unsloth Studio 的全局偏好设置。",
|
||||
account: "账号",
|
||||
huggingFaceToken: "Hugging Face token",
|
||||
huggingFaceTokenDescription: "用于加载受限模型和推送产物。",
|
||||
hideToken: "隐藏 token",
|
||||
showToken: "显示 token",
|
||||
chatDefaults: "聊天默认设置",
|
||||
autoTitleNewChats: "自动为新聊天命名",
|
||||
autoTitleNewChatsDescription: "根据第一条消息生成简短标题。",
|
||||
gettingStarted: "入门",
|
||||
startOnboarding: "开始引导",
|
||||
startOnboardingDescription: "重新打开设置向导,不会更改你的账号。",
|
||||
startOnboardingAction: "开始引导",
|
||||
resetPreferences: {
|
||||
sectionTitle: "危险区域",
|
||||
label: "重置所有本地偏好设置",
|
||||
description:
|
||||
"清除仅保存在本地的偏好设置。聊天、API 访问权限和数据库中的聊天设置不会受到影响。",
|
||||
action: "重置偏好设置",
|
||||
confirmTitle: "重置所有本地偏好设置?",
|
||||
confirmDescription:
|
||||
"这会清除仅保存在本地的偏好设置,然后重新加载 Studio。聊天、API 访问权限和数据库中的聊天设置不会受到影响。",
|
||||
confirmAction: "重置并重新加载",
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
title: "个人资料",
|
||||
description: "更新你在 Studio 中显示的个人资料。",
|
||||
changePicture: "更换头像",
|
||||
displayName: "显示名称",
|
||||
nameSaved: "个人资料名称已保存",
|
||||
namePersistErrorTitle: "无法持久保存个人资料名称",
|
||||
namePersistErrorDescription:
|
||||
"名称已在本次会话中更新,但重新加载后可能不会保留。",
|
||||
photoUpdated: "头像已更新",
|
||||
photoPersistErrorTitle: "无法持久保存头像",
|
||||
photoPersistErrorDescription:
|
||||
"头像已在本次会话中更新,但重新加载后可能不会保留。",
|
||||
photoUpdateErrorTitle: "无法更新头像",
|
||||
imageUseError: "无法使用这张图片。",
|
||||
},
|
||||
appearance: {
|
||||
title: "外观",
|
||||
description: "调整 Unsloth Studio 在此设备上的显示方式。",
|
||||
language: {
|
||||
title: "语言",
|
||||
label: "显示语言",
|
||||
description: "选择 Studio 使用的语言。",
|
||||
},
|
||||
theme: {
|
||||
title: "主题",
|
||||
label: "颜色主题",
|
||||
description: "选择浅色、深色,或跟随系统。",
|
||||
system: "跟随系统",
|
||||
light: "浅色",
|
||||
dark: "深色",
|
||||
},
|
||||
layout: {
|
||||
title: "布局",
|
||||
compactSidebar: "默认固定侧边栏",
|
||||
compactSidebarDescription: "保持侧边栏展开,而不是折叠为图标。",
|
||||
},
|
||||
},
|
||||
chat: {
|
||||
title: "聊天",
|
||||
description: "管理此设备上保存的聊天记录。",
|
||||
data: "数据",
|
||||
exportHistory: "导出聊天记录",
|
||||
exportHistoryDescription: "将所有聊天和消息下载为 JSON 文件。",
|
||||
exportAction: "导出",
|
||||
exportingAction: "导出中...",
|
||||
clearHistory: "清除聊天记录",
|
||||
clearHistoryDescription: "从此设备删除本地聊天记录。",
|
||||
clearAction: "清除",
|
||||
clearAllChats: "清除所有聊天",
|
||||
clearAllChatsDescription: "永久删除此设备上的每个聊天。",
|
||||
noChatsToClear: "没有可清除的聊天。",
|
||||
clearOneChatDescription: "永久删除此设备上的唯一一个聊天。",
|
||||
clearChatCountDescription: "永久删除此设备上的 {count} 个聊天。",
|
||||
clearChatsAction: "清除聊天",
|
||||
clearOneChatTitle: "清除 1 个聊天?",
|
||||
clearChatsTitle: "清除 {count} 个聊天?",
|
||||
clearChatsConfirmDescription:
|
||||
"这会永久删除此设备上保存的每个聊天和消息。此操作无法撤销。",
|
||||
clearingAction: "清除中...",
|
||||
clearOneChatAction: "清除 1 个聊天",
|
||||
clearChatCountAction: "清除 {count} 个聊天",
|
||||
clearedAllChats: "已清除所有聊天",
|
||||
clearedOneChat: "已清除 1 个聊天",
|
||||
clearedChatCount: "已清除 {count} 个聊天",
|
||||
someChatsCouldNotBeCleared: "部分聊天无法清除",
|
||||
chatsClearedRemainOne:
|
||||
"已清除 {clearedCount} 个聊天;仍有 1 个聊天保留。请重试。",
|
||||
chatsClearedRemain:
|
||||
"已清除 {clearedCount} 个聊天;仍有 {remainingCount} 个聊天保留。请重试。",
|
||||
oneChatClearedRemain:
|
||||
"已清除 1 个聊天;仍有 {remainingCount} 个聊天保留。请重试。",
|
||||
oneChatClearedRemainOne: "已清除 1 个聊天;仍有 1 个聊天保留。请重试。",
|
||||
storageClearFailedOne:
|
||||
"某个存储位置清除失败;可能仍有 1 个聊天保留。请重试。",
|
||||
storageClearFailed:
|
||||
"某个存储位置清除失败;可能仍有 {count} 个聊天保留。请重试。",
|
||||
failedToClearChats: "清除聊天失败",
|
||||
},
|
||||
connections: {
|
||||
title: "连接",
|
||||
description: "管理提供方和外部服务的连接。",
|
||||
},
|
||||
apiKeys: {
|
||||
title: "API",
|
||||
description: "通过兼容 OpenAI 的 API 以编程方式访问 Unsloth。",
|
||||
readDocs: "阅读 API 文档",
|
||||
noAccess: "还没有 API 访问权限。",
|
||||
newBadge: "新",
|
||||
accessTokens: "访问 token",
|
||||
loadError: "无法加载 API 访问权限。",
|
||||
createError: "无法创建访问 token。",
|
||||
revokeError: "无法撤销访问 token。",
|
||||
never: "永不过期",
|
||||
tokenNamePlaceholder: "Token 名称(例如 production)",
|
||||
newAccessTokenName: "新的访问 token 名称",
|
||||
createToken: "创建 token",
|
||||
creating: "创建中...",
|
||||
newTokenCreated: "新的访问 token 已创建",
|
||||
accessTokenCopied: "访问 token 已复制",
|
||||
copyAccessToken: "复制访问 token",
|
||||
copyNow: "现在复制 - 之后不会再次显示。",
|
||||
usageExamples: "使用示例",
|
||||
usageTools: "工具",
|
||||
copySnippet: "复制代码片段",
|
||||
copy: "复制",
|
||||
copied: "已复制",
|
||||
setupDocs: "设置文档:",
|
||||
relativeNever: "从未",
|
||||
relativeJustNow: "刚刚",
|
||||
relativeHoursAgo: "{count} 小时前",
|
||||
relativeDaysAgo: "{count} 天前",
|
||||
relativeMonthsAgo: "{count} 个月前",
|
||||
relativeYearsAgo: "{count} 年前",
|
||||
expired: "已过期",
|
||||
today: "今天",
|
||||
inDays: "{count} 天后",
|
||||
created: "创建于 {value}",
|
||||
used: "使用于 {value}",
|
||||
expires: "过期时间 {value}",
|
||||
actionsFor: "{name} 的操作",
|
||||
copyPrefix: "复制前缀",
|
||||
revokeToken: "撤销 token",
|
||||
revokeTitle: "撤销访问 token \"{name}\"?",
|
||||
revokeDescription:
|
||||
"使用此 token 的应用会立即失去访问权限。此操作无法撤销。",
|
||||
revokeAction: "撤销 \"{name}\"",
|
||||
revoking: "撤销中...",
|
||||
},
|
||||
about: {
|
||||
title: "帮助",
|
||||
description: "文档、发布说明、反馈和 Studio 构建信息。",
|
||||
studioVersion: "Studio 版本",
|
||||
packageVersion: "包版本",
|
||||
updates: "更新",
|
||||
help: "帮助",
|
||||
documentation: "文档",
|
||||
releaseNotes: "发布说明",
|
||||
whatsNew: "最新内容",
|
||||
feedback: "反馈",
|
||||
reportIssue: "报告问题",
|
||||
dangerZone: "危险区域",
|
||||
shutDownStudio: "关闭 Unsloth Studio",
|
||||
shutDownStudioDescription: "停止 Studio 服务进程并结束你的会话。",
|
||||
shutDown: "关闭",
|
||||
update: {
|
||||
title: "更新 Unsloth Studio",
|
||||
openPowerShell: "打开 PowerShell 并运行:",
|
||||
openTerminal: "打开终端并运行:",
|
||||
commandText: "{label} 文本",
|
||||
copied: "已复制",
|
||||
copyCommand: "复制命令",
|
||||
commandCopied: "{label} 已复制",
|
||||
copyNamedCommand: "复制 {label}",
|
||||
checkingInstall: "正在检查 Studio 的安装方式...",
|
||||
localInstallDetected:
|
||||
"检测到源码或本地安装。为避免替换为 PyPI 版本,请从最初安装时使用的 checkout 或源码位置更新。",
|
||||
pullThenUpdate:
|
||||
"从你的 Unsloth 仓库 checkout 拉取最新变更,然后本地更新 Studio:",
|
||||
gitPullCommand: "git pull 命令",
|
||||
localUpdateCommand: "本地更新命令",
|
||||
localInstallerFallback:
|
||||
"如果 Studio 更新命令不可用,请从该 checkout 运行本地安装器:",
|
||||
localInstallerCommand: "本地安装器命令",
|
||||
sourceInstallDetected:
|
||||
"这看起来是源码或 VCS 包安装。请从最初使用的本地路径或 Git URL 重新安装。",
|
||||
repoCheckoutFallback:
|
||||
"如果你仍保留 Unsloth 仓库 checkout,请从该 checkout 运行本地安装器:",
|
||||
restartAfterUpdate: "更新后重启 Studio,使变更生效。",
|
||||
unknownInstall:
|
||||
"Studio 无法检测安装方式。请先确认你如何安装 Studio,然后选择匹配的更新方式。",
|
||||
curlOrPypi: "对于 curl 或 PyPI 安装,请运行:",
|
||||
updateCommand: "更新命令",
|
||||
localCheckout:
|
||||
"对于本地 checkout 安装,请改为从该 checkout 更新并使用本地更新命令:",
|
||||
fallbackInstruction:
|
||||
"如果失败,或 unsloth studio update 不可用,请运行:",
|
||||
fallbackCommand: "备用命令",
|
||||
},
|
||||
},
|
||||
},
|
||||
studio: {
|
||||
routeTitle: "训练",
|
||||
title: "微调工作台",
|
||||
subtitles: {
|
||||
configure: "配置并开始训练",
|
||||
trainingInProgress: "训练进行中",
|
||||
viewPastRuns: "查看历史训练",
|
||||
viewingPastRun: "正在查看历史训练",
|
||||
},
|
||||
tabs: {
|
||||
configure: "配置",
|
||||
currentRun: "当前训练",
|
||||
history: "历史",
|
||||
},
|
||||
loadingRuntime: "正在加载训练运行时...",
|
||||
backToHistory: "返回历史",
|
||||
sections: {
|
||||
model: "模型",
|
||||
dataset: "数据集",
|
||||
params: "参数",
|
||||
training: "训练",
|
||||
charts: "图表",
|
||||
progress: "训练进度",
|
||||
},
|
||||
configure: {
|
||||
title: "配置",
|
||||
description: "选择模型、数据集和训练设置。",
|
||||
startTraining: "开始训练",
|
||||
starting: "启动中...",
|
||||
loadingModel: "正在加载模型...",
|
||||
checkingDataset: "正在检查数据集...",
|
||||
trainingConfig: "训练配置",
|
||||
},
|
||||
model: {
|
||||
title: "模型",
|
||||
description: "选择基础模型和训练方法",
|
||||
fasterTrainingBadge: "训练速度提升 2 倍",
|
||||
baseModel: "基础模型",
|
||||
localModel: "本地模型",
|
||||
localModelTooltip: "本地已下载模型的路径,或自定义 HF 仓库。",
|
||||
scanningLocalAndCachedModels: "正在扫描本地和缓存模型...",
|
||||
scanning: "正在扫描...",
|
||||
scanningLocalModels: "正在扫描本地模型...",
|
||||
noLocalModelsFound: "未找到本地模型",
|
||||
noLocalModelsFoundManual: "未找到本地模型。请手动输入路径。",
|
||||
failedToLoadLocalModels: "加载本地模型失败",
|
||||
hfCache: "HF 缓存",
|
||||
customFolders: "自定义文件夹",
|
||||
localDir: "本地目录",
|
||||
huggingFaceModel: "Hugging Face 模型",
|
||||
huggingFaceModelTooltip: "搜索 Hugging Face 模型,或从推荐列表中选择。",
|
||||
searchModels: "搜索模型...",
|
||||
searching: "搜索中...",
|
||||
noModelsFound: "未找到模型",
|
||||
needsVram: "约需 {vram}GB 显存(GPU:{gpu}GB)",
|
||||
tightVram: "约 {vram}GB 显存(在 {gpu}GB 上偏紧)",
|
||||
vramEstimate: "约 {vram}GB 显存",
|
||||
method: "方法",
|
||||
methodTooltip:
|
||||
"QLoRA 使用 4 位量化以最大限度降低显存。LoRA 使用 16 位。Full 会更新所有权重。CPT(持续预训练)在原始文本上训练,使模型适配新领域,不使用聊天格式。",
|
||||
readMore: "了解更多",
|
||||
fullFineTune: "全量微调",
|
||||
checkingToken: "正在检查 token...",
|
||||
getOrUpdateToken: "获取或更新 token",
|
||||
huggingFaceTokenOptional: "Hugging Face Token(可选)",
|
||||
continuedPretraining: "持续预训练",
|
||||
localModels: "本地模型",
|
||||
localModelsFound: "找到 {count} 个本地/缓存模型",
|
||||
loadingLocalModels: "正在加载本地模型...",
|
||||
},
|
||||
dataset: {
|
||||
title: "数据集",
|
||||
description: "选择或上传训练数据",
|
||||
source: "数据集来源",
|
||||
chooseDataset: "选择数据集",
|
||||
chooseDatasetTooltip:
|
||||
"通过弹出标签切换 Hugging Face 与本地数据配方输出。",
|
||||
localTab: "本地",
|
||||
searchHuggingFaceDatasets: "搜索 Hugging Face 数据集...",
|
||||
searchLocalDatasets: "搜索本地数据集...",
|
||||
searching: "搜索中...",
|
||||
noDatasetsFound: "未找到数据集",
|
||||
loadingLocalDatasets: "正在加载本地数据集...",
|
||||
failedToLoadLocalDatasets: "加载本地数据集失败。",
|
||||
noLocalDatasetsYet: "还没有本地数据集。",
|
||||
noLocalDatasetsMatchSearch: "没有本地数据集匹配搜索。",
|
||||
openDataRecipes: "打开数据配方",
|
||||
browsingSource: "正在浏览 {browsing}。当前选择仍保持为 {current}。",
|
||||
localDatasets: "本地数据集",
|
||||
localDataset: "本地数据集",
|
||||
localDatasetRows: " / {count} 行",
|
||||
huggingFaceDataset: "Hugging Face 数据集",
|
||||
localDatasetMetadata: "本地数据集元数据",
|
||||
dataRecipeOutput: "数据配方输出。",
|
||||
rows: "行",
|
||||
columns: "列",
|
||||
batches: "批次",
|
||||
updated: "更新时间",
|
||||
evalDataset: "评估数据集",
|
||||
uploading: "上传中...",
|
||||
upload: "上传",
|
||||
uploadEvalFile: "上传评估文件",
|
||||
evalDatasetDescription:
|
||||
"可选。如果未提供,将从训练数据中切分出一小部分。",
|
||||
advanced: "高级",
|
||||
targetFormat: "目标格式",
|
||||
targetFormatTooltip:
|
||||
"训练数据的格式。自动检测对大多数数据集都有效。",
|
||||
auto: "自动",
|
||||
rawText: "原始文本",
|
||||
trainSplitStart: "训练切分起始",
|
||||
trainSplitStartTooltip:
|
||||
"通过指定起始行索引(含,从 0 开始)仅在训练切分的子集上训练。留空则从第一行开始。",
|
||||
trainSplitEnd: "训练切分结束",
|
||||
trainSplitEndTooltip:
|
||||
"训练切分中包含的最后一行索引(含,从 0 开始)。例如将起始设为 0、结束设为 99,可在前 100 行上训练。留空则使用所有剩余行。",
|
||||
endPlaceholder: "结束",
|
||||
clear: "清除",
|
||||
dropFileOrClick: "拖放 1 个文件到此处,或点击上传",
|
||||
viewDataset: "查看数据集",
|
||||
uploadFailed: "上传失败",
|
||||
unknownError: "未知错误",
|
||||
unsupportedFileType: "不支持的文件类型",
|
||||
uploadOneFileType: "上传一个 {types} 文件。",
|
||||
datasetUploaded: "数据集已上传",
|
||||
evalDatasetUploaded: "评估数据集已上传",
|
||||
uploadOneFileAtATime: "一次只能上传一个文件",
|
||||
uploadSingleFileDescription: "训练数据集上传只接受单个文件。",
|
||||
checkingToken: "正在检查 token...",
|
||||
getOrUpdateToken: "获取或更新 token",
|
||||
preview: "预览数据集",
|
||||
split: "切分",
|
||||
subset: "子集",
|
||||
},
|
||||
params: {
|
||||
title: "参数",
|
||||
description: "配置训练超参数",
|
||||
loraSettings: "LoRA 设置",
|
||||
trainingHyperparameters: "训练超参数",
|
||||
maxSteps: "最大步数",
|
||||
epochs: "轮数",
|
||||
useMaxSteps: "使用最大步数",
|
||||
useEpochs: "使用轮数",
|
||||
maxStepsTooltip: "覆盖优化器总步数。",
|
||||
epochsTooltip: "完整遍历数据集的次数。",
|
||||
epochsDescription: "每个 epoch 是对数据集的一次完整遍历。",
|
||||
maxStepsDescription: "将训练限制为固定数量的优化器步数。",
|
||||
contextLength: "上下文长度",
|
||||
contextLengthTooltip: "每个训练样本的最大 token 数。",
|
||||
customContextLength: "输入自定义值",
|
||||
contextLengthDescription: "训练样本的最大序列长度",
|
||||
learningRate: "学习率",
|
||||
learningRateTooltip: "权重更新步长。较低的值训练更慢但更稳定。",
|
||||
learningRateDescription:
|
||||
"推荐值:LoRA 用 2e-4,CPT 用 5e-5,全量微调用 2e-5",
|
||||
embeddingLearningRate: "Embedding 学习率",
|
||||
embeddingLearningRateTooltip:
|
||||
"仅在 CPT 训练 embed_tokens 时使用。Embedding 比 LoRA 权重更易失稳,通常需要更小的学习率。留空则使用 lr/10;常用区间是比主学习率小 2 至 10 倍。仅在词表或领域 token 适配过慢时才提高。",
|
||||
embeddingLearningRateDescription:
|
||||
"留空使用 lr/10(推荐)。常用区间是比主学习率小 2 至 10 倍。",
|
||||
rank: "Rank",
|
||||
rankTooltip: "低秩矩阵的维度。越高容量越大。",
|
||||
alpha: "Alpha",
|
||||
alphaTooltip: "LoRA 更新的缩放因子。通常为 Rank 的 2 倍。",
|
||||
dropout: "Dropout",
|
||||
dropoutTooltip: "LoRA 层的 dropout 概率,用于减少过拟合。",
|
||||
visionLayers: "视觉层",
|
||||
languageLayers: "语言层",
|
||||
attentionModules: "注意力模块",
|
||||
mlpModules: "MLP 模块",
|
||||
targetModules: "目标模块",
|
||||
enableLora: "启用 LoRA",
|
||||
trainWithLora: "使用 LoRA 训练",
|
||||
stableRank: "稳定 Rank",
|
||||
memoryEfficient: "节省内存",
|
||||
optimization: "优化",
|
||||
schedule: "计划",
|
||||
memory: "内存",
|
||||
optimizer: "优化器",
|
||||
optimizerTooltip:
|
||||
"优化算法。8 位变体可降低内存占用。对视觉模型推荐 Fused。",
|
||||
lrScheduler: "LR 调度器",
|
||||
lrSchedulerTooltip:
|
||||
"学习率随训练变化的方式。Linear 平稳衰减;Cosine 曲线衰减。",
|
||||
optimizerOptions: {
|
||||
adamw8bit: "AdamW 8-bit",
|
||||
pagedAdamw8bit: "Paged AdamW 8-bit",
|
||||
adamwBnb8bit: "AdamW BNB 8-bit",
|
||||
pagedAdamw32bit: "Paged AdamW 32-bit",
|
||||
adamwTorch: "AdamW(PyTorch)",
|
||||
adamwTorchFused: "AdamW(PyTorch Fused)",
|
||||
},
|
||||
lrSchedulerOptions: {
|
||||
linear: "线性",
|
||||
cosine: "余弦",
|
||||
},
|
||||
batchSize: "批大小",
|
||||
batchSizeTooltip: "每步处理的样本数。越高占用越多显存。",
|
||||
gradAccum: "梯度累积",
|
||||
gradAccumTooltip: "在不增加显存的情况下模拟更大的批大小。",
|
||||
weightDecay: "权重衰减",
|
||||
weightDecayTooltip: "L2 正则化,用于防止过拟合。",
|
||||
warmupSteps: "预热步数",
|
||||
warmupStepsTooltip: "在训练开始时逐步提高学习率,提升稳定性。",
|
||||
scheduleEpochsTooltip:
|
||||
"完整遍历数据集的次数。设为 0 则按最大步数运行。",
|
||||
saveSteps: "保存步数",
|
||||
saveStepsTooltip: "每 N 步保存一次检查点。0 表示禁用。",
|
||||
evalSteps: "评估步数",
|
||||
evalStepsTooltip:
|
||||
"评估之间间隔占总训练步数的比例(0-1)。设为 0 则禁用评估。例如 0.01 = 每 1% 步评估一次。",
|
||||
seed: "随机种子",
|
||||
seedTooltip: "用于复现的随机种子。",
|
||||
gradCheckpoint: "梯度检查点",
|
||||
gradCheckpointTooltip: "通过重算激活以时间换显存。",
|
||||
none: "无",
|
||||
standard: "标准",
|
||||
enablePacking: "启用 packing",
|
||||
assistantCompletionsOnly: "仅助手回复",
|
||||
readMore: "了解更多",
|
||||
},
|
||||
training: {
|
||||
title: "训练",
|
||||
description: "监控和控制训练",
|
||||
chartNoDataTitle: "暂无训练数据",
|
||||
chartNoDataDescription: "开始训练后可查看 loss 进度",
|
||||
startTraining: "开始训练",
|
||||
starting: "启动中...",
|
||||
loadingModel: "正在加载模型...",
|
||||
checkingDataset: "正在检查数据集...",
|
||||
configLabel: "训练配置",
|
||||
upload: "上传",
|
||||
uploadConfigTooltip: "加载已保存的 YAML 配置",
|
||||
save: "保存",
|
||||
saveConfigTooltip: "将当前配置下载为 YAML",
|
||||
reset: "重置",
|
||||
resetConfigTooltip: "重置为模型默认值",
|
||||
configLoaded: "配置已加载",
|
||||
failedToLoadConfig: "加载配置失败",
|
||||
invalidYamlFile: "无效的 YAML 文件",
|
||||
failedToReadFile: "读取文件失败",
|
||||
parametersReset: "参数已重置为模型默认值",
|
||||
audioIncompatible:
|
||||
"该模型不支持音频。请切换到支持音频的模型,或选择非音频数据集。",
|
||||
visionIncompatible:
|
||||
"文本模型与多模态数据集不兼容。请切换到视觉模型,或选择纯文本数据集。",
|
||||
cancelTitle: "取消训练",
|
||||
cancelDescription: "要取消当前训练运行吗?",
|
||||
continueAction: "继续训练",
|
||||
cancelAction: "取消训练",
|
||||
stopTitle: "停止训练",
|
||||
stopDescription: "选择如何停止当前训练运行。",
|
||||
stopAction: "停止",
|
||||
stopping: "停止中...",
|
||||
stopAndSave: "停止并保存",
|
||||
compareInChat: "在聊天中对比",
|
||||
exportModel: "导出模型",
|
||||
milestone: "里程碑",
|
||||
halfwayDone: "已完成一半。训练进度超过 50%。",
|
||||
doneNextStep: "训练完成。下一步:对比基础模型和微调模型的输出。",
|
||||
},
|
||||
history: {
|
||||
title: "历史",
|
||||
emptyTitle: "还没有训练运行",
|
||||
emptyDescription: "还没有训练运行。请在配置标签页开始第一次训练。",
|
||||
loadError: "加载训练运行失败",
|
||||
deleteError: "删除训练运行失败。请重试。",
|
||||
retry: "重试",
|
||||
loadMore: "加载更多",
|
||||
loading: "加载中...",
|
||||
loadingRun: "正在加载训练运行...",
|
||||
runNotFound: "未找到运行",
|
||||
deleteTitle: "删除训练运行?",
|
||||
deleteDescription: "这会永久删除该训练运行及其所有指标。此操作无法撤销。",
|
||||
runCount: "{count} 次运行",
|
||||
oneRun: "1 次运行",
|
||||
resume: "继续",
|
||||
resumeTraining: "继续训练",
|
||||
resuming: "继续中...",
|
||||
deleteRun: "删除运行",
|
||||
loss: "Loss",
|
||||
steps: "步数",
|
||||
lossTrendSparkline: "Loss 趋势迷你图",
|
||||
relativeJustNow: "刚刚",
|
||||
relativeMinutesAgo: "{count} 分钟前",
|
||||
relativeHoursAgo: "{count} 小时前",
|
||||
relativeDaysAgo: "{count} 天前",
|
||||
status: {
|
||||
completed: "已完成",
|
||||
stopped: "已停止",
|
||||
error: "错误",
|
||||
running: "运行中",
|
||||
continued: "已继续",
|
||||
},
|
||||
message: {
|
||||
completed: "训练已完成",
|
||||
stopped: "训练已停止",
|
||||
running: "训练进行中",
|
||||
errored: "训练出错",
|
||||
},
|
||||
},
|
||||
charts: {
|
||||
settings: "图表设置",
|
||||
settingsDescription: "训练运行时调整图表显示。",
|
||||
openSettings: "打开图表设置",
|
||||
viewWindow: "查看窗口",
|
||||
viewWindowDescription: "只显示最新步数或完整历史。",
|
||||
window: "窗口",
|
||||
all: "全部",
|
||||
trainingLoss: "训练损失",
|
||||
trainingLossDescription: "控制覆盖线和 EMA 平滑。",
|
||||
smoothing: "平滑",
|
||||
smoothingDescription: "向右移动可增加平滑度。`0` = 原始值。",
|
||||
showRawLoss: "显示原始 loss",
|
||||
showSmoothedLoss: "显示平滑 loss",
|
||||
showAverageLine: "显示平均线",
|
||||
scaleAndCleanup: "比例和清理",
|
||||
linear: "线性",
|
||||
log: "对数",
|
||||
noClip: "不裁剪",
|
||||
clipP99: "裁剪 p99",
|
||||
clipP95: "裁剪 p95",
|
||||
lossAxis: "损失轴",
|
||||
gradientNormAxis: "梯度范数轴",
|
||||
learningRateAxis: "学习率轴",
|
||||
resetDefaults: "恢复默认值",
|
||||
loss: "Loss",
|
||||
smoothed: "平滑",
|
||||
evalLoss: "评估 Loss",
|
||||
learningRate: "学习率",
|
||||
lr: "LR",
|
||||
gradNorm: "梯度范数",
|
||||
gradientNorm: "梯度范数",
|
||||
step: "步数 {step}",
|
||||
averageValue: "平均 {value}",
|
||||
waitingForFirstEvaluationStep: "等待首次评估步...",
|
||||
evaluationNotConfigured: "未配置评估",
|
||||
evalChartWillAppear: "达到 eval_steps 后会显示图表",
|
||||
setEvalDatasetAndSteps: "设置评估数据集和 eval_steps 以追踪评估 loss",
|
||||
},
|
||||
progress: {
|
||||
title: "训练进度",
|
||||
liveMetrics: "实时训练指标",
|
||||
openConfig: "打开训练配置",
|
||||
configLabel: "训练配置",
|
||||
hyperparams: "超参数",
|
||||
epochs: "轮数",
|
||||
batchSize: "批大小",
|
||||
learningRate: "学习率",
|
||||
optimizer: "优化器",
|
||||
maxSteps: "最大步数",
|
||||
contextLength: "上下文长度",
|
||||
warmupSteps: "预热步数",
|
||||
rank: "Rank",
|
||||
alpha: "Alpha",
|
||||
dropout: "Dropout",
|
||||
variant: "变体",
|
||||
epoch: "Epoch {value}",
|
||||
percentComplete: "完成 {percent}%",
|
||||
stepProgress: "步数 {current} / {total}",
|
||||
loss: "Loss",
|
||||
lr: "LR",
|
||||
gradNorm: "梯度范数",
|
||||
model: "模型",
|
||||
method: "方法",
|
||||
elapsed: "已用时间:{value}",
|
||||
eta: "ETA:{value}",
|
||||
stepsPerSecond: "{value} 步/秒",
|
||||
noStepsPerSecond: "-- 步/秒",
|
||||
tokens: "Tokens:{value}",
|
||||
gpuMonitor: "GPU 监控",
|
||||
live: "实时",
|
||||
utilization: "利用率",
|
||||
temperature: "温度",
|
||||
vram: "VRAM",
|
||||
power: "功耗",
|
||||
phase: {
|
||||
idle: "空闲",
|
||||
downloadingModel: "正在下载模型",
|
||||
downloadingDataset: "正在下载数据集",
|
||||
loadingModel: "正在加载模型",
|
||||
loadingDataset: "正在加载数据集",
|
||||
configuring: "配置中",
|
||||
training: "训练中",
|
||||
completed: "已完成",
|
||||
error: "错误",
|
||||
stopped: "已停止",
|
||||
},
|
||||
},
|
||||
trainingStart: {
|
||||
ready: "就绪",
|
||||
downloading: "下载中",
|
||||
preparing: "准备中",
|
||||
left: "剩余 {eta}",
|
||||
downloaded: "已下载 {size}",
|
||||
terminalStart: "> Unsloth 训练开始...",
|
||||
preparingResources: "> 正在准备模型和数据集...",
|
||||
gettingReady: "> 正在为本次运行做好准备...",
|
||||
waitingForFirstStep: "> {message} | 等待第一步...({step})",
|
||||
resumingTraining: "正在继续训练...",
|
||||
startingTraining: "正在开始训练...",
|
||||
dataset: "数据集",
|
||||
modelWeights: "模型权重",
|
||||
},
|
||||
tour: {
|
||||
guidedTour: "引导教程",
|
||||
},
|
||||
},
|
||||
} satisfies DeepPartialMessageTree<typeof en>;
|
||||
76
studio/frontend/src/i18n/messages.ts
Normal file
76
studio/frontend/src/i18n/messages.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// 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 { getLocale } from "./locale-store";
|
||||
import { en } from "./locales/en";
|
||||
import { zhCN } from "./locales/zh-CN";
|
||||
import type { InterpolationValues, MessageKey } from "./types";
|
||||
|
||||
export const LOCALES = {
|
||||
en: { label: "English", nativeLabel: "English" },
|
||||
"zh-CN": { label: "Chinese (Simplified)", nativeLabel: "简体中文" },
|
||||
} as const;
|
||||
|
||||
export type Locale = keyof typeof LOCALES;
|
||||
export type TranslationKey = MessageKey<typeof en>;
|
||||
|
||||
export const messages = { en, "zh-CN": zhCN } as const;
|
||||
|
||||
const PLACEHOLDER_PATTERN = /\{([a-zA-Z0-9_]+)\}/g;
|
||||
|
||||
function readMessage(tree: unknown, key: string): string | undefined {
|
||||
let cursor = tree;
|
||||
for (const segment of key.split(".")) {
|
||||
if (
|
||||
cursor === null ||
|
||||
typeof cursor !== "object" ||
|
||||
!Object.prototype.hasOwnProperty.call(cursor, segment)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
cursor = (cursor as Record<string, unknown>)[segment];
|
||||
}
|
||||
return typeof cursor === "string" ? cursor : undefined;
|
||||
}
|
||||
|
||||
function interpolate(
|
||||
template: string,
|
||||
values: InterpolationValues | undefined,
|
||||
): string {
|
||||
if (!values) return template;
|
||||
|
||||
return template.replace(PLACEHOLDER_PATTERN, (match, name: string) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(values, name)) return match;
|
||||
const value = values[name];
|
||||
return value === null || value === undefined ? "" : String(value);
|
||||
});
|
||||
}
|
||||
|
||||
function warnMissingEnglishMessage(key: string): void {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`[i18n] Missing English translation for key "${key}".`);
|
||||
}
|
||||
}
|
||||
|
||||
export function translate(
|
||||
key: TranslationKey,
|
||||
values?: InterpolationValues,
|
||||
locale: Locale = getLocale(),
|
||||
): string {
|
||||
const localized = readMessage(messages[locale], key);
|
||||
const fallback = localized ?? readMessage(messages.en, key);
|
||||
|
||||
if (fallback === undefined) {
|
||||
warnMissingEnglishMessage(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
return interpolate(fallback, values);
|
||||
}
|
||||
|
||||
export function isSupportedLocale(value: unknown): value is Locale {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
Object.prototype.hasOwnProperty.call(LOCALES, value)
|
||||
);
|
||||
}
|
||||
30
studio/frontend/src/i18n/types.ts
Normal file
30
studio/frontend/src/i18n/types.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export type MessageTree = {
|
||||
readonly [key: string]: string | MessageTree;
|
||||
};
|
||||
|
||||
export type DeepPartialMessageTree<T> = {
|
||||
readonly [K in keyof T]?: T[K] extends string
|
||||
? string
|
||||
: T[K] extends MessageTree
|
||||
? DeepPartialMessageTree<T[K]>
|
||||
: never;
|
||||
};
|
||||
|
||||
type Join<Prefix extends string, Key extends string> =
|
||||
Prefix extends "" ? Key : `${Prefix}.${Key}`;
|
||||
|
||||
export type MessageKey<T, Prefix extends string = ""> = {
|
||||
[K in Extract<keyof T, string>]: T[K] extends string
|
||||
? Join<Prefix, K>
|
||||
: T[K] extends MessageTree
|
||||
? MessageKey<T[K], Join<Prefix, K>>
|
||||
: never;
|
||||
}[Extract<keyof T, string>];
|
||||
|
||||
export type InterpolationValues = Record<
|
||||
string,
|
||||
string | number | boolean | null | undefined
|
||||
>;
|
||||
|
|
@ -7,6 +7,7 @@ import { createRoot } from "react-dom/client";
|
|||
import "./index.css";
|
||||
import { fetchDeviceType } from "./config/env";
|
||||
import { App } from "./app/app";
|
||||
import { initializeLocale } from "./i18n";
|
||||
|
||||
const globalCrypto = globalThis.crypto as Crypto | undefined;
|
||||
|
||||
|
|
@ -33,6 +34,8 @@ if (!rootElement) {
|
|||
throw new Error("Root element not found");
|
||||
}
|
||||
|
||||
initializeLocale();
|
||||
|
||||
fetchDeviceType().then(() => {
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
|
|
|
|||
|
|
@ -1349,6 +1349,24 @@ def direct_upstream_release_plan(
|
|||
install_kind = "windows-cpu",
|
||||
)
|
||||
)
|
||||
elif host.is_windows and host.is_arm64:
|
||||
# Upstream ggml-org/llama.cpp ships llama-bNNNN-bin-win-cpu-arm64.zip
|
||||
# (visible in the b9334 release manifest). Without this branch the
|
||||
# selector returned 0 attempts and the installer fell back to a
|
||||
# source build on every Windows ARM64 host.
|
||||
cpu_asset = f"llama-{release_tag}-bin-win-cpu-arm64.zip"
|
||||
cpu_url = assets.get(cpu_asset)
|
||||
if cpu_url:
|
||||
attempts.append(
|
||||
AssetChoice(
|
||||
repo = repo,
|
||||
tag = release_tag,
|
||||
name = cpu_asset,
|
||||
url = cpu_url,
|
||||
source_label = "upstream",
|
||||
install_kind = "windows-arm64",
|
||||
)
|
||||
)
|
||||
elif host.is_macos and host.is_arm64:
|
||||
asset_name = f"llama-{release_tag}-bin-macos-arm64.tar.gz"
|
||||
asset_url = assets.get(asset_name)
|
||||
|
|
@ -1391,6 +1409,25 @@ def direct_upstream_release_plan(
|
|||
install_kind = "linux-cpu",
|
||||
)
|
||||
)
|
||||
elif host.is_linux and host.is_arm64 and not host.has_usable_nvidia:
|
||||
# Upstream ggml-org/llama.cpp ships llama-bNNNN-bin-ubuntu-arm64.tar.gz
|
||||
# (visible in the b9334 release manifest). Without this branch the
|
||||
# selector returned 0 attempts and the installer fell back to a
|
||||
# source build on every Linux ARM64 host (DGX Spark, Ampere
|
||||
# Altra, GitHub-hosted ubuntu-24.04-arm runners, etc.).
|
||||
asset_name = f"llama-{release_tag}-bin-ubuntu-arm64.tar.gz"
|
||||
asset_url = assets.get(asset_name)
|
||||
if asset_url:
|
||||
attempts.append(
|
||||
AssetChoice(
|
||||
repo = repo,
|
||||
tag = release_tag,
|
||||
name = asset_name,
|
||||
url = asset_url,
|
||||
source_label = "upstream",
|
||||
install_kind = "linux-arm64",
|
||||
)
|
||||
)
|
||||
if not attempts:
|
||||
raise PrebuiltFallback("no compatible upstream prebuilt asset was found")
|
||||
return InstallReleasePlan(
|
||||
|
|
@ -3833,11 +3870,16 @@ def runtime_patterns_for_choice(choice: AssetChoice) -> list[str]:
|
|||
# libraries between b9279 and b9283) without us re-enumerating
|
||||
# every new file. Studio only invokes llama-server and llama-quantize;
|
||||
# other CLIs upstream ships (llama-cli, llama-bench, ...) are skipped.
|
||||
if choice.install_kind in {"linux-cpu", "linux-cuda", "linux-rocm"}:
|
||||
if choice.install_kind in {"linux-cpu", "linux-cuda", "linux-rocm", "linux-arm64"}:
|
||||
return ["llama-server", "llama-quantize", "lib*.so*"]
|
||||
if choice.install_kind in {"macos-arm64", "macos-x64"}:
|
||||
return ["llama-server", "llama-quantize", "lib*.dylib"]
|
||||
if choice.install_kind in {"windows-cpu", "windows-cuda", "windows-hip"}:
|
||||
if choice.install_kind in {
|
||||
"windows-cpu",
|
||||
"windows-cuda",
|
||||
"windows-hip",
|
||||
"windows-arm64",
|
||||
}:
|
||||
return ["llama-server.exe", "llama-quantize.exe", "*.dll"]
|
||||
raise PrebuiltFallback(
|
||||
f"unsupported install kind for runtime overlay: {choice.install_kind}"
|
||||
|
|
@ -5188,7 +5230,7 @@ def load_prebuilt_metadata(install_dir: Path) -> dict[str, Any] | None:
|
|||
|
||||
|
||||
def runtime_payload_health_groups(choice: AssetChoice) -> list[list[str]]:
|
||||
if choice.install_kind == "linux-cpu":
|
||||
if choice.install_kind in {"linux-cpu", "linux-arm64"}:
|
||||
return [
|
||||
["libllama-common.so*"],
|
||||
["libllama.so*"],
|
||||
|
|
@ -5223,7 +5265,7 @@ def runtime_payload_health_groups(choice: AssetChoice) -> list[list[str]]:
|
|||
["libmtmd.so*"],
|
||||
["libggml-hip.so*"],
|
||||
]
|
||||
if choice.install_kind == "windows-cpu":
|
||||
if choice.install_kind in {"windows-cpu", "windows-arm64"}:
|
||||
return [["llama.dll"]]
|
||||
if choice.install_kind == "windows-cuda":
|
||||
groups = [["llama.dll"], ["ggml-cuda.dll"]]
|
||||
|
|
@ -5298,6 +5340,15 @@ def existing_install_matches_choice(
|
|||
for binary in ("llama-server", "llama-quantize"):
|
||||
if not (runtime_dir / f"{binary}{ext}").exists():
|
||||
return False
|
||||
if host.is_linux:
|
||||
try:
|
||||
preflight_linux_installed_binaries(
|
||||
[runtime_dir / "llama-server", runtime_dir / "llama-quantize"],
|
||||
install_dir,
|
||||
host,
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
expected_fingerprint = expected_install_fingerprint(
|
||||
llama_tag = llama_tag,
|
||||
release_tag = release_tag,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,18 @@ IS_WINDOWS = sys.platform == "win32"
|
|||
IS_MACOS = sys.platform == "darwin"
|
||||
IS_MAC_INTEL = IS_MACOS and platform.machine() == "x86_64"
|
||||
IS_MAC_ARM = IS_MACOS and platform.machine() == "arm64"
|
||||
IS_LINUX = sys.platform.startswith("linux")
|
||||
# torchcodec ships wheels only for manylinux_2_28_x86_64,
|
||||
# macosx_12_0_arm64, and win_amd64 (visible in the 0.10.0 PyPI page).
|
||||
# Trying to install it on any other host fails the whole
|
||||
# extras-no-deps step. `unsloth studio update` does not have a
|
||||
# --no-torch flag, so on these hosts the audio extras must be
|
||||
# filtered out independent of the NO_TORCH env var.
|
||||
PLATFORM_LACKS_TORCHCODEC_WHEEL = (
|
||||
(IS_LINUX and platform.machine() in {"aarch64", "arm64"})
|
||||
or (IS_WINDOWS and platform.machine().lower() in {"arm64", "aarch64"})
|
||||
or IS_MAC_INTEL
|
||||
)
|
||||
|
||||
# ── ROCm / AMD GPU support ─────────────────────────────────────────────────────
|
||||
# Mapping from detected ROCm (major, minor) to the best PyTorch wheel tag on
|
||||
|
|
@ -604,7 +616,14 @@ WINDOWS_SKIP_PACKAGES = {"open_spiel", "triton_kernels"}
|
|||
# Packages to skip when torch is unavailable (Intel Mac GGUF-only mode).
|
||||
# These packages either *are* torch extensions or have unconditional
|
||||
# ``Requires-Dist: torch`` in their published metadata, so installing
|
||||
# them would pull torch back into the environment.
|
||||
# them would pull torch back into the environment. ``librosa`` also
|
||||
# lives in this set even though it does not itself require torch:
|
||||
# upstream ``llvmlite`` dropped its macOS x86_64 wheel between 0.42.0
|
||||
# and 0.46.0+ (see https://pypi.org/project/llvmlite/0.47.0/#files --
|
||||
# only macosx_arm64 / manylinux / win_amd64 remain), so on Intel Mac
|
||||
# the librosa -> numba -> llvmlite chain triggers a from-source build
|
||||
# that fails inside CI and on the host without LLVM 14/15 headers.
|
||||
# Tracked separately in unslothai/unsloth#5046.
|
||||
NO_TORCH_SKIP_PACKAGES = {
|
||||
"torch-stoi",
|
||||
"timm",
|
||||
|
|
@ -612,6 +631,7 @@ NO_TORCH_SKIP_PACKAGES = {
|
|||
"torch-c-dlpack-ext",
|
||||
"openai-whisper",
|
||||
"transformers-cfg",
|
||||
"librosa",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -839,6 +859,14 @@ def pip_install(
|
|||
if actual_req is not None and NO_TORCH and NO_TORCH_SKIP_PACKAGES:
|
||||
actual_req = _filter_requirements(actual_req, NO_TORCH_SKIP_PACKAGES)
|
||||
temp_reqs.append(actual_req)
|
||||
if actual_req is not None and PLATFORM_LACKS_TORCHCODEC_WHEEL:
|
||||
# Linux aarch64 / Windows ARM64 / Intel Mac have no torchcodec
|
||||
# wheel. `unsloth studio update --local` does not pass
|
||||
# --no-torch, so the NO_TORCH filter above does not fire; do
|
||||
# the targeted skip independently so the audio extras step
|
||||
# does not take down the whole update.
|
||||
actual_req = _filter_requirements(actual_req, {"torchcodec"})
|
||||
temp_reqs.append(actual_req)
|
||||
req_args_pip: list[str] = []
|
||||
req_args_uv: list[str] = []
|
||||
if actual_req is not None:
|
||||
|
|
|
|||
|
|
@ -671,6 +671,16 @@ elif [ "$_HOST_SYSTEM" = "Linux" ] \
|
|||
&& [ "$_HOST_MACHINE" = "x86_64" ] \
|
||||
&& [ "$_LINUX_HAS_GPU" = false ]; then
|
||||
_HELPER_RELEASE_REPO="ggml-org/llama.cpp"
|
||||
elif [ "$_HOST_SYSTEM" = "Linux" ] \
|
||||
&& { [ "$_HOST_MACHINE" = "aarch64" ] || [ "$_HOST_MACHINE" = "arm64" ]; } \
|
||||
&& [ "$_LINUX_HAS_GPU" = false ]; then
|
||||
# Linux ARM64 (Ampere Altra, Raspberry Pi 5, GitHub `ubuntu-24.04-arm`,
|
||||
# CPU-only Jetson rescue mode, ...). unslothai/llama.cpp only ships
|
||||
# the Linux CUDA bundles, so without this branch the prebuilt
|
||||
# resolver returns 0 attempts on every release and the installer
|
||||
# falls all the way back to a source build. Upstream ggml-org ships
|
||||
# llama-bNNNN-bin-ubuntu-arm64.tar.gz from at least b9072 onward.
|
||||
_HELPER_RELEASE_REPO="ggml-org/llama.cpp"
|
||||
else
|
||||
_HELPER_RELEASE_REPO="unslothai/llama.cpp"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -436,6 +436,8 @@ def evaluate_fetch(
|
|||
headers: dict[str, str] | None = None,
|
||||
body: Any = None,
|
||||
timeout_ms: int = 20_000,
|
||||
transport_retries: int = 2,
|
||||
transport_backoff_ms: int = 250,
|
||||
) -> dict[str, Any]:
|
||||
"""Run `fetch(url, opts)` inside the page with an AbortSignal deadline.
|
||||
|
||||
|
|
@ -482,16 +484,47 @@ def evaluate_fetch(
|
|||
}
|
||||
}
|
||||
"""
|
||||
return page.evaluate(
|
||||
js,
|
||||
{
|
||||
"url": url,
|
||||
"method": method,
|
||||
"headers": headers or {},
|
||||
"body": body_arg,
|
||||
"timeoutMs": int(timeout_ms),
|
||||
},
|
||||
)
|
||||
payload = {
|
||||
"url": url,
|
||||
"method": method,
|
||||
"headers": headers or {},
|
||||
"body": body_arg,
|
||||
"timeoutMs": int(timeout_ms),
|
||||
}
|
||||
# Bounded retry on transport failures only.
|
||||
# status != 0 -> real HTTP response (incl. 4xx/5xx); propagate.
|
||||
# AbortError -> caller's deadline; propagate.
|
||||
# else (==0) -> stale-keepalive / "TypeError: Failed to fetch"
|
||||
# on macos-14 right after auth rotations close
|
||||
# existing sessions. Retry after backoff so the
|
||||
# browser pool evicts the dead socket.
|
||||
last: dict[str, Any] | None = None
|
||||
attempts = max(1, int(transport_retries) + 1)
|
||||
for attempt in range(attempts):
|
||||
result = page.evaluate(js, payload)
|
||||
last = result
|
||||
try:
|
||||
status = int(result.get("status") or 0)
|
||||
except (TypeError, ValueError):
|
||||
status = 0
|
||||
if status != 0:
|
||||
return result
|
||||
err = str(result.get("error") or "")
|
||||
if "AbortError" in err:
|
||||
return result
|
||||
if attempt < attempts - 1:
|
||||
wait_ms = transport_backoff_ms * (2**attempt)
|
||||
try:
|
||||
sys.stderr.write(
|
||||
f"[evaluate_fetch] {method} {url}: transport failure "
|
||||
f"({attempt + 1}/{attempts}, err={err!r}); "
|
||||
f"retrying in {wait_ms}ms\n"
|
||||
)
|
||||
sys.stderr.flush()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(wait_ms / 1000.0)
|
||||
return last or {"status": 0, "body": None, "error": "no attempt made"}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -2799,6 +2799,97 @@ def test_runtime_overlay_cannot_overwrite_main_archive_payload(
|
|||
assert (release_dir / name).exists(), f"missing {name}"
|
||||
|
||||
|
||||
def test_linux_runtime_overlay_copies_llama_tool_impl_libraries(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
install_from_archives = INSTALL_LLAMA_PREBUILT.install_from_archives
|
||||
|
||||
work = tmp_path / "work"
|
||||
install = tmp_path / "install"
|
||||
archives = tmp_path / "archives"
|
||||
work.mkdir()
|
||||
install.mkdir()
|
||||
archives.mkdir()
|
||||
|
||||
bundle = archives / "app-b9334-linux-x64-cuda13-newer.tar.gz"
|
||||
with tarfile.open(bundle, "w:gz") as archive:
|
||||
for name in (
|
||||
"llama-cli",
|
||||
"llama-server",
|
||||
"llama-quantize",
|
||||
"libllama-cli-impl.so",
|
||||
"libllama-server-impl.so",
|
||||
"libllama-quantize-impl.so",
|
||||
"libllama-common.so",
|
||||
"libllama.so",
|
||||
"libggml.so",
|
||||
"libggml-base.so",
|
||||
"libmtmd.so",
|
||||
"libggml-cpu-x64.so",
|
||||
"libggml-cuda.so",
|
||||
):
|
||||
payload = f"{name}\n".encode()
|
||||
member = tarfile.TarInfo(name)
|
||||
member.size = len(payload)
|
||||
archive.addfile(member, io.BytesIO(payload))
|
||||
|
||||
import hashlib
|
||||
import shutil as _shutil
|
||||
|
||||
bundle_sha = hashlib.sha256(bundle.read_bytes()).hexdigest()
|
||||
choice = AssetChoice(
|
||||
repo = "unslothai/llama.cpp",
|
||||
tag = "b9334",
|
||||
name = bundle.name,
|
||||
url = f"https://example.com/{bundle.name}",
|
||||
source_label = "published",
|
||||
install_kind = "linux-cuda",
|
||||
runtime_line = "cuda13",
|
||||
expected_sha256 = bundle_sha,
|
||||
)
|
||||
host = HostInfo(
|
||||
system = "Linux",
|
||||
machine = "x86_64",
|
||||
is_windows = False,
|
||||
is_linux = True,
|
||||
is_macos = False,
|
||||
is_x86_64 = True,
|
||||
is_arm64 = False,
|
||||
nvidia_smi = None,
|
||||
driver_cuda_version = (13, 0),
|
||||
compute_caps = [],
|
||||
visible_cuda_devices = None,
|
||||
has_physical_nvidia = True,
|
||||
has_usable_nvidia = True,
|
||||
)
|
||||
|
||||
orig_download = INSTALL_LLAMA_PREBUILT.download_file_verified
|
||||
|
||||
def fake_download(url, target_path, *, expected_sha256 = None, label = None, **kw):
|
||||
_shutil.copy2(bundle, target_path)
|
||||
if expected_sha256:
|
||||
actual = hashlib.sha256(Path(target_path).read_bytes()).hexdigest()
|
||||
if actual != expected_sha256:
|
||||
raise INSTALL_LLAMA_PREBUILT.PrebuiltFallback(
|
||||
f"sha256 mismatch on {label}"
|
||||
)
|
||||
|
||||
INSTALL_LLAMA_PREBUILT.download_file_verified = fake_download
|
||||
try:
|
||||
install_from_archives(choice, host, install, work)
|
||||
finally:
|
||||
INSTALL_LLAMA_PREBUILT.download_file_verified = orig_download
|
||||
|
||||
runtime_dir = install / "build" / "bin"
|
||||
for name in (
|
||||
"libllama-cli-impl.so",
|
||||
"libllama-server-impl.so",
|
||||
"libllama-quantize-impl.so",
|
||||
):
|
||||
assert (runtime_dir / name).exists(), f"missing {name}"
|
||||
assert not (runtime_dir / "llama-cli").exists()
|
||||
|
||||
|
||||
def test_python_runtime_dirs_covers_cu13_and_library_bin(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -390,7 +390,11 @@ def cmd_train(args) -> int:
|
|||
)
|
||||
if k in train_result
|
||||
}
|
||||
assert len(losses_per_step) == 7, f"expected 7 logged steps, got {losses_per_step}"
|
||||
# logging_steps=1 + max_steps=N -> N callbacks; track config so the
|
||||
# gate auto-follows if max_steps is bumped again.
|
||||
assert (
|
||||
len(losses_per_step) == config.max_steps
|
||||
), f"expected {config.max_steps} logged steps, got {losses_per_step}"
|
||||
for i, l in enumerate(losses_per_step):
|
||||
# Allow exact 0.0: fp16 per-step loss underflows to 0.0 after
|
||||
# the LoRA reaches loss=0 around step ~10 with this fixture +
|
||||
|
|
|
|||
|
|
@ -26,7 +26,11 @@ def _block_around(src: str, anchor: str, radius: int = 600) -> str:
|
|||
|
||||
|
||||
def test_main_composer_has_dir_auto():
|
||||
block = _block_around(THREAD_TSX.read_text(), 'aria-label="Message input"')
|
||||
# PR #5784 rewrote the literal attribute into a JSX conditional
|
||||
# (`aria-label={overlay ? "Image edit instructions" : "Message input"}`),
|
||||
# so anchor on the inner string literal instead -- it survives both
|
||||
# the old and new spellings.
|
||||
block = _block_around(THREAD_TSX.read_text(), '"Message input"')
|
||||
assert 'dir="auto"' in block, 'main composer is missing dir="auto"'
|
||||
|
||||
|
||||
|
|
|
|||
103
tests/test_tool_mask_zoo_compat.py
Normal file
103
tests/test_tool_mask_zoo_compat.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Compatibility checks for env/tool mask support with older unsloth_zoo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import os
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
RL_SOURCE_PATH = os.path.join(REPO_ROOT, "unsloth", "models", "rl.py")
|
||||
RL_REPLACEMENTS_SOURCE_PATH = os.path.join(
|
||||
REPO_ROOT, "unsloth", "models", "rl_replacements.py"
|
||||
)
|
||||
|
||||
|
||||
def _read(path: str) -> str:
|
||||
with open(path, "r") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def _load_local_align_completion_tool_mask():
|
||||
src = _read(RL_SOURCE_PATH)
|
||||
tree = ast.parse(src)
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.If):
|
||||
for item in node.body:
|
||||
if (
|
||||
isinstance(item, ast.FunctionDef)
|
||||
and item.name == "align_completion_tool_mask"
|
||||
):
|
||||
function_src = ast.get_source_segment(src, item)
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
else:
|
||||
raise AssertionError("local align_completion_tool_mask fallback is missing")
|
||||
|
||||
calls = []
|
||||
|
||||
def align_logprobs_with_mask(logprob_tensor, completion_mask, pad_value = None):
|
||||
calls.append((logprob_tensor, completion_mask, pad_value))
|
||||
return torch.tensor(
|
||||
[[1, 0, 1], [0, 1, 1]],
|
||||
device = completion_mask.device,
|
||||
dtype = logprob_tensor.dtype,
|
||||
)
|
||||
|
||||
namespace = {
|
||||
"torch": torch,
|
||||
"align_logprobs_with_mask": align_logprobs_with_mask,
|
||||
}
|
||||
exec(textwrap.dedent(function_src), namespace)
|
||||
return namespace["align_completion_tool_mask"], calls
|
||||
|
||||
|
||||
def test_rl_uses_optional_zoo_tool_mask_helper():
|
||||
src = _read(RL_SOURCE_PATH)
|
||||
assert 'RL_REPLACEMENTS.get("align_completion_tool_mask")' in src
|
||||
assert 'RL_REPLACEMENTS["align_completion_tool_mask"]' not in src
|
||||
|
||||
|
||||
def test_local_tool_mask_fallback_is_only_old_zoo_compat_shim():
|
||||
align_completion_tool_mask, calls = _load_local_align_completion_tool_mask()
|
||||
completion_mask = torch.tensor(
|
||||
[[1, 1, 0], [1, 1, 1]],
|
||||
dtype = torch.float32,
|
||||
)
|
||||
|
||||
assert align_completion_tool_mask(None, completion_mask) is completion_mask
|
||||
assert calls == []
|
||||
|
||||
same_shape_tool_mask = torch.tensor([[1, 0, 1], [0, 1, 1]], dtype = torch.bool)
|
||||
with pytest.raises(RuntimeError, match = "Please upgrade unsloth_zoo"):
|
||||
align_completion_tool_mask(same_shape_tool_mask, completion_mask)
|
||||
|
||||
|
||||
def test_grpo_accumulated_loss_omits_none_tool_mask_for_old_zoo():
|
||||
src = _read(RL_REPLACEMENTS_SOURCE_PATH)
|
||||
assert "_grpo_accumulated_loss_kwargs = {}" in src
|
||||
assert (
|
||||
'if tool_mask is not None:\n _grpo_accumulated_loss_kwargs["tool_mask"] = tool_mask'
|
||||
in src
|
||||
)
|
||||
assert src.count("**_grpo_accumulated_loss_kwargs") == 2
|
||||
|
||||
accelerated_loss_start = src.find('if hasattr(self.args, "loss_type"):')
|
||||
assert accelerated_loss_start != -1
|
||||
accelerated_loss_body = src[
|
||||
accelerated_loss_start : src.find(
|
||||
'if "train" in self._metrics:', accelerated_loss_start
|
||||
)
|
||||
]
|
||||
assert "tool_mask = tool_mask" not in accelerated_loss_body
|
||||
|
||||
|
||||
def test_rollout_output_patch_requires_real_tool_mask_symbol():
|
||||
src = _read(RL_REPLACEMENTS_SOURCE_PATH)
|
||||
assert 're.search(r"\\btool_mask\\b", function)' in src
|
||||
assert 'output["tool_mask"]' in src
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue