From 556f396b3cd63da525e2cdac1a5f1c606f4494ce Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 27 May 2026 01:35:13 -0700 Subject: [PATCH 001/222] ci: install unsloth_zoo from git main in notebooks-ci + studio-backend-ci (#5802) * ci: install unsloth_zoo from git main in notebooks-ci + studio-backend-ci These were the only two workflows that still pulled unsloth_zoo from PyPI; every other CI (Core, MLX, version-compat, install.sh-driven Studio smokes) installs zoo from git main. Drift between PyPI and main hides fixes-on-zoo-main and lets PR-time validation pass on a stale zoo, then break for users on next release. Both edits match the retry-with-backoff shape mlx-ci.yml already uses. * ci: drop --no-deps from studio-backend-ci unsloth_zoo install The prior PyPI line was `pip install 'unsloth_zoo>=2026.5.1'` (no --no-deps), which pulled in triton and the rest of zoo's runtime deps. I dropped that transitive resolve in the first commit, which broke collection of 5 tests in Repo tests (CPU) with ModuleNotFoundError: No module named 'triton'. Match the prior dep-resolve shape, keeping the source-from-git change. notebooks-ci keeps --no-deps because its original line also had it. --- .github/workflows/notebooks-ci.yml | 10 +++++++++- .github/workflows/studio-backend-ci.yml | 16 +++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/workflows/notebooks-ci.yml b/.github/workflows/notebooks-ci.yml index 673b2f3cc5..2edcae8ab2 100644 --- a/.github/workflows/notebooks-ci.yml +++ b/.github/workflows/notebooks-ci.yml @@ -285,7 +285,15 @@ jobs: # The PR-time CI must validate the code in this PR; PyPI unsloth # may lag the in-repo CPU-torch fallback in unsloth/kernels/utils.py # (lines 162-170) that handles missing torch._C._cuda_getCurrentRawStream. - pip install --no-deps unsloth_zoo + # unsloth_zoo from git main mirrors every other CI (Core / MLX / + # install.sh) so PR-time validation sees the same zoo HEAD. + for attempt in 1 2 3; do + if pip install --no-deps "unsloth_zoo @ git+https://github.com/unslothai/unsloth-zoo"; then + break + fi + [ "$attempt" -eq 3 ] && { echo "::error::unsloth_zoo install failed after 3 attempts"; exit 1; } + sleep $((5 * attempt)) + done pip install --no-deps -e ./unsloth - name: Convert notebooks for AST scan diff --git a/.github/workflows/studio-backend-ci.yml b/.github/workflows/studio-backend-ci.yml index 63eb70f7f1..ee5bbe8633 100644 --- a/.github/workflows/studio-backend-ci.yml +++ b/.github/workflows/studio-backend-ci.yml @@ -144,9 +144,19 @@ jobs: # versions ship a CPU build that imports cleanly on Linux. pip install 'bitsandbytes>=0.45' # unsloth.device_type imports unsloth_zoo.utils.Version at module - # scope, so the conftest preload needs unsloth_zoo even though - # it is an optional dep of unsloth. - pip install 'unsloth_zoo>=2026.5.1' + # scope, so the conftest preload needs unsloth_zoo. Pull from + # git main so this job sees the same zoo HEAD as Core / MLX / + # install.sh do (otherwise a fix on zoo main hides until release). + # No --no-deps: matches prior `pip install 'unsloth_zoo>=2026.5.1'` + # behaviour so triton etc. still come in for the Repo tests CPU + # collection imports. + for attempt in 1 2 3; do + if pip install "unsloth_zoo @ git+https://github.com/unslothai/unsloth-zoo"; then + break + fi + [ "$attempt" -eq 3 ] && { echo "::error::unsloth_zoo install failed after 3 attempts"; exit 1; } + sleep $((5 * attempt)) + done pip install -e . --no-deps - name: Repo tests (CPU, auto-discovered) From 8b2b99be036dc114700867392f1b6a149ee3fdaf Mon Sep 17 00:00:00 2001 From: Datta Nimmaturi Date: Wed, 27 May 2026 16:50:44 +0530 Subject: [PATCH 002/222] tool mask support (#5682) * tool mask support * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Handle tool masks with older zoo builds * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep tool mask implementation in zoo --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- tests/test_tool_mask_zoo_compat.py | 103 +++++++++++++++++++++++++++++ unsloth/models/rl.py | 19 ++++++ unsloth/models/rl_replacements.py | 45 ++++++++++++- 3 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 tests/test_tool_mask_zoo_compat.py diff --git a/tests/test_tool_mask_zoo_compat.py b/tests/test_tool_mask_zoo_compat.py new file mode 100644 index 0000000000..b6b68a7561 --- /dev/null +++ b/tests/test_tool_mask_zoo_compat.py @@ -0,0 +1,103 @@ +"""Compatibility checks for env/tool mask support with older unsloth_zoo.""" + +from __future__ import annotations + +import ast +import os +import textwrap + +import pytest +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) +RL_SOURCE_PATH = os.path.join(REPO_ROOT, "unsloth", "models", "rl.py") +RL_REPLACEMENTS_SOURCE_PATH = os.path.join( + REPO_ROOT, "unsloth", "models", "rl_replacements.py" +) + + +def _read(path: str) -> str: + with open(path, "r") as fh: + return fh.read() + + +def _load_local_align_completion_tool_mask(): + src = _read(RL_SOURCE_PATH) + tree = ast.parse(src) + for node in tree.body: + if isinstance(node, ast.If): + for item in node.body: + if ( + isinstance(item, ast.FunctionDef) + and item.name == "align_completion_tool_mask" + ): + function_src = ast.get_source_segment(src, item) + break + else: + continue + break + else: + raise AssertionError("local align_completion_tool_mask fallback is missing") + + calls = [] + + def align_logprobs_with_mask(logprob_tensor, completion_mask, pad_value = None): + calls.append((logprob_tensor, completion_mask, pad_value)) + return torch.tensor( + [[1, 0, 1], [0, 1, 1]], + device = completion_mask.device, + dtype = logprob_tensor.dtype, + ) + + namespace = { + "torch": torch, + "align_logprobs_with_mask": align_logprobs_with_mask, + } + exec(textwrap.dedent(function_src), namespace) + return namespace["align_completion_tool_mask"], calls + + +def test_rl_uses_optional_zoo_tool_mask_helper(): + src = _read(RL_SOURCE_PATH) + assert 'RL_REPLACEMENTS.get("align_completion_tool_mask")' in src + assert 'RL_REPLACEMENTS["align_completion_tool_mask"]' not in src + + +def test_local_tool_mask_fallback_is_only_old_zoo_compat_shim(): + align_completion_tool_mask, calls = _load_local_align_completion_tool_mask() + completion_mask = torch.tensor( + [[1, 1, 0], [1, 1, 1]], + dtype = torch.float32, + ) + + assert align_completion_tool_mask(None, completion_mask) is completion_mask + assert calls == [] + + same_shape_tool_mask = torch.tensor([[1, 0, 1], [0, 1, 1]], dtype = torch.bool) + with pytest.raises(RuntimeError, match = "Please upgrade unsloth_zoo"): + align_completion_tool_mask(same_shape_tool_mask, completion_mask) + + +def test_grpo_accumulated_loss_omits_none_tool_mask_for_old_zoo(): + src = _read(RL_REPLACEMENTS_SOURCE_PATH) + assert "_grpo_accumulated_loss_kwargs = {}" in src + assert ( + 'if tool_mask is not None:\n _grpo_accumulated_loss_kwargs["tool_mask"] = tool_mask' + in src + ) + assert src.count("**_grpo_accumulated_loss_kwargs") == 2 + + accelerated_loss_start = src.find('if hasattr(self.args, "loss_type"):') + assert accelerated_loss_start != -1 + accelerated_loss_body = src[ + accelerated_loss_start : src.find( + 'if "train" in self._metrics:', accelerated_loss_start + ) + ] + assert "tool_mask = tool_mask" not in accelerated_loss_body + + +def test_rollout_output_patch_requires_real_tool_mask_symbol(): + src = _read(RL_REPLACEMENTS_SOURCE_PATH) + assert 're.search(r"\\btool_mask\\b", function)' in src + assert 'output["tool_mask"]' in src diff --git a/unsloth/models/rl.py b/unsloth/models/rl.py index c82c8364b3..9bf0d3e968 100644 --- a/unsloth/models/rl.py +++ b/unsloth/models/rl.py @@ -365,6 +365,22 @@ calculate_pad_tokens_in_prompt = RL_REPLACEMENTS["calculate_pad_tokens_in_prompt create_completion_attention_mask = RL_REPLACEMENTS["create_completion_attention_mask"] left_pack_padding = RL_REPLACEMENTS["left_pack_padding"] align_logprobs_with_mask = RL_REPLACEMENTS["align_logprobs_with_mask"] +align_completion_tool_mask = RL_REPLACEMENTS.get("align_completion_tool_mask") +if align_completion_tool_mask is None: + + def align_completion_tool_mask( + tool_mask: torch.Tensor, + completion_mask: torch.Tensor, + ) -> torch.Tensor: + if tool_mask is None: + return completion_mask + raise RuntimeError( + "env_mask/tool_mask GRPO requires an unsloth_zoo build whose " + "grpo_accumulated_loss handles tool_mask. Please upgrade " + "unsloth_zoo." + ) + + autotune_batch_and_chunks = RL_REPLACEMENTS["grpo_autotune_batch_and_chunks"] sanitize_logprob = RL_REPLACEMENTS["sanitize_logprob"] @@ -452,6 +468,7 @@ torch_compile_options = {{ {create_completion_attention_mask_code} {left_pack_padding_code} {align_logprobs_with_mask_code} +{align_completion_tool_mask_code} {autotune_batch_and_chunks_code} {sanitize_logprob_code} @@ -1577,6 +1594,7 @@ def _patch_trl_rl_trainers_impl(trainer_file = "grpo_trainer"): ) left_pack_padding_code = inspect.getsource(left_pack_padding) align_logprobs_with_mask_code = inspect.getsource(align_logprobs_with_mask) + align_completion_tool_mask_code = inspect.getsource(align_completion_tool_mask) autotune_batch_and_chunks_code = inspect.getsource(autotune_batch_and_chunks) sanitize_logprob_code = inspect.getsource(sanitize_logprob) # Get final source code @@ -1607,6 +1625,7 @@ def _patch_trl_rl_trainers_impl(trainer_file = "grpo_trainer"): autotune_batch_and_chunks_code = autotune_batch_and_chunks_code, left_pack_padding_code = left_pack_padding_code, align_logprobs_with_mask_code = align_logprobs_with_mask_code, + align_completion_tool_mask_code = align_completion_tool_mask_code, sanitize_logprob_code = sanitize_logprob_code, ) diff --git a/unsloth/models/rl_replacements.py b/unsloth/models/rl_replacements.py index 0f9a324d5b..9ddb5e9453 100644 --- a/unsloth/models/rl_replacements.py +++ b/unsloth/models/rl_replacements.py @@ -946,6 +946,14 @@ def grpo_trainer__generate_and_score_completions(function_name, function): ) function = function.replace(_save_search, _save_replace) + if re.search(r"\btool_mask\b", function) and 'output["tool_mask"]' not in function: + function = function.replace( + " return output", + " if tool_mask is not None:\n" + ' output["tool_mask"] = tool_mask\n' + " return output", + ) + return function @@ -1523,6 +1531,7 @@ def grpo_trainer_compute_loss(function_name, function): mm_token_type_ids = inputs.get("mm_token_type_ids", None) num_items_in_batch = inputs.get("num_items_in_batch", None) sampling_per_token_logps = inputs.get("sampling_per_token_logps", None) + tool_mask = inputs.get("tool_mask", None) current_gradient_accumulation_steps = self.current_gradient_accumulation_steps num_processes = self.accelerator.num_processes @@ -1598,6 +1607,16 @@ def grpo_trainer_compute_loss(function_name, function): max_left_pad = inputs.get("max_left_pad", 0) if per_token_logps is not None: + loss_mask = completion_mask + if tool_mask is not None: + if tool_mask.shape != completion_mask.shape: + raise ValueError( + "tool_mask/env_mask must have the same shape as completion_mask" + ) + loss_mask = completion_mask * tool_mask.to( + device = completion_mask.device, + dtype = completion_mask.dtype, + ) ( loss, completion_length, @@ -1612,7 +1631,7 @@ def grpo_trainer_compute_loss(function_name, function): old_logps, sampling_per_token_logps, input_ids, - completion_mask, + loss_mask, self.beta, advantages, pixel_values = pixel_values, @@ -1662,6 +1681,28 @@ def grpo_trainer_compute_loss(function_name, function): "unsloth_zoo (see https://github.com/unslothai/unsloth-zoo/pull/613)." ) self._unsloth_grpo_zoo_checked = True + if tool_mask is not None and not getattr( + self, "_unsloth_grpo_tool_mask_zoo_checked", False + ): + _supports_tool_mask = ( + "tool_mask" in inspect.signature(grpo_accumulated_loss).parameters + ) + if not _supports_tool_mask: + try: + _zoo_src = inspect.getsource(grpo_accumulated_loss) + except (TypeError, OSError): + _zoo_src = "" + _supports_tool_mask = "tool_mask" in _zoo_src + if not _supports_tool_mask: + raise RuntimeError( + "env_mask/tool_mask GRPO requires an unsloth_zoo build whose " + "grpo_accumulated_loss handles tool_mask. Please upgrade " + "unsloth_zoo." + ) + self._unsloth_grpo_tool_mask_zoo_checked = True + _grpo_accumulated_loss_kwargs = {} + if tool_mask is not None: + _grpo_accumulated_loss_kwargs["tool_mask"] = tool_mask if hasattr(self.args, "loss_type"): ( loss, @@ -1703,6 +1744,7 @@ def grpo_trainer_compute_loss(function_name, function): sampling_per_token_logps = sampling_per_token_logps, token_type_ids = token_type_ids, mm_token_type_ids = mm_token_type_ids, + **_grpo_accumulated_loss_kwargs, ) else: # to ensure backwards compatibility with trl 0.15.2 and maybe even 0.17 @@ -1728,6 +1770,7 @@ def grpo_trainer_compute_loss(function_name, function): attention_mask = attention_mask, token_type_ids = token_type_ids, mm_token_type_ids = mm_token_type_ids, + **_grpo_accumulated_loss_kwargs, ) ) if "train" in self._metrics: From 4891118b5e31194e79c361de5b6b49cefe9b4112 Mon Sep 17 00:00:00 2001 From: wuwuwu <1498968550@qq.com> Date: Wed, 27 May 2026 19:52:16 +0800 Subject: [PATCH 003/222] Studio: add frontend i18n support (#5765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Studio: add frontend i18n support * Studio i18n: guard storage events, restore plurals, fill zh-CN, add parity check - locale-store.ts: wrap window.localStorage access in handleStorageEvent with try/catch. readStoredLocale and writeStoredLocale already guard the same API; the storage-event path can throw the same way in privacy/ restricted contexts and was the only unguarded localStorage call. Refactor the storageArea + key match into isLocaleStorageEvent for clarity. - chat-tab.tsx + en.ts/zh-CN.ts: restore singular handling for chat-clear copy that the i18n migration dropped. Pre-PR code rendered "1 chat" but the new template strings always said "chats", so a user with exactly one chat saw "Cleared 1 chats", "Clear 1 chats?", and "1 chats cleared; 1 chats remain". Add clearOneChat*, clearedOneChat, oneChatClearedRemain*, chatsClearedRemainOne, and storageClearFailedOne keys and pick them in chat-tab.tsx when count === 1. - zh-CN.ts: fill ~50 previously English-fallback keys across studio.configure, studio.model VRAM helpers, studio.dataset (source, browsing, tooltips, preview/split/subset), studio.params tooltips and learningRateDescription, studio.training (audio/vision incompatible), studio.trainingStart.terminalStart, studio.tour.guidedTour, settings.chat.clear*, settings.connections, settings.apiKeys.newBadge. shell.{beta,brand,product} kept as brand strings. - src/i18n/check-parity.ts + npm i18n:check: small script that verifies every locale overlay against the English baseline. Catches placeholder mismatches, shape mismatches, and unintended extra keys; runs via node --experimental- strip-types with no new devDependencies. Verified locally: npm run typecheck, lint, build, biome:check, i18n:check all pass. 24 vitest unit tests cover locale resolution, persistence failures, storage-event sync (including window.localStorage throwing), interpolation, and fallback. 33 Playwright e2e tests pass across Chromium, Firefox, and WebKit covering default load, switch + reload persistence, unsupported/garbage locale fallback, storage-event cross-tab sync, and storage clear. * Studio i18n: use translated API-key error copy instead of raw err.message The API helpers in src/features/settings/api/api-keys.ts throw generic English Error objects ("Failed to load API access", "Failed to create access token", "Failed to revoke access token"). ApiKeysTab and CreateKeyForm caught those and preferred err.message over the translated "settings.apiKeys.loadError" / .createError / .revokeError keys, so in zh-CN mode failed load/create/revoke requests still surfaced the English strings instead of the translated copy. Switched the four call-sites to always render the translated message and left the helper throws unchanged (they are still useful for diagnostics but should not be treated as user-facing localized copy). * Studio i18n: polish two zh-CN embedding LR tooltips Translation-pass review surfaced two awkward phrasings I introduced earlier: "常用区间是主学习率的 2 至 10 倍小" -> "常用区间是比主学习率小 2 至 10 倍" Both versions are grammatical, but the new "比 X 小 N 倍" phrasing is the standard idiomatic comparative for "N times smaller than X" in technical Chinese writing. The earlier "X 的 N 倍小" reads as a non-native construction. Applies to: studio.params.embeddingLearningRateTooltip studio.params.embeddingLearningRateDescription --------- Co-authored-by: Daniel Han --- studio/frontend/package.json | 1 + studio/frontend/src/app/router.tsx | 9 +- studio/frontend/src/app/routes/__root.tsx | 39 +- studio/frontend/src/app/routes/studio.tsx | 2 +- .../frontend/src/components/app-sidebar.tsx | 162 ++-- .../profile-personalization-panel.tsx | 25 +- .../settings/components/api-key-row.tsx | 58 +- .../settings/components/create-key-form.tsx | 19 +- .../settings/components/key-reveal-card.tsx | 14 +- .../settings/components/language-select.tsx | 46 ++ .../settings/components/theme-segmented.tsx | 16 +- .../components/update-studio-instructions.tsx | 94 +-- .../settings/components/usage-examples.tsx | 24 +- .../src/features/settings/settings-dialog.tsx | 46 +- .../src/features/settings/tabs/about-tab.tsx | 34 +- .../features/settings/tabs/api-keys-tab.tsx | 72 +- .../features/settings/tabs/appearance-tab.tsx | 30 +- .../src/features/settings/tabs/chat-tab.tsx | 78 +- .../features/settings/tabs/general-tab.tsx | 56 +- .../features/settings/tabs/profile-tab.tsx | 9 +- .../studio/historical-training-view.tsx | 29 +- .../src/features/studio/history-card-grid.tsx | 93 ++- .../sections/charts/chart-settings-sheet.tsx | 60 +- .../sections/charts/eval-loss-chart-card.tsx | 26 +- .../sections/charts/grad-norm-chart-card.tsx | 19 +- .../charts/learning-rate-chart-card.tsx | 19 +- .../charts/training-loss-chart-card.tsx | 28 +- .../studio/sections/dataset-section.tsx | 139 ++-- .../studio/sections/model-section.tsx | 81 +- .../studio/sections/params-section.tsx | 212 ++--- .../studio/sections/progress-section-lib.ts | 13 - .../studio/sections/progress-section.tsx | 125 +-- .../studio/sections/training-section.tsx | 53 +- .../src/features/studio/studio-page.tsx | 23 +- .../studio/training-start-overlay.tsx | 45 +- .../frontend/src/features/training/index.ts | 5 +- studio/frontend/src/i18n/AGENTS.md | 11 + studio/frontend/src/i18n/check-parity.ts | 111 +++ studio/frontend/src/i18n/index.ts | 44 ++ studio/frontend/src/i18n/locale-store.ts | 130 ++++ studio/frontend/src/i18n/locales/en.ts | 731 ++++++++++++++++++ studio/frontend/src/i18n/locales/zh-CN.ts | 712 +++++++++++++++++ studio/frontend/src/i18n/messages.ts | 76 ++ studio/frontend/src/i18n/types.ts | 30 + studio/frontend/src/main.tsx | 3 + 45 files changed, 2958 insertions(+), 694 deletions(-) create mode 100644 studio/frontend/src/features/settings/components/language-select.tsx create mode 100644 studio/frontend/src/i18n/AGENTS.md create mode 100644 studio/frontend/src/i18n/check-parity.ts create mode 100644 studio/frontend/src/i18n/index.ts create mode 100644 studio/frontend/src/i18n/locale-store.ts create mode 100644 studio/frontend/src/i18n/locales/en.ts create mode 100644 studio/frontend/src/i18n/locales/zh-CN.ts create mode 100644 studio/frontend/src/i18n/messages.ts create mode 100644 studio/frontend/src/i18n/types.ts diff --git a/studio/frontend/package.json b/studio/frontend/package.json index 83b1fd96f9..b43e174889 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -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" }, diff --git a/studio/frontend/src/app/router.tsx b/studio/frontend/src/app/router.tsx index c7bc0440bd..f0a417638d 100644 --- a/studio/frontend/src/app/router.tsx +++ b/studio/frontend/src/app/router.tsx @@ -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 (

- Page not found + {t("shell.notFound.title")}

- {pathname} does not exist. + {t("shell.notFound.description", { path: pathname })}

); diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index 47bff815e6..57ed233d51 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -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 = ( -
- Loading... -
-); +function RouteFallback() { + const t = useT(); + + return ( +
+ {t("common.loading")} +
+ ); +} 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() { {hideNavbar ? (
- + }>
@@ -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"}`} > - + }> diff --git a/studio/frontend/src/app/routes/studio.tsx b/studio/frontend/src/app/routes/studio.tsx index 75f1a1b937..ae7f445e94 100644 --- a/studio/frontend/src/app/routes/studio.tsx +++ b/studio/frontend/src/app/routes/studio.tsx @@ -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, }); diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index aac5f8f8a8..849e017ea8 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -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; + +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({emphasizedValue}); + } + }); + 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(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")} > - BETA + {t("shell.beta")} {!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")} > @@ -397,7 +416,7 @@ export function AppSidebar() { sideOffset={6} className="tooltip-compact" > - Close sidebar + {t("shell.aria.closeSidebar")} )} @@ -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")} > @@ -422,7 +441,7 @@ export function AppSidebar() { sideOffset={8} className="tooltip-compact" > - Open sidebar + {t("shell.aria.openSidebar")} @@ -434,7 +453,7 @@ export function AppSidebar() { { @@ -446,7 +465,7 @@ export function AppSidebar() { /> i.id === search.compare)} disabled={chatDisabled} dataTour="chat-compare" @@ -459,7 +478,7 @@ export function AppSidebar() { /> { @@ -477,7 +496,7 @@ export function AppSidebar() { { @@ -489,7 +508,7 @@ export function AppSidebar() { { navigate({ to: "/data-recipes" }); @@ -499,7 +518,7 @@ export function AppSidebar() { { @@ -513,13 +532,16 @@ export function AppSidebar() { - {/* Recent Chats — hide on Studio only (Eyera fac13); chatOpen = ec695 clickability */} {!isStudioRoute && chatItems.length > 0 && ( - + - Recents + {t("shell.navigation.recents")} @@ -552,7 +574,7 @@ export function AppSidebar() { @@ -844,7 +872,9 @@ export function AppSidebar() { - {renamingTarget?.kind === "run" ? "Rename run" : "Rename chat"} + {renamingTarget?.kind === "run" + ? t("shell.dialog.renameRun.title") + : t("shell.dialog.renameChat.title")} @@ -868,14 +906,14 @@ export function AppSidebar() { variant="ghost" onClick={() => setRenamingTarget(null)} > - Cancel + {t("common.cancel")} diff --git a/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx index f30af7fcf9..ed590f226e 100644 --- a/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx +++ b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx @@ -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")} > @@ -123,7 +126,7 @@ export function ProfilePersonalizationPanel() {
diff --git a/studio/frontend/src/features/settings/components/api-key-row.tsx b/studio/frontend/src/features/settings/components/api-key-row.tsx index ced6de17d1..9a6e1ee207 100644 --- a/studio/frontend/src/features/settings/components/api-key-row.tsx +++ b/studio/frontend/src/features/settings/components/api-key-row.tsx @@ -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; + +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 (
@@ -64,11 +74,23 @@ export function ApiKeyRow({
- Created {relative(apiKey.created_at)} + + {t("settings.apiKeys.created", { + value: relative(apiKey.created_at, t), + })} + · - Used {relative(apiKey.last_used_at)} + + {t("settings.apiKeys.used", { + value: relative(apiKey.last_used_at, t), + })} + · - Expires {expiresText(apiKey.expires_at)} + + {t("settings.apiKeys.expires", { + value: expiresText(apiKey.expires_at, t), + })} +
@@ -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 })} > @@ -85,14 +107,14 @@ export function ApiKeyRow({ { await copyToClipboard(prefix); }}> - Copy prefix + {t("settings.apiKeys.copyPrefix")} onRevoke(apiKey)} className="text-destructive focus:text-destructive" > - Revoke token + {t("settings.apiKeys.revokeToken")} diff --git a/studio/frontend/src/features/settings/components/create-key-form.tsx b/studio/frontend/src/features/settings/components/create-key-form.tsx index a0f2d7f82d..93802bfad8 100644 --- a/studio/frontend/src/features/settings/components/create-key-form.tsx +++ b/studio/frontend/src/features/settings/components/create-key-form.tsx @@ -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(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({ 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")} />
{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} ); })}
diff --git a/studio/frontend/src/features/settings/components/key-reveal-card.tsx b/studio/frontend/src/features/settings/components/key-reveal-card.tsx index 2b589e88fe..bdcb861c1d 100644 --- a/studio/frontend/src/features/settings/components/key-reveal-card.tsx +++ b/studio/frontend/src/features/settings/components/key-reveal-card.tsx @@ -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" /> - New access token created + {t("settings.apiKeys.newTokenCreated")}

- Copy now — this won't be shown again. + {t("settings.apiKeys.copyNow")}

diff --git a/studio/frontend/src/features/settings/components/language-select.tsx b/studio/frontend/src/features/settings/components/language-select.tsx new file mode 100644 index 0000000000..9d30e06147 --- /dev/null +++ b/studio/frontend/src/features/settings/components/language-select.tsx @@ -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 ( + + ); +} diff --git a/studio/frontend/src/features/settings/components/theme-segmented.tsx b/studio/frontend/src/features/settings/components/theme-segmented.tsx index 1061995346..36e7062d8f 100644 --- a/studio/frontend/src/features/settings/components/theme-segmented.tsx +++ b/studio/frontend/src/features/settings/components/theme-segmented.tsx @@ -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() { /> )} - {opt.label} + {t(opt.labelKey)} ); })} diff --git a/studio/frontend/src/features/settings/components/update-studio-instructions.tsx b/studio/frontend/src/features/settings/components/update-studio-instructions.tsx index e4cdccd2d7..90f66483b7 100644 --- a/studio/frontend/src/features/settings/components/update-studio-instructions.tsx +++ b/studio/frontend/src/features/settings/components/update-studio-instructions.tsx @@ -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, +): 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 | 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, + })} /> ); })} @@ -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")} > - {copied ? "Copied" : "Copy"} + {copied ? t("settings.apiKeys.copied") : t("settings.apiKeys.copy")}
           {snippets[lang]}
         
- Setup docs: + {t("settings.apiKeys.setupDocs")} {DOC_LINKS.map((link) => ( 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", )} > - Settings + {t("settings.dialog.title")} - Manage your Unsloth Studio preferences. + {t("settings.dialog.description")}