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
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue