feat: Implementation of the Portuguese (Brazil) language and VRAM/RAM monitor (#6509)

* feat: Implementation of the Portuguese (Brazil) language and VRAM/RAM monitor.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update studio/frontend/src/hooks/use-gpu-utilization.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update studio/backend/main.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update studio/backend/main.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update studio/backend/utils/hardware/hardware.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update studio/backend/utils/hardware/hardware.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update studio/frontend/src/features/settings/components/usage-examples.tsx

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update studio/backend/main.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update studio/backend/utils/hardware/hardware.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update studio/frontend/src/features/studio/sections/progress-section.tsx

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix: resolve automated review feedback on API shape

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix review issues for PR #6509: Cpu icon, VRAM percent, system polling

- model-inspector: use the exported CpuIcon (Cpu is not a Hugeicons export)
- app-sidebar: guard the VRAM percent on totalVram to avoid Infinity, and
  reset the system poll cache only after each request settles so a slow probe
  is reused instead of stacking overlapping requests
- use-gpu-info: populate CPU/RAM on hosts without a GPU
- progress-section: label GPUs by visible_ordinal instead of array index
- hub-page: base the RAM label on systemRamTotalGb
- usage-examples: emit JS sampling and tool options at the top level instead
  of nesting them under extra_body (the JS SDK does not unwrap extra_body)
- main: read torch and transformers versions from package metadata instead of
  importing the libraries on every system poll, and guard the VRAM math
  against null values
- hardware: translate a leftover comment to English

* Harden /api/system: guard psutil.boot_time for PR #6509

Simulating restricted containers and some VMs (where psutil.boot_time can raise)
showed the /api/system endpoint would 500 on the unguarded boot_time call, the
same failure class already handled for cpu_freq, disk_usage, and Process. Wrap
boot_time and return uptime_seconds as null when it is unavailable so the sidebar
monitor degrades gracefully instead of breaking. Widen the uptime_seconds type to
number | null to match.

* Studio: make the sidebar hardware monitor a toggle (default on) for PR #6509

Adds a "Show hardware monitor" switch under Settings > Appearance > Layout,
backed by a localStorage preference (default on), mirroring the existing
useSidebarPin pattern. When turned off, the sidebar hides the VRAM/RAM meters
and useSystemInfo stops the 3s /api/system poll entirely, so no nvidia-smi /
SMI probes run while the monitor is disabled. Adds the en and pt-BR strings.

* Studio: default the sidebar hardware monitor to off (opt-in) for PR #6509

* Studio pt-BR: fix three small translation defects for PR #6509

- learningRateDescription: "5e-5 for CPT" -> "5e-5 para CPT" (leftover English)
- exportScopeRecents: "Recents" -> "Recentes" (untranslated)
- relativeMonthsAgo/relativeYearsAgo: add the missing space ("há {count} meses"/
  "há {count} anos") so they no longer render as "há 3meses"

* Studio pt-BR: translate the last 10 fallback keys for PR #6509

Adds the settings.general.storage block (Armazenamento) and the
settings.chat.modelDisclaimer pair, so pt-BR now covers all en keys
(679/679) with no English fallbacks.

* Studio: hide sidebar VRAM row on CPU-only hosts for PR #6509

* Studio: tighten and trim code comments for PR #6509

* fix: UI issue in the stop button dialog box (fine-tuning)

* Studio pt-BR: translate 18 new keys from main merge (password dialog, GGUF export, dataset streaming) for PR #6509

* Rounding to GB

* Fix/adjust System resources tab for PR #6509

* Fix/adjust GPU monitor review items for PR #6509

* Fix/adjust remaining GPU monitor review items for PR #6509

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix/adjust MLX resource fallback for PR #6509

* floating window implementation

* resize for floating window

* Fix resource monitor review items

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Restore frontend optional dependency lock entries

* Make GPU selection tests hermetic

* Fix GPU monitor CI test failures

* Bound MLX GGUF reload smoke

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix MLX GGUF reload smoke exit

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
This commit is contained in:
Gabriel Pereira Góes 2026-07-02 13:06:39 -03:00 committed by GitHub
commit 22cd26f75d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2689 additions and 383 deletions

View file

@ -12,6 +12,8 @@ from pathlib import Path as _Path
import asyncio
from dataclasses import asdict
from typing import Any, Optional
# Suppress C-level dependency warnings globally
os.environ["PYTHONWARNINGS"] = "ignore"
@ -36,6 +38,10 @@ if sys.platform == "win32":
pass
del _win_stream
_SYSTEM_GPU_CACHE_TTL_SECONDS = 10.0
_system_gpu_cache_lock = threading.Lock()
_system_gpu_cache: Optional[tuple[float, dict[str, Any]]] = None
# ── Windows AMD ROCm DLL injection ──────────────────────────────────────────
# Python 3.8+ ignores PATH for extension modules; register ROCm bin dirs with
# os.add_dll_directory() so amdhip64.dll etc. are found before any torch import.
@ -226,7 +232,6 @@ import shutil
import warnings
from contextlib import asynccontextmanager
from importlib.metadata import PackageNotFoundError, version as package_version
from typing import Optional
from urllib.parse import urlparse
@ -1078,8 +1083,57 @@ async def shutdown_server(request: Request, current_subject: str = Depends(get_c
return {"status": "shutting_down"}
def _get_cached_system_gpu_info(logger) -> dict[str, Any]:
"""Return merged GPU visibility/utilization with bounded live-probe churn."""
import time
from utils.hardware import get_backend_visible_gpu_info, get_visible_gpu_utilization
global _system_gpu_cache
now = time.monotonic()
with _system_gpu_cache_lock:
if _system_gpu_cache is not None:
cached_at, cached_gpu_info = _system_gpu_cache
if now - cached_at < _SYSTEM_GPU_CACHE_TTL_SECONDS:
return cached_gpu_info
try:
visibility_info = get_backend_visible_gpu_info() or {"available": False, "devices": []}
except Exception as e:
logger.debug(f"Failed to get GPU visibility info: {e}")
visibility_info = {"available": False, "devices": []}
try:
utilization_info = get_visible_gpu_utilization() or {"devices": []}
except Exception as e:
logger.debug(f"Failed to get GPU utilization info: {e}")
utilization_info = {"devices": []}
util_devices = {d.get("index"): d for d in utilization_info.get("devices", [])}
enriched_devices = []
for dev in visibility_info.get("devices", []):
idx = dev.get("index")
util = util_devices.get(idx, {})
total_vram = util.get("vram_total_gb") or dev.get("memory_total_gb") or 0
used_vram = util.get("vram_used_gb") or 0
enriched_dev = dict(dev)
enriched_dev["vram_used_gb"] = used_vram
enriched_dev["vram_free_gb"] = round(total_vram - used_vram, 2) if total_vram else 0
enriched_dev["vram_utilization_pct"] = util.get("vram_utilization_pct")
enriched_devices.append(enriched_dev)
gpu_info = {
"available": visibility_info.get("available", False),
"devices": enriched_devices,
}
_system_gpu_cache = (time.monotonic(), gpu_info)
return gpu_info
@app.get("/api/system")
async def get_system_info(current_subject: str = Depends(get_current_subject)):
def get_system_info(current_subject: str = Depends(get_current_subject)):
"""Get system information.
Auth-gated: the response (platform, Python/GPU, memory, ML packages) can
@ -1088,31 +1142,82 @@ async def get_system_info(current_subject: str = Depends(get_current_subject)):
"""
import platform
import psutil
import os
import time
import logging
from utils.hardware import get_device
from utils.hardware.hardware import _backend_label
visibility_info = get_backend_visible_gpu_info()
gpu_info = {
"available": visibility_info["available"],
"devices": visibility_info["devices"],
}
logger = logging.getLogger(__name__)
gpu_info = _get_cached_system_gpu_info(logger)
# CPU & Memory
memory = psutil.virtual_memory()
try:
cpu_freq = psutil.cpu_freq()
except Exception as e:
logger.debug(f"Failed to get CPU frequency: {e}")
cpu_freq = None
try:
disk = psutil.disk_usage(os.path.abspath(os.sep))
except Exception as e:
logger.debug(f"Failed to get disk usage: {e}")
disk = None
try:
current_process = psutil.Process(os.getpid())
process_used_mb = round(current_process.memory_info().rss / 1024**2)
except Exception as e:
logger.debug(f"Failed to get current process memory: {e}")
process_used_mb = 0
try:
boot_time = psutil.boot_time()
except Exception as e:
logger.debug(f"Failed to get boot time: {e}")
boot_time = None
# Read versions from metadata so a 3s poll never imports heavy ML libs (or 500s on their import errors).
from importlib.metadata import PackageNotFoundError, version as pkg_version
ml_packages = {}
for pkg in ("torch", "transformers"):
try:
ml_packages[pkg] = pkg_version(pkg)
except PackageNotFoundError:
pass
except Exception as e:
logger.debug(f"Failed to read {pkg} version: {e}")
return {
"platform": platform.platform(),
"python_version": platform.python_version(),
# _backend_label so /api/system reports "rocm" (not "cuda") on AMD,
# matching /api/hardware and /api/gpu-visibility.
"device_backend": _backend_label(get_device()),
"cpu_count": psutil.cpu_count(),
"cpu_count": psutil.cpu_count(logical = True),
"uptime_seconds": max(0, round(time.time() - boot_time)) if boot_time else None,
"cpu": {
"logical_count": psutil.cpu_count(logical = True),
"physical_count": psutil.cpu_count(logical = False),
"usage_percent": psutil.cpu_percent(interval = None),
"frequency_mhz": round(cpu_freq.current, 2)
if cpu_freq and cpu_freq.current is not None
else None,
},
"memory": {
"total_gb": round(memory.total / 1e9, 2),
"available_gb": round(memory.available / 1e9, 2),
"total_gb": round(memory.total / 1024**3, 2),
"available_gb": round(memory.available / 1024**3, 2),
"percent_used": memory.percent,
"process_used_mb": process_used_mb,
},
"disk": {
"total_gb": round(disk.total / 1e9, 2) if disk else 0,
"free_gb": round(disk.free / 1e9, 2) if disk else 0,
"percent_used": disk.percent if disk else 0,
},
"gpu": gpu_info,
"ml_packages": ml_packages,
}

View file

@ -128,13 +128,7 @@ class TestToolActionNudge:
assert "call render_html once" in nudge
def test_balanced_nudge_empty_without_known_tool_categories(self):
assert (
_build_tool_action_nudge(
tools = [],
model_name = "Llama-3.1-8B-Instruct",
)
== ""
)
assert _build_tool_action_nudge(tools = [], model_name = "Llama-3.1-8B-Instruct") == ""
# =====================================================================

View file

@ -5,9 +5,11 @@ import asyncio
import importlib.util
import os
import re
import sys
import unittest
from contextlib import nullcontext
from pathlib import Path
from types import SimpleNamespace
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
from fastapi import HTTPException
@ -22,6 +24,7 @@ from utils.hardware import (
estimate_required_model_memory_gb,
get_backend_visible_gpu_info,
get_device_map,
get_gpu_utilization,
get_offloaded_device_map_entries,
get_parent_visible_gpu_ids,
get_visible_gpu_utilization,
@ -33,6 +36,24 @@ import utils.hardware.hardware as _hw_module
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
async def _inline_to_thread(func, /, *args, **kwargs):
return func(*args, **kwargs)
def _fake_unsloth_attention_modules(resolver):
unsloth_module = ModuleType("unsloth")
models_module = ModuleType("unsloth.models")
utils_module = ModuleType("unsloth.models._utils")
utils_module.resolve_attention_implementation = resolver
models_module._utils = utils_module
unsloth_module.models = models_module
return {
"unsloth": unsloth_module,
"unsloth.models": models_module,
"unsloth.models._utils": utils_module,
}
def _load_route_module(name: str, relative_path: str):
spec = importlib.util.spec_from_file_location(name, _BACKEND_ROOT / relative_path)
module = importlib.util.module_from_spec(spec)
@ -122,6 +143,139 @@ class TestResolveRequestedGpuIds(_GpuCacheResetMixin, unittest.TestCase):
class TestVisibleGpuUtilization(_GpuCacheResetMixin, unittest.TestCase):
def test_gpu_utilization_preserves_primary_shape_with_devices(self):
devices = [
{
"index": 5,
"visible_ordinal": 0,
"gpu_utilization_pct": 11.0,
"temperature_c": 40.0,
"vram_used_gb": 4.0,
"vram_total_gb": 24.0,
"vram_utilization_pct": 16.7,
"power_draw_w": 80.0,
"power_limit_w": 300.0,
"power_utilization_pct": 26.7,
},
{
"index": 3,
"visible_ordinal": 1,
"gpu_utilization_pct": 22.0,
"temperature_c": 50.0,
"vram_used_gb": 8.0,
"vram_total_gb": 24.0,
"vram_utilization_pct": 33.3,
"power_draw_w": 120.0,
"power_limit_w": 300.0,
"power_utilization_pct": 40.0,
},
]
with (
patch("utils.hardware.hardware.get_device", return_value = DeviceType.CUDA),
patch.object(_hw_module, "IS_ROCM", False),
patch(
"utils.hardware.hardware._get_parent_visible_gpu_spec",
return_value = {"raw": "5,3", "numeric_ids": [5, 3]},
),
patch(
"utils.hardware.hardware._smi_query",
return_value = {
"available": True,
"devices": devices,
"backend_cuda_visible_devices": "5,3",
"parent_visible_gpu_ids": [5, 3],
"index_kind": "physical",
},
),
):
result = get_gpu_utilization()
self.assertIsInstance(result, dict)
self.assertTrue(result["available"])
self.assertEqual(result["backend"], "cuda")
self.assertEqual(result["index"], 5)
self.assertEqual(result["visible_ordinal"], 0)
self.assertEqual(result["vram_total_gb"], 24.0)
self.assertEqual(result["parent_visible_gpu_ids"], [5, 3])
self.assertEqual([device["index"] for device in result["devices"]], [5, 3])
def test_gpu_utilization_cpu_returns_legacy_unavailable_object(self):
with patch("utils.hardware.hardware.get_device", return_value = DeviceType.CPU):
result = get_gpu_utilization()
self.assertEqual(result, {"available": False, "backend": "cpu", "devices": []})
def test_gpu_utilization_mlx_stays_available_without_agx_stats(self):
fake_psutil = ModuleType("psutil")
fake_psutil.virtual_memory = lambda: SimpleNamespace(total = 64 * 1024**3)
with (
patch.dict(sys.modules, {"psutil": fake_psutil}),
patch("utils.hardware.hardware.get_device", return_value = DeviceType.MLX),
patch("utils.hardware.hardware._read_apple_gpu_stats", return_value = {}),
patch(
"core.training.get_training_backend",
return_value = SimpleNamespace(_progress = None),
),
patch("utils.hardware.apple.read_gpu_temperature_c", return_value = None),
patch("utils.hardware.apple.read_gpu_power_w", return_value = None),
):
result = get_gpu_utilization()
self.assertTrue(result["available"])
self.assertEqual(result["backend"], "mlx")
self.assertIsNone(result["gpu_utilization_pct"])
self.assertEqual(result["vram_used_gb"], 0)
self.assertEqual(result["vram_total_gb"], 64.0)
self.assertEqual(len(result["devices"]), 1)
def test_gpu_utilization_xpu_uses_visible_devices(self):
with (
patch("utils.hardware.hardware.get_device", return_value = DeviceType.XPU),
patch(
"utils.hardware.hardware.get_visible_gpu_utilization",
return_value = {
"available": True,
"backend": "xpu",
"parent_visible_gpu_ids": [2, 0],
"index_kind": "physical",
"devices": [
{
"index": 2,
"visible_ordinal": 1,
"gpu_utilization_pct": None,
"temperature_c": None,
"vram_used_gb": 3.0,
"vram_total_gb": 16.0,
"vram_utilization_pct": 18.8,
"power_draw_w": None,
"power_limit_w": None,
"power_utilization_pct": None,
},
{
"index": 0,
"visible_ordinal": 0,
"gpu_utilization_pct": None,
"temperature_c": None,
"vram_used_gb": 1.0,
"vram_total_gb": 16.0,
"vram_utilization_pct": 6.3,
"power_draw_w": None,
"power_limit_w": None,
"power_utilization_pct": None,
},
],
},
),
):
result = get_gpu_utilization()
self.assertEqual(result["backend"], "xpu")
self.assertEqual(result["index"], 0)
self.assertEqual(result["visible_ordinal"], 0)
self.assertEqual([device["index"] for device in result["devices"]], [0, 2])
def test_visible_gpu_utilization_filters_to_parent_visible_ids(self):
smi_output = "\n".join(
[
@ -272,6 +426,14 @@ class TestGpuAutoSelection(_GpuCacheResetMixin, unittest.TestCase):
def test_get_offloaded_device_map_entries_handles_models_without_device_map(self):
self.assertEqual(get_offloaded_device_map_entries(SimpleNamespace()), {})
@patch(
"utils.hardware.hardware._resolve_model_identifier_for_gpu_estimate",
new = lambda model_name, **_: model_name,
)
@patch(
"utils.hardware.hardware._load_config_for_gpu_estimate",
new = lambda *_args, **_kwargs: None,
)
def test_estimate_required_memory_formulas(self):
eight_gb = 8 * (1024**3)
@ -432,6 +594,7 @@ class TestGpuAutoSelection(_GpuCacheResetMixin, unittest.TestCase):
def test_prepare_gpu_selection_preserves_explicit_ids_without_auto_selection(self):
with (
patch("utils.hardware.hardware.get_device", return_value = DeviceType.CUDA),
patch(
"utils.hardware.hardware.resolve_requested_gpu_ids",
return_value = [2, 3],
@ -464,6 +627,7 @@ class TestGpuAutoSelection(_GpuCacheResetMixin, unittest.TestCase):
def test_prepare_gpu_selection_preserves_uuid_parent_visibility_in_auto_mode(self):
with (
patch.dict(os.environ, {"CUDA_VISIBLE_DEVICES": "GPU-aaa,GPU-bbb"}, clear = True),
patch("utils.hardware.hardware.get_device", return_value = DeviceType.CUDA),
patch(
"utils.hardware.hardware.estimate_required_model_memory_gb",
return_value = (
@ -582,6 +746,7 @@ class TestPreSpawnGpuResolution(_GpuCacheResetMixin, unittest.TestCase):
with (
patch.dict(os.environ, {"CUDA_VISIBLE_DEVICES": "GPU-aaa,GPU-bbb"}, clear = True),
patch("utils.hardware.hardware.get_device", return_value = DeviceType.CUDA),
patch(
"core.training.training._CTX.Queue",
side_effect = [dummy_queue, dummy_queue],
@ -709,14 +874,23 @@ class TestRouteErrors(unittest.TestCase):
has_audio_input = False,
)
with patch.object(
inference_route.ModelConfig,
"from_identifier",
return_value = model_config,
with (
patch.object(
inference_route,
"ModelConfig",
SimpleNamespace(from_identifier = lambda **_kwargs: model_config),
),
patch.object(
inference_route,
"_guard_chat_load_against_training",
return_value = None,
),
patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread),
patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext),
):
with self.assertRaises(HTTPException) as exc_info:
asyncio.run(
inference_route.load_model(
inference_route._load_model_impl(
request,
SimpleNamespace(
app = SimpleNamespace(
@ -835,9 +1009,9 @@ class TestRouteErrors(unittest.TestCase):
with (
patch.object(
inference_route.ModelConfig,
"from_identifier",
return_value = model_config,
inference_route,
"ModelConfig",
SimpleNamespace(from_identifier = lambda **_kwargs: model_config),
),
patch.object(
inference_route,
@ -849,6 +1023,13 @@ class TestRouteErrors(unittest.TestCase):
"get_llama_cpp_backend",
return_value = SimpleNamespace(is_loaded = False),
),
patch.object(
inference_route,
"_guard_chat_load_against_training",
return_value = None,
),
patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread),
patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext),
patch(
"core.export.get_export_backend",
return_value = SimpleNamespace(current_checkpoint = None),
@ -856,7 +1037,7 @@ class TestRouteErrors(unittest.TestCase):
):
with self.assertRaises(HTTPException) as exc_info:
asyncio.run(
inference_route.load_model(
inference_route._load_model_impl(
request,
SimpleNamespace(
app = SimpleNamespace(
@ -899,9 +1080,9 @@ class TestRouteErrors(unittest.TestCase):
with (
patch.object(
inference_route.ModelConfig,
"from_identifier",
return_value = model_config,
inference_route,
"ModelConfig",
SimpleNamespace(from_identifier = lambda **_kwargs: model_config),
),
patch.object(
inference_route,
@ -913,6 +1094,13 @@ class TestRouteErrors(unittest.TestCase):
"get_llama_cpp_backend",
return_value = SimpleNamespace(is_loaded = False),
),
patch.object(
inference_route,
"_guard_chat_load_against_training",
return_value = None,
),
patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread),
patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext),
patch(
"core.export.get_export_backend",
return_value = SimpleNamespace(current_checkpoint = None),
@ -920,7 +1108,7 @@ class TestRouteErrors(unittest.TestCase):
):
with self.assertRaises(HTTPException) as exc_info:
asyncio.run(
inference_route.load_model(
inference_route._load_model_impl(
request,
SimpleNamespace(
app = SimpleNamespace(
@ -1102,10 +1290,7 @@ class TestPerGpuFitGuardAllCounts(unittest.TestCase):
cfg._attn_implementation = "eager"
return "eager"
with patch(
"unsloth.models._utils.resolve_attention_implementation",
side_effect = _stub_resolver,
):
with patch.dict(sys.modules, _fake_unsloth_attention_modules(_stub_resolver)):
hardware_module._determine_attention_impl_for_gpu_estimate(config)
self.assertFalse(hasattr(config, "_attn_implementation"))
@ -1133,10 +1318,7 @@ class TestPerGpuFitGuardAllCounts(unittest.TestCase):
with (
patch.object(AutoModelForCausalLM, "_model_mapping", new = None),
patch.object(AutoModel, "_model_mapping", new = None),
patch(
"unsloth.models._utils.resolve_attention_implementation",
side_effect = _stub_resolver,
),
patch.dict(sys.modules, _fake_unsloth_attention_modules(_stub_resolver)),
):
result = hardware_module._determine_attention_impl_for_gpu_estimate(config)
@ -1173,10 +1355,7 @@ class TestPerGpuFitGuardAllCounts(unittest.TestCase):
inner._attn_implementation = "eager"
return "eager"
with patch(
"unsloth.models._utils.resolve_attention_implementation",
side_effect = _stub_resolver,
):
with patch.dict(sys.modules, _fake_unsloth_attention_modules(_stub_resolver)):
hardware_module._determine_attention_impl_for_gpu_estimate(config)
self.assertFalse(hasattr(config, "_attn_implementation"))

View file

@ -710,82 +710,159 @@ def _rocm_windows_perf_counter_vram_gb() -> tuple[Optional[float], Optional[floa
return None, None
def _gpu_utilization_payload(
device: DeviceType, devices: list[Dict[str, Any]], **metadata: Any
) -> Dict[str, Any]:
"""Keep the legacy primary-GPU shape and append all visible devices."""
backend = _backend_label(device)
normalized = []
for ordinal, raw in enumerate(devices):
dev = dict(raw)
dev.setdefault("available", True)
dev.setdefault("backend", backend)
if dev.get("visible_ordinal") is None:
dev["visible_ordinal"] = ordinal
normalized.append(dev)
normalized.sort(key = lambda dev: dev.get("visible_ordinal", dev.get("index", 0)))
payload: Dict[str, Any] = {
"available": bool(normalized),
"backend": backend,
"devices": normalized,
}
payload.update(metadata)
if normalized:
payload.update(normalized[0])
payload["available"] = True
payload["backend"] = normalized[0].get("backend", backend)
payload["devices"] = normalized
return payload
def get_gpu_utilization() -> Dict[str, Any]:
"""Return a live snapshot of device utilization information."""
"""Live utilization snapshot for the primary GPU plus all visible GPUs."""
device = get_device()
if device == DeviceType.XPU:
result = get_visible_gpu_utilization()
return _gpu_utilization_payload(
device,
result.get("devices", []),
parent_visible_gpu_ids = result.get("parent_visible_gpu_ids", []),
index_kind = result.get("index_kind"),
)
if device == DeviceType.CUDA:
result = _smi_query("get_primary_gpu_utilization")
if result is not None:
result["backend"] = _backend_label(device)
if IS_ROCM:
# Fix unified-memory VRAM on AMD iGPUs (Strix Halo etc.).
_reconcile_primary_rocm_unified_memory(result, _get_parent_visible_gpu_spec())
return result
# SMI unavailable. On Windows, use Performance Counters (Task Manager
# source) for system-wide VRAM, covering cross-process usage torch can't see.
parent_visible_spec = _get_parent_visible_gpu_spec()
result = _smi_query(
"get_visible_gpu_utilization",
parent_visible_spec["numeric_ids"],
parent_cuda_visible_devices = parent_visible_spec["raw"],
)
if result is not None and "devices" in result:
devices = result["devices"]
numeric_ids = parent_visible_spec.get("numeric_ids")
if IS_ROCM and numeric_ids is not None:
_reconcile_rocm_unified_memory(result, numeric_ids)
return _gpu_utilization_payload(
device,
devices,
backend_cuda_visible_devices = result.get("backend_cuda_visible_devices"),
parent_visible_gpu_ids = result.get("parent_visible_gpu_ids", []),
index_kind = result.get("index_kind"),
)
# Fallback Windows ROCm
if IS_ROCM and platform.system() == "Windows":
_win_used, _win_total = _rocm_windows_perf_counter_vram_gb()
if _win_used is not None and _win_total is not None:
_win_util = _rocm_windows_perf_counter_gpu_util_pct()
return {
"available": True,
"backend": _backend_label(device),
"gpu_utilization_pct": _win_util,
"temperature_c": None,
"vram_used_gb": _win_used,
"vram_total_gb": _win_total,
"vram_utilization_pct": round((_win_used / _win_total) * 100, 1)
if _win_total > 0
else None,
"power_draw_w": None,
"power_limit_w": None,
"power_utilization_pct": None,
}
# Linux: DRM sysfs gives system-wide VRAM across all processes, no tools needed.
return _gpu_utilization_payload(
device,
[
{
"available": True,
"backend": _backend_label(device),
"index": 0,
"visible_ordinal": 0,
"gpu_utilization_pct": _win_util,
"temperature_c": None,
"vram_used_gb": _win_used,
"vram_total_gb": _win_total,
"vram_utilization_pct": round((_win_used / _win_total) * 100, 1)
if _win_total > 0
else None,
"power_draw_w": None,
"power_limit_w": None,
"power_utilization_pct": None,
}
],
)
# Fallback Linux ROCm
if IS_ROCM and platform.system() == "Linux":
_linux_used, _linux_total = _rocm_linux_sysfs_vram_gb()
if _linux_used is not None and _linux_total is not None:
_linux_util = _rocm_linux_sysfs_gpu_busy_pct()
_linux_temp = _rocm_linux_sysfs_temp_c()
_linux_power = _rocm_linux_sysfs_power_w()
return {
"available": True,
"backend": _backend_label(device),
"gpu_utilization_pct": _linux_util,
"temperature_c": _linux_temp,
"vram_used_gb": _linux_used,
"vram_total_gb": _linux_total,
"vram_utilization_pct": round((_linux_used / _linux_total) * 100, 1)
if _linux_total > 0
else None,
"power_draw_w": _linux_power,
"power_limit_w": None,
"power_utilization_pct": None,
}
# Last resort: torch mem_get_info (process-local).
_visible_spec = _get_parent_visible_gpu_spec()
_numeric_ids = _visible_spec.get("numeric_ids") or [0]
_primary_idx = [_numeric_ids[0]] if _numeric_ids else [0]
_torch_devices = _torch_get_per_device_info(_primary_idx)
if _torch_devices:
_td = _torch_devices[0]
_total = _td["total_gb"]
_used = _td["used_gb"]
return {
"available": True,
"backend": _backend_label(device),
"gpu_utilization_pct": None,
"temperature_c": None,
"vram_used_gb": _used,
"vram_total_gb": _total,
"vram_utilization_pct": round((_used / _total) * 100, 1) if _total > 0 else None,
"power_draw_w": None,
"power_limit_w": None,
"power_utilization_pct": None,
}
return _gpu_utilization_payload(
device,
[
{
"available": True,
"backend": _backend_label(device),
"index": 0,
"visible_ordinal": 0,
"gpu_utilization_pct": _linux_util,
"temperature_c": _linux_temp,
"vram_used_gb": _linux_used,
"vram_total_gb": _linux_total,
"vram_utilization_pct": round((_linux_used / _linux_total) * 100, 1)
if _linux_total > 0
else None,
"power_draw_w": _linux_power,
"power_limit_w": None,
"power_utilization_pct": None,
}
],
)
# MLX: _read_apple_gpu_stats() carries both VRAM-used and GPU util%.
# Last resort: torch mem_get_info (process-local) for all visible GPUs
_visible_spec = _get_parent_visible_gpu_spec()
_numeric_ids = _visible_spec.get("numeric_ids") or []
if not _numeric_ids:
visible_count = _torch_get_physical_gpu_count() or 0
_numeric_ids = list(range(visible_count))
_torch_devices = _torch_get_per_device_info(_numeric_ids)
if _torch_devices:
gpu_array = []
for _td in _torch_devices:
_total = _td["total_gb"]
_used = _td["used_gb"]
gpu_array.append(
{
"available": True,
"backend": _backend_label(device),
"index": _td["index"],
"name": _td.get("name", "Unknown"),
"gpu_utilization_pct": None,
"temperature_c": None,
"vram_used_gb": _used,
"vram_total_gb": _total,
"vram_utilization_pct": round((_used / _total) * 100, 1)
if _total > 0
else None,
"power_draw_w": None,
"power_limit_w": None,
"power_utilization_pct": None,
}
)
return _gpu_utilization_payload(device, gpu_array)
# MLX
if device == DeviceType.MLX:
try:
import psutil
@ -793,9 +870,8 @@ def get_gpu_utilization() -> Dict[str, Any]:
total_bytes = psutil.virtual_memory().total
except Exception as e:
logger.error(f"Error getting MLX GPU utilization: {e}")
return {"available": False, "backend": device.value, "error": str(e)}
if not agx:
return {"available": False, "backend": device.value}
return {"available": False, "backend": device.value, "devices": [], "error": str(e)}
allocated_bytes = agx.get("vram_used_bytes", 0) or 0
vram_used_gb = allocated_bytes / (1024**3)
total_gb = total_bytes / (1024**3)
@ -814,37 +890,51 @@ def get_gpu_utilization() -> Dict[str, Any]:
from . import apple
return {
"available": True,
"backend": device.value,
"gpu_utilization_pct": agx.get("utilization_pct") if agx else None,
"temperature_c": apple.read_gpu_temperature_c(),
"vram_used_gb": round(vram_used_gb, 2),
"vram_total_gb": round(total_gb, 2),
"vram_utilization_pct": (
round((vram_used_gb / total_gb) * 100, 1) if total_gb > 0 else None
),
"power_draw_w": apple.read_gpu_power_w(),
"power_limit_w": None,
"power_utilization_pct": None,
}
return _gpu_utilization_payload(
device,
[
{
"available": True,
"backend": device.value,
"index": 0,
"visible_ordinal": 0,
"gpu_utilization_pct": agx.get("utilization_pct") if agx else None,
"temperature_c": apple.read_gpu_temperature_c(),
"vram_used_gb": round(vram_used_gb, 2),
"vram_total_gb": round(total_gb, 2),
"vram_utilization_pct": round((vram_used_gb / total_gb) * 100, 1)
if total_gb > 0
else None,
"power_draw_w": apple.read_gpu_power_w(),
"power_limit_w": None,
"power_utilization_pct": None,
}
],
)
mem = get_gpu_memory_info()
if device != DeviceType.CPU and mem.get("available"):
return {
"available": True,
"backend": _backend_label(device),
"gpu_utilization_pct": None,
"temperature_c": None,
"vram_used_gb": round(mem.get("allocated_gb", 0), 2),
"vram_total_gb": round(mem.get("total_gb", 0), 2),
"vram_utilization_pct": round(mem.get("utilization_pct", 0), 1),
"power_draw_w": None,
"power_limit_w": None,
"power_utilization_pct": None,
}
return _gpu_utilization_payload(
device,
[
{
"available": True,
"backend": _backend_label(device),
"index": mem.get("device", 0),
"visible_ordinal": 0,
"gpu_utilization_pct": None,
"temperature_c": None,
"vram_used_gb": round(mem.get("allocated_gb", 0), 2),
"vram_total_gb": round(mem.get("total_gb", 0), 2),
"vram_utilization_pct": round(mem.get("utilization_pct", 0), 1),
"power_draw_w": None,
"power_limit_w": None,
"power_utilization_pct": None,
}
],
)
return {"available": False, "backend": _backend_label(device)}
return {"available": False, "backend": _backend_label(device), "devices": []}
def _apply_unified_memory_correction(

View file

@ -0,0 +1,160 @@
// 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 { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useMonitorOverlayStore } from "@/features/settings/stores/monitor-overlay-store";
import { useSystemInfo } from "@/hooks/use-system";
import { useT } from "@/i18n";
import { cn } from "@/lib/utils";
import { CpuIcon, GripVerticalIcon, XIcon } from "lucide-react";
import { motion } from "motion/react";
import { useRef } from "react";
function clampPercent(value: number): number {
return Math.max(0, Math.min(100, value));
}
function usageIndicatorClass(percent: number): string {
if (percent >= 90) return "bg-destructive";
if (percent >= 70) return "bg-amber-500";
return "bg-primary";
}
function usageTextClass(percent: number): string {
if (percent >= 90) return "text-destructive";
if (percent >= 70) return "text-amber-600 dark:text-amber-400";
return "text-primary";
}
function formatGb(value: number): string {
const digits = value >= 10 ? 1 : 2;
return `${value.toFixed(digits)} GB`;
}
export function FloatingMonitor() {
const t = useT();
const { isOpen, setIsOpen } = useMonitorOverlayStore();
const systemInfo = useSystemInfo({ enabled: isOpen, pollMs: 5000 });
const constraintsRef = useRef<HTMLDivElement>(null);
if (!isOpen) return null;
const ramTotal = systemInfo.memory?.total_gb ?? 0;
const ramAvailable = systemInfo.memory?.available_gb ?? 0;
const ramUsed = Math.max(0, ramTotal - ramAvailable);
const ramPercent = clampPercent(systemInfo.memory?.percent_used ?? 0);
const devices = systemInfo.gpu?.devices ?? [];
const vramTotal = devices.reduce(
(sum, device) => sum + (device.memory_total_gb ?? 0),
0,
);
const vramUsed = devices.reduce(
(sum, device) => sum + (device.vram_used_gb ?? 0),
0,
);
const vramPercent = clampPercent(
vramTotal > 0 ? (vramUsed / vramTotal) * 100 : 0,
);
const hasGpu = (systemInfo.gpu?.available ?? false) && devices.length > 0;
return (
<div
ref={constraintsRef}
className="fixed inset-0 z-50 pointer-events-none"
>
<motion.div
layout={true}
drag={true}
dragConstraints={constraintsRef}
dragElastic={0.1}
dragMomentum={false}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="settings-surface fixed bottom-4 right-4 w-64 max-w-[calc(100vw-2rem)] resize overflow-hidden rounded-xl border border-border/70 p-3 shadow-border ring-0 backdrop-blur-sm pointer-events-auto cursor-default select-none"
>
<div className="mb-2 flex items-center justify-between gap-2 border-b border-border/60 pb-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-xs font-semibold text-foreground">
<CpuIcon className="size-3.5 shrink-0 text-primary" />
<span className="truncate">
{t("settings.resources.liveMonitor.title")}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<div className="cursor-grab rounded-md px-1 text-muted-foreground/60 transition-colors hover:bg-muted/60 hover:text-muted-foreground active:cursor-grabbing">
<GripVerticalIcon className="size-3.5" />
</div>
<Button
size="icon-xs"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={() => setIsOpen(false)}
title={t("common.close")}
aria-label={t("common.close")}
>
<XIcon className="size-3" />
</Button>
</div>
</div>
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="space-y-3 overflow-hidden"
>
<div className="space-y-1">
<div className="flex justify-between text-[11px] font-medium font-mono">
<span>{t("settings.resources.liveMonitor.ram")}</span>
<span className={cn("tabular-nums", usageTextClass(ramPercent))}>
{Math.round(ramPercent)}%
</span>
</div>
<div className="text-xs text-muted-foreground font-mono tabular-nums">
{formatGb(ramUsed)} / {formatGb(ramTotal)}
</div>
<Progress
value={ramPercent}
className="mt-1 h-1.5 rounded-full bg-muted"
indicatorClassName={usageIndicatorClass(ramPercent)}
/>
</div>
{hasGpu && (
<div className="space-y-1">
<div className="flex justify-between text-[11px] font-medium font-mono">
<span className="truncate flex-1 pr-2">
{t("settings.resources.liveMonitor.vram")}{" "}
{devices.length > 1
? `(${devices.length} GPUs)`
: `(${devices[0].name ?? "GPU"})`}
</span>
<span
className={cn(
"shrink-0 tabular-nums",
usageTextClass(vramPercent),
)}
>
{Math.round(vramPercent)}%
</span>
</div>
<div className="text-xs text-muted-foreground font-mono tabular-nums">
{formatGb(vramUsed)} / {formatGb(vramTotal)}
</div>
<Progress
value={vramPercent}
className="mt-1 h-1.5 rounded-full bg-muted"
indicatorClassName={usageIndicatorClass(vramPercent)}
/>
</div>
)}
</motion.div>
</motion.div>
</div>
);
}

View file

@ -15,6 +15,7 @@ import {
PackageIcon,
RamMemoryIcon,
RemoveCircleIcon,
CpuIcon
} from "@hugeicons/core-free-icons";
import type { IconSvgElement } from "@hugeicons/react";
import { HugeiconsIcon } from "@hugeicons/react";
@ -43,6 +44,7 @@ export function ModelsHeader({
isDataset,
gpuLabel,
ramLabel,
coreLabel,
activeCheckpoint,
activeGgufVariant,
onTitleClick,
@ -53,6 +55,7 @@ export function ModelsHeader({
isDataset: boolean;
gpuLabel: string;
ramLabel: string;
coreLabel: string;
activeCheckpoint: string | null;
activeGgufVariant: string | null;
onTitleClick: () => void;
@ -84,7 +87,8 @@ export function ModelsHeader({
value={String(localCount)}
/>
<StatPill icon={ChipIcon} label="VRAM" value={gpuLabel} />
<StatPill icon={RamMemoryIcon} label="CPU RAM" value={ramLabel} />
<StatPill icon={RamMemoryIcon} label="RAM" value={ramLabel} />
<StatPill icon={CpuIcon} label="CPU" value={coreLabel} />
{activeCheckpoint && (
<div className="hub-tag-soft ml-1 inline-flex items-center gap-1.5 px-2 py-1 text-[11.5px]">

View file

@ -1085,11 +1085,15 @@ export function ModelsPage() {
const { vramInfo, minMemory } = useHubModelVram(selectedModel, gpu);
const gpuLabel = gpu.available
? `${Math.floor(gpu.memoryTotalGb)} GB`
? `${Math.round(gpu.memoryTotalGb)} GB`
: "Unavailable";
const ramLabel =
gpu.systemRamAvailableGb > 0
? `${Math.floor(gpu.systemRamAvailableGb)} GB`
gpu.systemRamTotalGb > 0
? `${Math.round(gpu.systemRamTotalGb)} GB`
: "Unavailable";
const coreLabel =
gpu.cpuCore > 0 && gpu.cpuThread > 0
? `${gpu.cpuCore}/${gpu.cpuThread}`
: "Unavailable";
const openNewChat = useCallback(() => {
@ -1453,6 +1457,7 @@ export function ModelsPage() {
isDataset={isDatasetMode}
gpuLabel={gpuLabel}
ramLabel={ramLabel}
coreLabel={coreLabel}
activeCheckpoint={activeCheckpoint}
activeGgufVariant={activeGgufVariant}
onTitleClick={handleResetToDiscover}

View file

@ -33,44 +33,58 @@ import {
updateOpenAIAutoSwitchSettings,
} from "../api/openai-auto-switch";
// API call type; OS axis applies to curl only (Python is OS-identical).
type ExampleType =
| "curl"
| "python"
| "javascript"
| "curlTools"
| "pythonTools"
| "javascriptTools"
| "curlAdvanced"
| "pythonAdvanced";
| "pythonAdvanced"
| "javascriptAdvanced";
type Os = "unix" | "windows";
// plain = bare call; tools = server-side tools; advanced = sampling + thinking + tools.
type Variant = "plain" | "tools" | "advanced";
const TYPE_TABS: { id: ExampleType; label: string }[] = [
{ id: "curl", label: "curl" },
{ id: "python", label: "Python" },
{ id: "javascript", label: "JavaScript" },
{ id: "curlTools", label: "curl + tools" },
{ id: "pythonTools", label: "Python + tools" },
{ id: "javascriptTools", label: "JavaScript + tools" },
{ id: "curlAdvanced", label: "curl + advanced" },
{ id: "pythonAdvanced", label: "Python + advanced" },
{ id: "javascriptAdvanced", label: "JavaScript + advanced" },
];
const TYPE_LABEL_KEY: Partial<Record<ExampleType, TranslationKey>> = {
curlTools: "settings.apiKeys.exampleCurlTools",
pythonTools: "settings.apiKeys.examplePythonTools",
javascriptTools: "settings.apiKeys.exampleJavaScriptTools",
curlAdvanced: "settings.apiKeys.exampleCurlAdvanced",
pythonAdvanced: "settings.apiKeys.examplePythonAdvanced",
javascriptAdvanced: "settings.apiKeys.exampleJavaScriptAdvanced",
};
const OS_AWARE: Record<ExampleType, boolean> = {
curl: true,
python: false,
javascript: false,
curlTools: true,
pythonTools: false,
javascriptTools: false,
curlAdvanced: true,
pythonAdvanced: false,
javascriptAdvanced: false,
};
const CURL_TYPES = new Set<ExampleType>(["curl", "curlTools", "curlAdvanced"]);
const JAVASCRIPT_TYPES = new Set<ExampleType>([
"javascript",
"javascriptTools",
"javascriptAdvanced",
]);
const PROMPT = "Can Unsloth Studio do API calling?";
// Auto-switch demo: a second call naming a different downloaded GGUF so the
@ -82,7 +96,6 @@ const SWITCH_MODEL = "your-other-downloaded-GGUF";
const SWITCH_PROMPT = "Now answer as a different model.";
// web_search + python + terminal are the reliable built-in tools.
const TOOLS = ["web_search", "python", "terminal"];
// Sampling/thinking knobs for the "+ advanced" examples.
const ADV = {
temperature: 0.7,
top_p: 0.8,
@ -93,37 +106,18 @@ const ADV = {
} as const;
const DOC_LINKS = [
{
label: "Claude Code",
href: "https://unsloth.ai/docs/basics/claude-code",
},
{
label: "Codex",
href: "https://unsloth.ai/docs/basics/codex",
},
{
label: "OpenClaw",
href: "https://unsloth.ai/docs/integrations/openclaw",
},
{
label: "OpenCode",
href: "https://unsloth.ai/docs/integrations/opencode",
},
{
label: "Hermes Agent",
href: "https://unsloth.ai/docs/integrations/hermes-agent",
},
{ label: "Claude Code", href: "https://unsloth.ai/docs/basics/claude-code" },
{ label: "Codex", href: "https://unsloth.ai/docs/basics/codex" },
{ label: "OpenClaw", href: "https://unsloth.ai/docs/integrations/openclaw" },
{ label: "OpenCode", href: "https://unsloth.ai/docs/integrations/opencode" },
{ label: "Hermes Agent", href: "https://unsloth.ai/docs/integrations/hermes-agent" },
];
// JSON-encode; also a valid Python literal, so odd model names never break output.
const j = (s: string): string => JSON.stringify(s);
// Embed in a POSIX single-quoted string: close, escaped quote, reopen.
const shSingle = (s: string): string => s.replace(/'/g, "'\\''");
// Embed in a PowerShell single-quoted string: '' is a literal quote.
const psSingle = (s: string): string => s.replace(/'/g, "''");
const toolsJson = TOOLS.map(j).join(", ");
// Shared body fields (after model/messages, before stream) per variant.
function bodyExtraLines(variant: Variant, indent: string): string[] {
const lines: string[] = [];
if (variant === "advanced") {
@ -152,7 +146,6 @@ function curlBodyPretty(model: string, variant: Variant): string {
return `{\n${lines.join("\n")}\n }`;
}
// One-line JSON for the Windows body file (PowerShell mangles inline quotes to curl.exe).
function winBody(model: string, variant: Variant): string {
const body: Record<string, unknown> = {
model,
@ -172,7 +165,7 @@ function winBody(model: string, variant: Variant): string {
body.enabled_tools = TOOLS;
}
body.stream = true;
return JSON.stringify(body);
return JSON.stringify(body, null, 2);
}
// A leading comment (valid in both bash and PowerShell) noting the model field
@ -193,7 +186,6 @@ function curlUnix(
-d '${shSingle(curlBodyPretty(model, variant))}'`;
}
// Windows PowerShell: curl aliases to Invoke-WebRequest, so use curl.exe + body file.
function curlWindows(
base: string,
key: string,
@ -233,7 +225,6 @@ function pythonSnippet(
variant: Variant,
autoSwitch: boolean,
): string {
// Standard OpenAI args are named; Unsloth extensions go through extra_body.
const named =
variant === "advanced"
? `
@ -258,7 +249,6 @@ function pythonSnippet(
${extra.join("\n")}
},`
: "";
// With tools, some chunks are tool-lifecycle events with no choices; guard it.
const loop =
variant !== "plain"
? `for chunk in response:
@ -281,6 +271,70 @@ response = client.chat.completions.create(
${loop}${autoSwitch ? pythonSwitchDemo() : ""}`;
}
function javascriptSnippet(
base: string,
key: string,
model: string,
variant: Variant,
autoSwitch: boolean,
): string {
const options: string[] = [];
if (variant === "advanced") {
options.push(` temperature: ${ADV.temperature},`);
options.push(` top_p: ${ADV.top_p},`);
options.push(` max_tokens: ${ADV.max_tokens},`);
}
// The JS SDK forwards unknown options into the request body, so these go at the
// top level (the Python SDK needs them under extra_body instead).
if (variant === "advanced") {
options.push(` top_k: ${ADV.top_k},`);
options.push(` min_p: ${ADV.min_p},`);
options.push(` repetition_penalty: ${ADV.repetition_penalty},`);
options.push(` enable_thinking: true,`);
}
if (variant !== "plain") {
options.push(` enable_tools: true,`);
options.push(` enabled_tools: [${toolsJson}],`);
}
const trailingOptions = options.length ? `\n${options.join("\n")}` : "";
return `import OpenAI from "openai";
const client = new OpenAI({
baseURL: ${j(`${base}/v1`)},
apiKey: ${j(key)},
});
const response = await client.chat.completions.create({
model: ${j(model)},
messages: [{ role: "user", content: ${j(PROMPT)} }],${trailingOptions}
stream: true,
});
for await (const chunk of response) {
process.stdout.write(chunk.choices?.[0]?.delta?.content || "");
}${autoSwitch ? javascriptSwitchDemo() : ""}`;
}
function javascriptSwitchDemo(): string {
return `
// "Switch model by request" is on: replace the model below with another GGUF you
// have downloaded and Studio loads it before serving. Unknown names keep serving
// the current model.
const switchResponse = await client.chat.completions.create({
model: ${j(SWITCH_MODEL)},
messages: [{ role: "user", content: ${j(SWITCH_PROMPT)} }],
stream: true,
});
for await (const chunk of switchResponse) {
process.stdout.write(chunk.choices?.[0]?.delta?.content || "");
}`;
}
function buildSnippets(
base: string,
key: string,
@ -292,17 +346,24 @@ function buildSnippets(
return {
curl: curl(base, key, model, "plain", autoSwitch),
python: pythonSnippet(base, key, model, "plain", autoSwitch),
javascript: javascriptSnippet(base, key, model, "plain", autoSwitch),
curlTools: curl(base, key, model, "tools", autoSwitch),
pythonTools: pythonSnippet(base, key, model, "tools", autoSwitch),
javascriptTools: javascriptSnippet(base, key, model, "tools", autoSwitch),
curlAdvanced: curl(base, key, model, "advanced", autoSwitch),
pythonAdvanced: pythonSnippet(base, key, model, "advanced", autoSwitch),
javascriptAdvanced: javascriptSnippet(
base,
key,
model,
"advanced",
autoSwitch,
),
};
}
const KEY_PLACEHOLDER = "sk-unsloth-YOUR_KEY";
const MODEL_FALLBACK = "unsloth/gemma-4-E4B-it-GGUF:UD-Q5_K_XL";
// Default ON: when a tunnel exists, examples should show the public base_url.
const USE_TUNNEL_KEY = "unsloth_api_use_tunnel";
function readUseTunnelPref(): boolean {
@ -319,11 +380,10 @@ function writeUseTunnelPref(value: boolean): void {
try {
window.localStorage.setItem(USE_TUNNEL_KEY, value ? "true" : "false");
} catch {
// Non-fatal: the toggle still applies for this session.
// Non-fatal
}
}
// Active local checkpoint as repo[:variant]; external/none falls back to a default.
function useLoadedModelName(): string {
const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint);
const ggufVariant = useChatRuntimeStore((s) => s.activeGgufVariant);
@ -338,7 +398,6 @@ function useLoadedModelName(): string {
}, [checkpoint, ggufVariant]);
}
// shiki highlighting via the app's shared code plugin + themes (same as chat).
const SHIKI_THEMES = [unslothLightTheme, unslothDarkTheme] as [
typeof unslothLightTheme,
typeof unslothDarkTheme,
@ -352,7 +411,6 @@ function HighlightedCode({
code: string;
language: string;
}) {
// Fence so Streamdown's shiki plugin highlights it (no markdown inside a fence).
const markdown = useMemo(
() => `\`\`\`${language}\n${code}\n\`\`\``,
[code, language],
@ -390,7 +448,6 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
);
const [savingAutoSwitch, setSavingAutoSwitch] = useState(false);
// Tunnel may start after the first /api/health read; refresh so it surfaces here.
useEffect(() => {
void fetchDeviceType({ force: true });
}, []);
@ -410,10 +467,7 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
}, []);
const model = useLoadedModelName();
// Real key while revealed (before "Done"); otherwise a placeholder.
const key = apiKey || KEY_PLACEHOLDER;
// Toggle on + tunnel up: public tunnel URL. Off: backend direct host:port
// (origin is only a last-resort fallback).
const origin = typeof window !== "undefined" ? window.location.origin : "";
const base =
useTunnel && cloudflareUrl ? cloudflareUrl : (serverUrl ?? origin);
@ -429,7 +483,9 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
? os === "windows"
? "powershell"
: "bash"
: "python";
: JAVASCRIPT_TYPES.has(lang)
? "javascript"
: "python";
const handleCopy = async () => {
if (await copyToClipboard(snippets[lang])) {
@ -539,8 +595,6 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
</Tooltip>
)}
</div>
{/* Always rendered (dimmed when off) so toggling never changes the
row height and shifts the code block below. */}
<button
type="button"
onClick={handleCopyUrl}
@ -629,9 +683,6 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
/>
{copied ? t("settings.apiKeys.copied") : t("settings.apiKeys.copy")}
</button>
{/* key on the snippet so Streamdown remounts and re-highlights when
only a substring (e.g. the base URL) changes; its block memo
otherwise keeps the stale render. */}
<HighlightedCode
key={snippets[lang]}
code={snippets[lang]}

View file

@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
import {
Cancel01Icon,
CloudIcon,
CpuIcon,
Globe02Icon,
HelpCircleIcon,
Message01Icon,
@ -33,6 +34,8 @@ import { ChatTab } from "./tabs/chat-tab";
import { ConnectionsTab } from "./tabs/connections-tab";
import { GeneralTab } from "./tabs/general-tab";
import { ProfileTab } from "./tabs/profile-tab";
import { ResourcesTab } from "./tabs/resources-tab";
import { FloatingMonitor } from "@/components/floating-monitor";
interface TabDef {
id: SettingsTab;
@ -49,6 +52,11 @@ const TABS: TabDef[] = [
labelKey: "settings.tabs.appearance",
icon: PaintBrush02Icon,
},
{
id: "resources",
labelKey: "settings.tabs.resources",
icon: CpuIcon,
},
{
id: "chat",
labelKey: "settings.tabs.chat",
@ -77,6 +85,8 @@ function renderTab(tab: SettingsTab) {
return <ProfileTab />;
case "appearance":
return <AppearanceTab />;
case "resources":
return <ResourcesTab />;
case "chat":
return <ChatTab />;
case "connections":
@ -100,6 +110,7 @@ export function SettingsDialog() {
general: null,
profile: null,
appearance: null,
resources: null,
chat: null,
connections: null,
"api-keys": null,
@ -115,110 +126,113 @@ export function SettingsDialog() {
}, [open, activeTab]);
return (
<Dialog open={open} onOpenChange={(o) => !o && closeDialog()}>
<DialogContent
showCloseButton={false}
overlayClassName="bg-black/30 supports-backdrop-filter:backdrop-blur-[2px]"
onCloseAutoFocus={(e) => {
// Restore focus to the element that triggered openDialog(). Radix's
// FocusScope races our rAF-scheduled tab focus and loses the
// previous-focus reference, so restore it by hand.
if (opener && opener.isConnected) {
e.preventDefault();
opener.focus({ preventScroll: true });
}
}}
className={cn(
// Cap at 820px but shrink to the viewport so it doesn't clip on
// iPad-portrait widths (640-820px) where fixed `w-[820px]` overflows.
"settings-surface !max-w-[min(820px,calc(100vw-2rem))] h-[560px] w-[min(820px,calc(100vw-2rem))] p-0 overflow-hidden",
// Soft shadow, no outline ring. Pin --radius to the light value so
// corner rounding matches in dark mode.
"shadow-border rounded-xl ring-0 [--radius:1.1rem]",
"max-sm:h-dvh max-sm:w-dvw max-sm:!max-w-none max-sm:rounded-none",
)}
>
<DialogTitle className="sr-only">
{t("settings.dialog.title")}
</DialogTitle>
<DialogDescription className="sr-only">
{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-sidebar-border bg-muted/20 p-2 dark:border-r-0 max-sm:w-full max-sm:border-r-0 max-sm:border-b max-sm:border-sidebar-border">
<h2 className="pl-3 pr-2.5 pt-3.5 pb-3.5 text-[19px] font-semibold text-foreground max-sm:hidden">
{t("settings.dialog.title")}
</h2>
<nav className="flex flex-col gap-0.5 max-sm:flex-row max-sm:overflow-x-auto">
{TABS.map((tab) => {
const active = activeTab === tab.id;
return (
<button
key={tab.id}
ref={(node) => {
tabButtonRefs.current[tab.id] = node;
}}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
"relative flex h-[32px] items-center gap-2.5 rounded-full pl-3 pr-2.5 text-[14.5px] leading-[19px] tracking-nav font-medium transition-colors",
"max-sm:shrink-0",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
active
? "text-black dark:text-white"
: "text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec] dark:hover:bg-[#3a3d43] hover:text-black dark:hover:text-white",
)}
>
{active && (
<motion.span
layoutId="settings-active-pill"
className="absolute inset-0 rounded-full bg-[#ececec] dark:bg-[#3a3d43]"
transition={
reduced
? { duration: 0 }
: {
<>
<Dialog open={open} onOpenChange={(o) => !o && closeDialog()}>
<DialogContent
showCloseButton={false}
overlayClassName="bg-black/30 supports-backdrop-filter:backdrop-blur-[2px]"
onCloseAutoFocus={(e) => {
// Restore focus to the element that triggered openDialog(). Radix's
// FocusScope races our rAF-scheduled tab focus and loses the
// previous-focus reference, so restore it by hand.
if (opener && opener.isConnected) {
e.preventDefault();
opener.focus({ preventScroll: true });
}
}}
className={cn(
// Cap at 820px but shrink to the viewport so it doesn't clip on
// iPad-portrait widths (640-820px) where fixed `w-[820px]` overflows.
"settings-surface !max-w-[min(820px,calc(100vw-2rem))] h-[560px] w-[min(820px,calc(100vw-2rem))] p-0 overflow-hidden",
// Soft shadow, no outline ring. Pin --radius to the light value so
// corner rounding matches in dark mode.
"shadow-border rounded-xl ring-0 [--radius:1.1rem]",
"max-sm:h-dvh max-sm:w-dvw max-sm:!max-w-none max-sm:rounded-none",
)}
>
<DialogTitle className="sr-only">
{t("settings.dialog.title")}
</DialogTitle>
<DialogDescription className="sr-only">
{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-sidebar-border bg-muted/20 p-2 dark:border-r-0 max-sm:w-full max-sm:border-r-0 max-sm:border-b max-sm:border-sidebar-border">
<h2 className="pl-3 pr-2.5 pt-3.5 pb-3.5 text-[19px] font-semibold text-foreground max-sm:hidden">
{t("settings.dialog.title")}
</h2>
<nav className="flex flex-col gap-0.5 max-sm:flex-row max-sm:overflow-x-auto">
{TABS.map((tab) => {
const active = activeTab === tab.id;
return (
<button
key={tab.id}
ref={(node) => {
tabButtonRefs.current[tab.id] = node;
}}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
"relative flex h-[32px] items-center gap-2.5 rounded-full pl-3 pr-2.5 text-[14.5px] leading-[19px] tracking-nav font-medium transition-colors",
"max-sm:shrink-0",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
active
? "text-black dark:text-white"
: "text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec] dark:hover:bg-[#3a3d43] hover:text-black dark:hover:text-white",
)}
>
{active && (
<motion.span
layoutId="settings-active-pill"
className="absolute inset-0 rounded-full bg-[#ececec] dark:bg-[#3a3d43]"
transition={
reduced
? { duration: 0 }
: {
type: "spring",
stiffness: 500,
damping: 35,
mass: 0.5,
}
}
}
/>
)}
<HugeiconsIcon
icon={tab.icon}
strokeWidth={1.75}
className="relative z-10 size-icon"
/>
)}
<HugeiconsIcon
icon={tab.icon}
strokeWidth={1.75}
className="relative z-10 size-icon"
/>
<span className="relative z-10 min-w-0 truncate">
{t(tab.labelKey)}
</span>
{tab.badgeKey ? (
<span className="relative z-10 ml-auto rounded-full bg-emerald-500/10 px-2 py-1 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300">
{t(tab.badgeKey)}
<span className="relative z-10 min-w-0 truncate">
{t(tab.labelKey)}
</span>
) : null}
</button>
);
})}
</nav>
</aside>
{tab.badgeKey ? (
<span className="relative z-10 ml-auto rounded-full bg-emerald-500/10 px-2 py-1 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300">
{t(tab.badgeKey)}
</span>
) : null}
</button>
);
})}
</nav>
</aside>
<main className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<button
type="button"
onClick={closeDialog}
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-full text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#3a3d43] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("settings.dialog.closeAriaLabel")}
>
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
</button>
<div className="hover-scrollbar flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-6 [scrollbar-gutter:stable]">
{renderTab(activeTab)}
</div>
</main>
</div>
</DialogContent>
</Dialog>
<main className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<button
type="button"
onClick={closeDialog}
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-full text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#3a3d43] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("settings.dialog.closeAriaLabel")}
>
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
</button>
<div className="hover-scrollbar flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-6 [scrollbar-gutter:stable]">
{renderTab(activeTab)}
</div>
</main>
</div>
</DialogContent>
</Dialog>
<FloatingMonitor />
</>
);
}

View file

@ -0,0 +1,24 @@
// 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 { create } from "zustand";
import { persist } from "zustand/middleware";
interface MonitorOverlayState {
isOpen: boolean;
isMinimized: boolean;
setIsOpen: (open: boolean) => void;
toggleMinimized: () => void;
}
export const useMonitorOverlayStore = create<MonitorOverlayState>()(
persist(
(set) => ({
isOpen: false,
isMinimized: false,
setIsOpen: (isOpen) => set({ isOpen }),
toggleMinimized: () => set((state) => ({ isMinimized: !state.isMinimized })),
}),
{ name: "unsloth_monitor_overlay" }
)
);

View file

@ -7,6 +7,7 @@ export type SettingsTab =
| "general"
| "profile"
| "appearance"
| "resources"
| "chat"
| "connections"
| "api-keys"
@ -60,6 +61,7 @@ function loadInitialTab(): SettingsTab {
"general",
"profile",
"appearance",
"resources",
"chat",
"connections",
"api-keys",

View file

@ -104,6 +104,7 @@ const PREFS_KEYS: string[] = [
"tour:studio:v1",
// Update notifications
"unsloth_show_llama_update_banner",
"unsloth_monitor_overlay",
];
// Set by resetAllPrefs so the unmount-commit effect skips writing back the

View file

@ -0,0 +1,477 @@
// 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 { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Switch } from "@/components/ui/switch";
import { openModelsDir } from "@/features/native-intents";
import { useSystemInfo, type GpuDevice } from "@/hooks/use-system";
import { isTauri } from "@/lib/api-base";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
import { useT } from "@/i18n";
import { useEffect, useMemo, useState } from "react";
import { loadModelsFolder, type ModelsFolder } from "../api/models-folder";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
import { useMonitorOverlayStore } from "../stores/monitor-overlay-store";
import { LayersIcon } from "lucide-react";
const POLL_MS = 3000;
function isFiniteNumber(value: number | null | undefined): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function clampPercent(value: number | null | undefined): number {
if (!isFiniteNumber(value)) return 0;
return Math.max(0, Math.min(100, value));
}
function usageIndicatorClass(percent: number): string {
if (percent >= 90) return "bg-destructive";
if (percent >= 70) return "bg-amber-500";
return "bg-primary";
}
function usageTextClass(percent: number): string {
if (percent >= 90) return "text-destructive";
if (percent >= 70) return "text-amber-600 dark:text-amber-400";
return "text-primary";
}
function formatGb(value: number | null | undefined): string {
const safe = isFiniteNumber(value) ? Math.max(0, value) : 0;
const digits = safe >= 10 ? 1 : 2;
return `${safe.toFixed(digits)} GB`;
}
function formatMb(value: number | null | undefined): string {
const safe = isFiniteNumber(value) ? Math.max(0, value) : 0;
return `${Math.round(safe).toLocaleString()} MB`;
}
function formatPercent(value: number | null | undefined): string {
return `${Math.round(clampPercent(value))}%`;
}
function formatFrequency(mhz: number | null | undefined): string | null {
if (!isFiniteNumber(mhz) || mhz <= 0) return null;
if (mhz >= 1000) return `${(mhz / 1000).toFixed(2)} GHz`;
return `${Math.round(mhz)} MHz`;
}
function formatUptime(seconds: number | null | undefined): string {
if (!isFiniteNumber(seconds) || seconds <= 0) return "0m";
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h`;
if (hours > 0) return `${hours}h ${minutes % 60}m`;
return `${Math.max(1, minutes)}m`;
}
function MetricTile({
label,
value,
detail,
percent,
}: {
label: string;
value: string;
detail: string;
percent: number;
}) {
const safePercent = clampPercent(percent);
return (
<div className="flex min-w-0 flex-col gap-2 rounded-md border border-border/60 bg-muted/20 p-3">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{label}
</span>
<span
className={cn(
"shrink-0 font-mono text-xs tabular-nums",
usageTextClass(safePercent),
)}
>
{formatPercent(safePercent)}
</span>
</div>
<div className="min-w-0">
<div className="truncate font-mono text-sm tabular-nums text-foreground">
{value}
</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{detail}
</div>
</div>
<Progress
value={safePercent}
aria-label={label}
className="h-1.5 rounded-full bg-muted"
indicatorClassName={usageIndicatorClass(safePercent)}
/>
</div>
);
}
function InfoRow({
label,
value,
detail,
}: {
label: string;
value: string;
detail?: string;
}) {
return (
<div className="flex min-w-0 items-center justify-between gap-4 py-2.5">
<span className="min-w-0 truncate text-sm font-medium text-foreground">
{label}
</span>
<span
title={detail ?? value}
className="min-w-0 max-w-[60%] truncate text-right font-mono text-xs tabular-nums text-muted-foreground"
>
{detail ? `${value} (${detail})` : value}
</span>
</div>
);
}
function deviceOrdinal(device: GpuDevice): number | undefined {
return device.visible_ordinal ?? device.index;
}
export function ResourcesTab() {
const t = useT();
const [liveUpdates, setLiveUpdates] = useState(true);
const { isOpen, setIsOpen } = useMonitorOverlayStore();
const systemInfo = useSystemInfo({
enabled: liveUpdates,
pollMs: liveUpdates ? POLL_MS : undefined,
});
const [modelsFolder, setModelsFolder] = useState<ModelsFolder | null>(null);
const [modelsFolderLoaded, setModelsFolderLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
void loadModelsFolder()
.then((folder) => {
if (cancelled) return;
setModelsFolder(folder);
setModelsFolderLoaded(true);
})
.catch(() => {
if (cancelled) return;
setModelsFolderLoaded(true);
});
return () => {
cancelled = true;
};
}, []);
const metrics = useMemo(() => {
const devices = systemInfo.gpu?.devices ?? [];
const ramTotal = systemInfo.memory?.total_gb ?? 0;
const ramAvailable = systemInfo.memory?.available_gb ?? 0;
const ramUsed = Math.max(0, ramTotal - ramAvailable);
const diskTotal = systemInfo.disk?.total_gb ?? 0;
const diskFree = systemInfo.disk?.free_gb ?? 0;
const diskUsed = Math.max(0, diskTotal - diskFree);
const vramTotal = devices.reduce(
(sum, device) => sum + (device.memory_total_gb ?? 0),
0,
);
const vramUsed = devices.reduce(
(sum, device) => sum + (device.vram_used_gb ?? 0),
0,
);
const vramFree = devices.reduce(
(sum, device) =>
sum +
(device.vram_free_gb ??
Math.max(0, (device.memory_total_gb ?? 0) - (device.vram_used_gb ?? 0))),
0,
);
const vramPercent = vramTotal > 0 ? (vramUsed / vramTotal) * 100 : 0;
return {
devices,
ramTotal,
ramUsed,
diskTotal,
diskFree,
diskUsed,
vramTotal,
vramUsed,
vramFree,
vramPercent,
};
}, [systemInfo]);
const handleModelsFolder = async () => {
const folder = modelsFolder;
if (!folder) return;
if (isTauri) {
try {
await openModelsDir(folder.path);
} catch (error) {
toast.error(t("settings.resources.storage.openError"), {
description: error instanceof Error ? error.message : undefined,
});
}
return;
}
if (await copyToClipboard(folder.path)) {
toast.success(t("settings.resources.storage.copied"));
} else {
toast.error(t("settings.resources.storage.copyError"));
}
};
const cpuCoresLabel =
systemInfo.cpu?.logical_count && systemInfo.cpu?.physical_count
? t("settings.resources.liveMonitor.cpuCores", {
logical: systemInfo.cpu.logical_count,
physical: systemInfo.cpu.physical_count,
})
: t("settings.resources.environment.unknown");
const cpuFrequencyLabel = formatFrequency(systemInfo.cpu?.frequency_mhz);
const hasGpu =
(systemInfo.gpu?.available ?? false) && metrics.devices.length > 0;
const backendLabel = (
systemInfo.gpu?.backend ?? systemInfo.device_backend ?? "cpu"
).toUpperCase();
const modelsFolderPath = modelsFolder
? modelsFolder.path
: modelsFolderLoaded
? t("settings.resources.environment.unknown")
: t("common.loading");
return (
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<h1 className="text-xl font-semibold font-heading">
{t("settings.resources.title")}
</h1>
<p className="text-xs text-muted-foreground">
{t("settings.resources.description")}
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant={isOpen ? "secondary" : "outline"}
size="sm"
className="gap-1.5 h-8 text-xs rounded-full px-3"
onClick={() => setIsOpen(!isOpen)}
>
<LayersIcon className="size-3.5" />
{isOpen
? t("settings.resources.disableOverlay")
: t("settings.resources.floatingWindow")}
</Button>
<div className="flex shrink-0 items-center gap-2 rounded-full border border-border/60 px-2.5 py-1.5 text-xs font-medium text-foreground">
<span>{t("settings.resources.liveUpdates")}</span>
<Switch
aria-label={t("settings.resources.liveUpdates")}
checked={liveUpdates}
onCheckedChange={setLiveUpdates}
/>
</div>
</div>
</header>
<SettingsSection title={t("settings.resources.liveMonitor.title")}>
<div className="grid gap-2 py-3 sm:grid-cols-2">
<MetricTile
label={t("settings.resources.liveMonitor.cpu")}
value={cpuFrequencyLabel ?? cpuCoresLabel}
detail={
cpuFrequencyLabel
? cpuCoresLabel
: t("settings.resources.liveMonitor.currentLoad")
}
percent={systemInfo.cpu?.usage_percent ?? 0}
/>
<MetricTile
label={t("settings.resources.liveMonitor.ram")}
value={`${formatGb(metrics.ramUsed)} / ${formatGb(metrics.ramTotal)}`}
detail={t("settings.resources.liveMonitor.free", {
value: formatGb(systemInfo.memory?.available_gb),
})}
percent={systemInfo.memory?.percent_used ?? 0}
/>
<MetricTile
label={t("settings.resources.liveMonitor.disk")}
value={`${formatGb(metrics.diskUsed)} / ${formatGb(metrics.diskTotal)}`}
detail={t("settings.resources.liveMonitor.free", {
value: formatGb(metrics.diskFree),
})}
percent={systemInfo.disk?.percent_used ?? 0}
/>
<MetricTile
label={t("settings.resources.liveMonitor.vram")}
value={
hasGpu
? `${formatGb(metrics.vramUsed)} / ${formatGb(metrics.vramTotal)}`
: t("settings.resources.liveMonitor.noGpu")
}
detail={
hasGpu
? t("settings.resources.liveMonitor.free", {
value: formatGb(metrics.vramFree),
})
: backendLabel
}
percent={metrics.vramPercent}
/>
</div>
</SettingsSection>
<SettingsSection title={t("settings.resources.gpu.title")}>
{hasGpu ? (
metrics.devices.map((device, index) => {
const ordinal = deviceOrdinal(device);
const total = device.memory_total_gb ?? 0;
const used = device.vram_used_gb ?? 0;
const free = device.vram_free_gb ?? Math.max(0, total - used);
const percent =
device.vram_utilization_pct ??
(total > 0 ? (used / total) * 100 : null);
const safePercent = clampPercent(percent);
return (
<div
key={`${device.index ?? index}-${device.name ?? "gpu"}`}
className="flex min-w-0 flex-col gap-2 py-3"
>
<div className="flex min-w-0 items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{device.name ??
t("settings.resources.gpu.unknownDevice")}
</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{ordinal === undefined
? backendLabel
: `${t("settings.resources.gpu.deviceWithIndex", {
index: ordinal,
})}, ${backendLabel}`}
</div>
</div>
<div className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
<span>
{formatPercent(safePercent)}{" "}
{t("settings.resources.gpu.vramUtilization")}
</span>
</div>
</div>
<div className="grid gap-1 text-xs text-muted-foreground sm:grid-cols-3 sm:gap-2">
<span className="min-w-0 truncate font-mono tabular-nums">
{t("settings.resources.gpu.used", {
value: formatGb(used),
})}
</span>
<span className="min-w-0 truncate font-mono tabular-nums sm:text-center">
{t("settings.resources.gpu.free", {
value: formatGb(free),
})}
</span>
<span className="min-w-0 truncate font-mono tabular-nums sm:text-right">
{t("settings.resources.gpu.total", {
value: formatGb(total),
})}
</span>
</div>
<Progress
value={safePercent}
aria-label={device.name ?? "GPU"}
className="h-1.5 rounded-full bg-muted"
indicatorClassName={usageIndicatorClass(safePercent)}
/>
</div>
);
})
) : (
<div className="py-3 text-sm text-muted-foreground">
{t("settings.resources.gpu.noGpu")}
</div>
)}
</SettingsSection>
<SettingsSection title={t("settings.resources.storage.title")}>
<InfoRow
label={t("settings.resources.storage.systemDisk")}
value={t("settings.resources.storage.diskUsage", {
used: formatGb(metrics.diskUsed),
total: formatGb(metrics.diskTotal),
})}
detail={t("settings.resources.storage.diskFree", {
free: formatGb(metrics.diskFree),
})}
/>
<SettingsRow
label={t("settings.resources.storage.modelsFolder")}
description={t("settings.resources.storage.modelsFolderDescription")}
className="max-sm:flex-col max-sm:items-start max-sm:gap-2"
>
<div className="flex min-w-0 items-center gap-2 max-sm:max-w-[calc(100vw-5rem)]">
<span
title={modelsFolder?.path}
className="min-w-0 max-w-[280px] truncate font-mono text-xs text-muted-foreground max-sm:max-w-[180px]"
>
{modelsFolderPath}
</span>
<Button
variant="outline"
size="sm"
disabled={!modelsFolder}
onClick={() => void handleModelsFolder()}
>
{isTauri
? t("settings.resources.storage.openAction")
: t("settings.resources.storage.copyAction")}
</Button>
</div>
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.resources.environment.title")}>
<InfoRow
label={t("settings.resources.environment.backend")}
value={backendLabel}
/>
<InfoRow
label={t("settings.resources.environment.python")}
value={systemInfo.python_version}
/>
<InfoRow
label={t("settings.resources.environment.torch")}
value={
systemInfo.ml_packages.torch ??
t("settings.resources.environment.notInstalled")
}
/>
<InfoRow
label={t("settings.resources.environment.transformers")}
value={
systemInfo.ml_packages.transformers ??
t("settings.resources.environment.notInstalled")
}
/>
<InfoRow
label={t("settings.resources.environment.uptime")}
value={formatUptime(systemInfo.uptime_seconds)}
/>
<InfoRow
label={t("settings.resources.environment.processMemory")}
value={formatMb(systemInfo.memory?.process_used_mb)}
/>
</SettingsSection>
</div>
);
}

View file

@ -29,6 +29,7 @@ import {
import { getTrainingMethodLabel } from "@/features/training/lib/training-methods";
import type { TrainingViewData } from "@/features/training";
import { useGpuUtilization } from "@/hooks";
import type { GpuUtilization } from "@/hooks/use-gpu-utilization";
import { cn } from "@/lib/utils";
import {
ChartAverageIcon,
@ -42,7 +43,7 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Link, useNavigate } from "@tanstack/react-router";
import { type ReactElement, type ReactNode, useState } from "react";
import { type ReactElement, type ReactNode, useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { ChartSettingsSheet } from "./charts/chart-settings-sheet";
import {
@ -123,18 +124,17 @@ export function ProgressSection({
const [stopDialogOpen, setStopDialogOpen] = useState(false);
const [stopRequestedLocal, setStopRequestedLocal] = useState(false);
// Auto-resets when training stops; no useEffect needed
const stopRequested = data.isTrainingRunning && stopRequestedLocal;
const pct =
data.totalSteps > 0
? Math.min(
100,
Math.max(
0,
Math.round((data.currentStep / data.totalSteps) * 100),
),
)
100,
Math.max(
0,
Math.round((data.currentStep / data.totalSteps) * 100),
),
)
: Math.round(data.progressPercent);
const elapsed = data.elapsedSeconds;
@ -214,16 +214,16 @@ export function ProgressSection({
},
...(data.trainingMethod !== "full"
? [
{
section: "LoRA",
rows: [
configRow(t("studio.progress.rank"), cfgLoraRank),
configRow(t("studio.progress.alpha"), cfgLoraAlpha),
configRow(t("studio.progress.dropout"), cfgLoraDropout),
configRow(t("studio.progress.variant"), cfgLoraVariant),
],
},
]
{
section: "LoRA",
rows: [
configRow(t("studio.progress.rank"), cfgLoraRank),
configRow(t("studio.progress.alpha"), cfgLoraAlpha),
configRow(t("studio.progress.dropout"), cfgLoraDropout),
configRow(t("studio.progress.variant"), cfgLoraVariant),
],
},
]
: []),
];
@ -350,8 +350,8 @@ export function ProgressSection({
{stepsPerSecond == null
? t("studio.progress.noStepsPerSecond")
: t("studio.progress.stepsPerSecond", {
value: stepsPerSecond.toFixed(2),
})}
value: stepsPerSecond.toFixed(2),
})}
</span>
{data.currentNumTokens != null && (
<span>{t("studio.progress.tokens", { value: data.currentNumTokens })}</span>
@ -373,14 +373,50 @@ function LiveGpuPanel({
isTrainingRunning: boolean;
}): ReactElement {
const t = useT();
const gpu = useGpuUtilization(isTrainingRunning);
const [selectedGpu, setSelectedGpu] = useState(0);
const gpuData = useGpuUtilization(isTrainingRunning);
const gpus: GpuUtilization[] =
Array.isArray(gpuData?.devices) && gpuData.devices.length > 0
? gpuData.devices
: gpuData && Object.keys(gpuData).length > 0
? [gpuData]
: [];
useEffect(() => {
if (selectedGpu > 0 && selectedGpu >= gpus.length) {
setSelectedGpu(0);
}
}, [gpus.length, selectedGpu]);
const gpuCount = gpus.length;
const currentGpu: Partial<GpuUtilization> = gpus[selectedGpu] || gpus[0] || {};
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">
{t("studio.progress.gpuMonitor")}
</p>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<p className="text-xs font-medium text-muted-foreground">
{t("studio.progress.gpuMonitor")}
</p>
{gpuCount > 1 && (
<select
value={selectedGpu}
onChange={(e) => setSelectedGpu(Number(e.target.value))}
className="h-6 cursor-pointer rounded-md border border-border bg-popover px-1.5 py-0.5 text-[11px] text-popover-foreground outline-none hover:bg-muted focus:border-primary transition-colors font-medium appearance-none"
title="Select GPU"
>
{gpus.map((device, index) => (
<option
key={device.index ?? index}
value={index}
className="bg-popover text-popover-foreground dark:bg-zinc-900 dark:text-zinc-100"
>
GPU {device.visible_ordinal ?? index} - {device.backend} ({device.vram_total_gb ? `${Math.round(device.vram_total_gb)}GB` : "N/A"})
</option>
))}
</select>
)}
</div>
<span className="text-[11px] text-muted-foreground">
{t("studio.progress.live")}
</span>
@ -388,51 +424,44 @@ function LiveGpuPanel({
<div className="grid grid-cols-2 gap-2.5">
<GpuStat
label={t("studio.progress.utilization")}
icon={
<HugeiconsIcon
icon={DashboardSpeed01Icon}
className="size-3.5"
/>
}
icon={<HugeiconsIcon icon={DashboardSpeed01Icon} className="size-3.5" />}
value={
gpu.gpu_utilization_pct != null
? `${gpu.gpu_utilization_pct}%`
currentGpu.gpu_utilization_pct != null
? `${currentGpu.gpu_utilization_pct}%`
: "--"
}
pct={gpu.gpu_utilization_pct ?? 0}
pct={currentGpu.gpu_utilization_pct ?? 0}
/>
<GpuStat
label={t("studio.progress.temperature")}
icon={
<HugeiconsIcon icon={TemperatureIcon} className="size-3.5" />
}
icon={<HugeiconsIcon icon={TemperatureIcon} className="size-3.5" />}
value={
gpu.temperature_c != null ? `${gpu.temperature_c}°C` : "--"
currentGpu.temperature_c != null ? `${currentGpu.temperature_c}°C` : "--"
}
pct={gpu.temperature_c ?? 0}
pct={currentGpu.temperature_c ?? 0}
max={100}
/>
<GpuStat
label={t("studio.progress.vram")}
icon={<HugeiconsIcon icon={RamMemoryIcon} className="size-3.5" />}
value={
gpu.vram_used_gb != null && gpu.vram_total_gb != null
? `${gpu.vram_used_gb} / ${gpu.vram_total_gb} GB`
currentGpu.vram_used_gb != null && currentGpu.vram_total_gb != null
? `${currentGpu.vram_used_gb} / ${currentGpu.vram_total_gb} GB`
: "--"
}
pct={gpu.vram_utilization_pct ?? 0}
pct={currentGpu.vram_utilization_pct ?? 0}
/>
<GpuStat
label={t("studio.progress.power")}
icon={<HugeiconsIcon icon={ZapIcon} className="size-3.5" />}
value={
gpu.power_draw_w != null
? gpu.power_limit_w != null
? `${gpu.power_draw_w} / ${gpu.power_limit_w} W`
: `${gpu.power_draw_w} W`
currentGpu.power_draw_w != null
? currentGpu.power_limit_w != null
? `${currentGpu.power_draw_w} / ${currentGpu.power_limit_w} W`
: `${currentGpu.power_draw_w} W`
: "--"
}
pct={gpu.power_utilization_pct ?? 0}
pct={currentGpu.power_utilization_pct ?? 0}
/>
</div>
</div>
@ -560,7 +589,10 @@ function TrainingHeaderActions({
<HugeiconsIcon icon={StopIcon} className="size-3" />
{stopRequested ? t("studio.training.stopping") : t("studio.training.stopAction")}
</Button>
<AlertDialogContent overlayClassName="bg-background/40 supports-backdrop-filter:backdrop-blur-[1px]">
<AlertDialogContent
className="w-max max-w-[95vw]"
overlayClassName="bg-background/40 supports-backdrop-filter:backdrop-blur-[1px]"
>
<AlertDialogHeader>
<AlertDialogTitle>{t("studio.training.stopTitle")}</AlertDialogTitle>
<AlertDialogDescription>

View file

@ -1,6 +1,7 @@
// 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 { useDebouncedValue } from "./use-debounced-value";
export { useGpuInfo } from "./use-gpu-info";
export { useGpuUtilization } from "./use-gpu-utilization";
@ -9,3 +10,4 @@ export { useHfDatasetSplits } from "./use-hf-dataset-splits";
export { useHfTokenValidation } from "./use-hf-token-validation";
export { useTauriBackend } from "./use-tauri-backend";
export { useCollapseScrollLock } from "./use-collapse-scroll-lock";
export { useSystemInfo } from "./use-system";

View file

@ -3,19 +3,26 @@
import { authFetch } from "@/features/auth";
import { useEffect, useState } from "react";
import type { SystemInfoResponse } from "./use-system";
export interface GpuInfo {
available: boolean;
name: string;
memoryTotalGb: number;
cpuCore: number;
cpuThread: number;
systemRamAvailableGb: number;
systemRamTotalGb: number
}
const DEFAULT_GPU: GpuInfo = {
available: false,
name: "Unknown",
memoryTotalGb: 0,
cpuCore: 0,
cpuThread: 0,
systemRamAvailableGb: 0,
systemRamTotalGb: 0
};
// Module-level cache so multiple components share one fetch.
@ -30,24 +37,30 @@ async function fetchGpuOnce(): Promise<GpuInfo> {
try {
const res = await authFetch("/api/system");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const ramAvailableGb = data?.memory?.available_gb ?? 0;
const data = await res.json() as SystemInfoResponse;
const gpuData = data?.gpu;
if (!gpuData?.available || !gpuData.devices?.length) {
// No discrete GPU (e.g. Mac): still surface system RAM so memory math
// (unified memory) has a budget to work with.
const info: GpuInfo = { ...DEFAULT_GPU, systemRamAvailableGb: ramAvailableGb };
cachedGpu = info;
return info;
}
const devices = gpuData.devices as Array<{ name?: string; memory_total_gb?: number }>;
const totalGb = devices.reduce((sum, d) => sum + (d.memory_total_gb ?? 0), 0);
const info: GpuInfo = {
available: true,
name: devices[0]?.name ?? "Unknown",
memoryTotalGb: totalGb,
systemRamAvailableGb: ramAvailableGb,
// CPU/RAM exist even on hosts without a GPU, so populate them on every path.
// No discrete GPU (e.g. Mac): still surface system RAM so memory math
// (unified memory) has a budget to work with.
const base = {
cpuCore: data?.cpu?.physical_count ?? 0,
cpuThread: data?.cpu?.logical_count ?? 0,
systemRamAvailableGb: data?.memory?.available_gb ?? 0,
systemRamTotalGb: data?.memory?.total_gb ?? 0,
};
const devices = gpuData?.devices ?? [];
const info: GpuInfo =
gpuData?.available && devices.length
? {
...base,
available: true,
name: devices[0]?.name ?? "Unknown",
memoryTotalGb: devices.reduce((sum, d) => sum + (d.memory_total_gb ?? 0), 0),
}
: { ...DEFAULT_GPU, ...base };
cachedGpu = info;
return info;
} catch {
@ -78,4 +91,4 @@ export function useGpuInfo(): GpuInfo {
}, []);
return gpu;
}
}

View file

@ -7,6 +7,9 @@ import { useEffect, useRef, useState } from "react";
export interface GpuUtilization {
available: boolean;
backend: string | null;
devices?: GpuUtilization[];
index?: number;
visible_ordinal?: number;
gpu_utilization_pct: number | null;
temperature_c: number | null;
vram_used_gb: number | null;
@ -57,11 +60,10 @@ export function useGpuUtilization(
const json = (await res.json()) as GpuUtilization;
if (!cancelled) setData(json);
} catch {
// Silently ignore — next poll will retry
// Retry on the next poll.
}
}
// Fetch immediately, then set up interval
void poll();
timerRef.current = setInterval(() => void poll(), intervalMs);

View 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 { authFetch } from "@/features/auth";
import { useEffect, useState } from "react";
export interface GpuDevice {
index?: number;
index_kind?: string;
visible_ordinal?: number;
name?: string;
memory_total_gb?: number;
vram_used_gb?: number;
vram_free_gb?: number;
vram_utilization_pct?: number | null;
}
export interface SystemInfoResponse {
platform: string;
python_version: string;
device_backend: "cuda" | "rocm" | "cpu" | "mlx" | "xpu";
uptime_seconds: number | null;
cpu: {
logical_count: number;
physical_count: number;
usage_percent: number;
frequency_mhz: number | null;
};
memory: {
total_gb: number;
available_gb: number;
percent_used: number;
process_used_mb: number;
};
disk: {
total_gb: number;
free_gb: number;
percent_used: number;
};
gpu: {
available: boolean;
backend?: string;
backend_cuda_visible_devices?: string | null;
parent_visible_gpu_ids?: number[];
index_kind?: string;
devices: GpuDevice[];
};
ml_packages: {
torch?: string;
transformers?: string;
};
}
let cachedSystem: SystemInfoResponse | null = null;
let systemFetchPromise: Promise<SystemInfoResponse> | null = null;
const DEFAULT_SYSTEM: SystemInfoResponse = {
platform: "Unknown",
python_version: "Unknown",
device_backend: "cpu",
uptime_seconds: 0,
cpu: { logical_count: 0, physical_count: 0, usage_percent: 0, frequency_mhz: null },
memory: { total_gb: 0, available_gb: 0, percent_used: 0, process_used_mb: 0 },
disk: { total_gb: 0, free_gb: 0, percent_used: 0 },
gpu: { available: false, devices: [] },
ml_packages: {}
};
async function fetchSystemOnce({
force = false,
}: { force?: boolean } = {}): Promise<SystemInfoResponse> {
if (systemFetchPromise) return systemFetchPromise;
if (!force && cachedSystem) return cachedSystem;
systemFetchPromise = (async () => {
try {
const res = await authFetch("/api/system");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
cachedSystem = data as SystemInfoResponse;
return cachedSystem;
} catch {
cachedSystem = null;
return DEFAULT_SYSTEM;
} finally {
systemFetchPromise = null;
}
})();
return systemFetchPromise;
}
interface UseSystemInfoOptions {
pollMs?: number;
enabled?: boolean;
}
export function useSystemInfo({
pollMs,
enabled = true,
}: UseSystemInfoOptions = {}): SystemInfoResponse {
const [systemInfo, setSystemInfo] = useState<SystemInfoResponse>(cachedSystem ?? DEFAULT_SYSTEM);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
let timeoutId: number | null = null;
const update = (force: boolean) => {
void fetchSystemOnce({ force })
.then((info) => {
if (!cancelled) setSystemInfo(info);
})
.finally(() => {
if (cancelled || !pollMs) return;
timeoutId = window.setTimeout(() => update(true), pollMs);
});
};
update(Boolean(pollMs));
return () => {
cancelled = true;
if (timeoutId !== null) window.clearTimeout(timeoutId);
};
}, [enabled, pollMs]);
return systemInfo;
}

View file

@ -2,10 +2,11 @@
- `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`.
- Use BCP 47 locale tags for new languages, for example `zh-CN`, `pt-BR`, `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.
- Run `npx tsx src/i18n/check-parity.ts` before committing to ensure there are no shape mismatches or placeholder discrepancies in the non-English overlays.

View file

@ -3,13 +3,14 @@
// 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).
// - All non-English 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";
import { ptBR } from "./locales/pt-br.ts";
import { ja } from "./locales/ja.ts";
type Tree = { readonly [k: string]: string | Tree };
@ -90,6 +91,7 @@ function checkExtras(
const overlays: Record<string, Tree> = {
"zh-CN": zhCN as unknown as Tree,
"pt-BR": ptBR as unknown as Tree,
"ja": ja as unknown as Tree,
};
let anyError = false;
@ -112,4 +114,4 @@ for (const [locale, overlay] of Object.entries(overlays)) {
}
if (anyError) process.exit(1);
console.log("\nAll locale overlays pass parity.");
console.log("\nAll locale overlays pass parity.");

View file

@ -92,6 +92,7 @@ export const en = {
general: "General",
profile: "Profile",
appearance: "Appearance",
resources: "System",
chat: "Chat",
connections: "Connections",
apiKeys: "API",
@ -275,6 +276,58 @@ export const en = {
"Keep the sidebar expanded instead of collapsing to icons.",
},
},
resources: {
title: "System",
description: "Monitor this Studio server's hardware and storage.",
liveUpdates: "Live updates",
floatingWindow: "Floating window",
disableOverlay: "Disable overlay",
liveMonitor: {
title: "Live monitor",
cpu: "CPU",
ram: "RAM",
disk: "Disk",
vram: "VRAM",
cpuCores: "{logical} logical / {physical} physical cores",
currentLoad: "Current load",
free: "{value} free",
noGpu: "No visible GPU",
},
gpu: {
title: "GPU devices",
noGpu: "No visible GPU detected. CPU-only resources are shown above.",
unknownDevice: "Unknown GPU",
deviceWithIndex: "GPU {index}",
vramUtilization: "VRAM",
used: "{value} used",
free: "{value} free",
total: "{value} total",
},
storage: {
title: "Storage",
systemDisk: "System disk",
diskUsage: "{used} used / {total}",
diskFree: "{free} free",
modelsFolder: "Models folder",
modelsFolderDescription: "Where downloaded models are stored.",
openAction: "Open",
copyAction: "Copy path",
copied: "Path copied",
openError: "Couldn't open the folder",
copyError: "Couldn't copy the path",
},
environment: {
title: "Environment",
backend: "Backend",
python: "Python",
torch: "Torch",
transformers: "Transformers",
uptime: "Uptime",
processMemory: "Process memory",
notInstalled: "Not installed",
unknown: "Unknown",
},
},
chat: {
title: "Chat",
description: "Manage chat history stored on this device.",
@ -373,8 +426,10 @@ export const en = {
usageTools: "Tools",
exampleCurlTools: "curl + tools",
examplePythonTools: "Python + tools",
exampleJavaScriptTools: "JavaScript + tools",
exampleCurlAdvanced: "curl + advanced",
examplePythonAdvanced: "Python + advanced",
exampleJavaScriptAdvanced: "JavaScript + advanced",
osUnix: "Linux / macOS / WSL",
osWindows: "Windows",
secureHttps: "Secure HTTPS",

View file

@ -0,0 +1,934 @@
// 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 ptBR = {
common: {
cancel: "Cancelar",
close: "Fechar",
delete: "Excluir",
done: "Concluído",
error: "Erro",
export: "Exportar",
help: "Ajuda",
loading: "Carregando...",
new: "Novo",
rename: "Renomear",
save: "Salvar",
saving: "Salvando...",
search: "Buscar",
shutdown: "Desligar",
},
shell: {
beta: "BETA",
brand: "unsloth",
product: "Unsloth Studio",
accountMenu: "Menu de conta {name}",
updateAvailable: "Atualização disponível",
aria: {
home: "Início do Unsloth",
closeSidebar: "Fechar barra lateral",
openSidebar: "Abrir barra lateral",
chatOptions: "Opções de chat",
runOptions: "Opções de execução",
},
navigation: {
newChat: "Novo Chat",
returnToChat: "Retornar ao Chat",
compare: "Comparar",
search: "Buscar",
hub: "Hub",
train: "Treinar",
recipes: "Receitas",
export: "Exportar",
recents: "Recentes",
settings: "Configurações",
api: "API",
lightMode: "Modo Claro",
darkMode: "Modo Escuro",
guidedTour: "Tour Guiado",
help: "Ajuda",
logOut: "Sair",
shutdown: "Desligar",
},
notFound: {
title: "Página não encontrada",
description: "{path} não existe.",
backToChat: "Voltar para o chat",
},
dialog: {
deleteChat: {
title: "Excluir chat",
description: 'Tem certeza de que deseja excluir este chat "{name}"?',
},
deleteRun: {
title: "Excluir execução de treino",
description: 'Tem certeza de que deseja excluir esta execução "{name}"?',
},
renameChat: {
title: "Renomear chat",
placeholder: "Título do chat",
},
renameRun: {
title: "Renomear execução",
placeholder: "Nome da execução",
},
},
toast: {
cannotDeleteRunningRun: "Não é possível excluir uma execução de treino em andamento",
failedToDeleteChat: "Falha ao excluir o chat",
failedToDeleteRun: "Falha ao excluir a execução",
failedToRenameChat: "Falha ao renomear o chat",
failedToRenameRun: "Falha ao renomear a execução",
},
},
settings: {
title: "Configurações",
dialog: {
title: "Configurações",
description: "Gerencie suas preferências do Unsloth.",
closeAriaLabel: "Fechar configurações",
},
tabs: {
general: "Geral",
profile: "Perfil",
appearance: "Aparência",
resources: "Sistema",
chat: "Chat",
connections: "Conexões",
apiKeys: "API",
about: "Sobre",
},
general: {
title: "Geral",
description: "Preferências globais do Unsloth.",
account: "Conta",
huggingFaceToken: "Token do Hugging Face",
huggingFaceTokenDescription:
"Usado para carregar modelos restritos e enviar artefatos.",
tokenSaved: "Token salvo",
hideToken: "Ocultar token",
showToken: "Mostrar token",
password: "Senha",
passwordDescription: "Altere a senha desta conta do Studio.",
passwordDialog: {
trigger: "Alterar senha",
title: "Alterar senha",
description:
"Insira sua senha atual e escolha uma nova (no mínimo {minLength} caracteres).",
currentPassword: "Senha atual",
newPassword: "Nova senha",
confirmPassword: "Confirmar nova senha",
currentTooShort:
"A senha atual deve ter no mínimo {minLength} caracteres.",
newTooShort: "A nova senha deve ter no mínimo {minLength} caracteres.",
mismatch: "As senhas não coincidem.",
samePassword:
"A nova senha deve ser diferente da senha atual.",
update: "Atualizar senha",
updating: "Atualizando...",
updated: "Senha atualizada.",
updateFailed: "Falha ao atualizar a senha.",
},
chatDefaults: "Padrões do chat",
autoTitleNewChats: "Gerar título automático para novos chats",
autoTitleNewChatsDescription:
"Gera um título curto a partir da primeira mensagem.",
helperLlm: {
sectionTitle: "LLM Auxiliar",
preloadOnStartup: "Pré-carregar LLM Auxiliar na inicialização",
preloadOnStartupDescription:
"Baixa o modelo auxiliar do Assistente de IA em segundo plano ao iniciar. Desativado por padrão; o Assistente de IA ainda pode buscá-lo sob demanda.",
disabledByEnv:
"Desativado por UNSLOTH_HELPER_MODEL_DISABLE no ambiente de backend.",
loadError: "Falha ao carregar as configurações do LLM Auxiliar.",
saveError: "Falha ao salvar as configurações do LLM Auxiliar.",
},
notifications: {
sectionTitle: "Notificações",
showLlamaUpdates: "Notificações de atualização do llama.cpp",
showLlamaUpdatesDescription:
"Notifica quando uma nova versão do llama.cpp estiver disponível. Desative se você apenas realiza treinos.",
},
gettingStarted: "Primeiros passos",
startOnboarding: "Iniciar integração",
startOnboardingDescription:
"Reabre o assistente de configuração sem alterar sua conta.",
startOnboardingAction: "Iniciar integração",
uploads: {
sectionTitle: "Uploads",
maxUploadSize: "Limite de upload do dataset de treino",
maxUploadSizeDescription:
"O padrão é {defaultSize} MB.",
},
storage: {
sectionTitle: "Armazenamento",
modelsFolder: "Pasta de modelos",
modelsFolderDescription:
"Onde os modelos baixados são armazenados.",
openAction: "Abrir",
copyAction: "Copiar caminho",
copied: "Caminho copiado",
openError: "Não foi possível abrir a pasta",
copyError: "Não foi possível copiar o caminho",
},
resetPreferences: {
sectionTitle: "Zona de perigo",
label: "Redefinir todas as preferências locais",
description:
"Limpa apenas as preferências locais. Chats, acesso à API e configurações salvas no banco de dados são mantidos.",
action: "Redefinir preferências",
confirmTitle: "Redefinir todas as preferências locais?",
confirmDescription:
"Limpa as preferências locais e recarrega o Unsloth. Chats, acesso à API e configurações salvas no banco de dados são mantidos.",
confirmAction: "Redefinir e recarregar",
},
},
profile: {
title: "Perfil",
description: "Como seu perfil aparece no Unsloth.",
changePicture: "Alterar foto de perfil",
displayName: "Nome de exibição",
nickname: "Como o Unsloth deve chamar você?",
nicknamePlaceholder: "Apelido",
nicknameSaved: "Nome preferido salvo",
avatarShape: "Formato da foto de perfil",
avatarShapeCircle: "Círculo",
avatarShapeRounded: "Arredondado",
chooseSloth: "Ou escolha uma preguiça",
nameSaved: "Nome de perfil salvo",
namePersistErrorTitle: "Não foi possível persistir o nome de perfil",
namePersistErrorDescription:
"Nome atualizado para esta sessão, mas pode não persistir após recarregar.",
photoUpdated: "Foto de perfil atualizada",
photoPersistErrorTitle: "Não foi possível persistir a foto de perfil",
photoPersistErrorDescription:
"Foto atualizada para esta sessão, mas pode não persistir após recarregar.",
photoUpdateErrorTitle: "Não foi possível atualizar a foto de perfil",
imageUseError: "Não foi possível usar esta imagem.",
},
appearance: {
title: "Aparência",
description: "Como o Unsloth Studio se parece neste dispositivo.",
theme: {
title: "Tema",
label: "Esquema de cores",
description: "Claro, escuro ou seguir o sistema.",
system: "Sistema",
light: "Claro",
dark: "Escuro",
},
language: {
title: "Idioma",
label: "Idioma de exibição",
description: "O idioma utilizado pelo Unsloth.",
},
layout: {
title: "Layout",
compactSidebar: "Fixar barra lateral por padrão",
compactSidebarDescription:
"Mantém a barra lateral expandida em vez de recolhê-la em ícones.",
},
},
resources: {
title: "Sistema",
description: "Monitore o hardware e o armazenamento deste servidor Studio.",
liveUpdates: "Atualizações ao vivo",
floatingWindow: "Janela flutuante",
disableOverlay: "Desativar sobreposição",
liveMonitor: {
title: "Monitor ao vivo",
cpu: "CPU",
ram: "RAM",
disk: "Disco",
vram: "VRAM",
cpuCores: "{logical} lógicos / {physical} físicos",
currentLoad: "Carga atual",
free: "{value} livres",
noGpu: "Nenhuma GPU visível",
},
gpu: {
title: "Dispositivos GPU",
noGpu: "Nenhuma GPU visível detectada. Os recursos somente CPU aparecem acima.",
unknownDevice: "GPU desconhecida",
deviceWithIndex: "GPU {index}",
vramUtilization: "VRAM",
used: "{value} usados",
free: "{value} livres",
total: "{value} total",
},
storage: {
title: "Armazenamento",
systemDisk: "Disco do sistema",
diskUsage: "{used} usados / {total}",
diskFree: "{free} livres",
modelsFolder: "Pasta de modelos",
modelsFolderDescription: "Onde os modelos baixados são armazenados.",
openAction: "Abrir",
copyAction: "Copiar caminho",
copied: "Caminho copiado",
openError: "Não foi possível abrir a pasta",
copyError: "Não foi possível copiar o caminho",
},
environment: {
title: "Ambiente",
backend: "Backend",
python: "Python",
torch: "Torch",
transformers: "Transformers",
uptime: "Tempo ativo",
processMemory: "Memória do processo",
notInstalled: "Não instalado",
unknown: "Desconhecido",
},
},
chat: {
title: "Chat",
description: "Gerencie o histórico de chat armazenado neste dispositivo.",
modelDisclaimer: "Mostrar aviso do modelo",
modelDisclaimerDescription:
'Mostra "LLMs podem cometer erros" abaixo da caixa de chat.',
artifacts: {
title: "Canvas",
collapseHtmlBlocks: "Recolher blocos HTML",
collapseHtmlBlocksDescription:
"O modo Canvas recolhe o HTML completo automaticamente. Ative isso para também recolher documentos HTML delimitados quando o Canvas estiver desativado.",
allowNetworkAccess: "Permitir acesso à rede no canvas",
allowNetworkAccessDescription:
"Permite que as pré-visualizações do canvas carreguem scripts, estilos, fontes, mídia e recursos de rede de CDNs. Mantenha desativado para pré-visualizações totalmente offline.",
},
data: "Dados",
exportHistory: "Exportar histórico de chat",
exportHistoryDescription:
"Baixe todos os chats e mensagens em formato JSON.",
exportAction: "Exportar",
exportingAction: "Exportando...",
exportConversations: "Exportar Recentes e Projetos",
exportConversationsDescription:
"Baixe os Recentes ou Recentes mais chats de projetos como JSONL bruto, CSV ou ShareGPT JSONL, combinados ou por chat.",
exportConversationsAction: "Exportar",
exportScopeRecents: "Recentes",
exportScopeAll: "Recentes + Projetos",
exportCombinedSuffix: "(combinado)",
exportPerChatSuffix: "(por chat)",
importChats: "Importar chats",
importChatsDescription:
"Importe um arquivo exportado em JSONL, NDJSON ou CSV para os Recentes.",
importChatsAction: "Importar",
importNoConversations: "Nenhuma conversa encontrada no arquivo.",
importedOneChat: "Importada 1 conversa para os Recentes.",
importedChatCount: "Importadas {count} conversas para os Recentes.",
importFailed: "Falha na importação.",
clearHistory: "Limpar histórico de chat",
clearHistoryDescription: "Exclui o histórico de chat deste dispositivo.",
clearAction: "Limpar",
clearAllChats: "Limpar todos os chats",
clearAllChatsDescription: "Exclui permanentemente todos os chats deste dispositivo.",
noChatsToClear: "Nenhum chat para limpar.",
clearOneChatDescription:
"Exclui permanentemente o único chat deste dispositivo.",
clearChatCountDescription:
"Exclui permanentemente todos os {count} chats deste dispositivo.",
clearChatsAction: "Limpar chats",
clearOneChatTitle: "Limpar 1 chat?",
clearChatsTitle: "Limpar {count} chats?",
clearChatsConfirmDescription:
"Exclui permanentemente todos os chats deste dispositivo. Esta ação não pode ser desfeita.",
clearingAction: "Limpando...",
clearOneChatAction: "Limpar 1 chat",
clearChatCountAction: "Limpar {count} chats",
clearedAllChats: "Todos os chats foram limpos",
clearedOneChat: "1 chat foi limpo",
clearedChatCount: "{count} chats foram limpos",
someChatsCouldNotBeCleared: "Não foi possível limpar alguns chats",
chatsClearedRemainOne:
"{clearedCount} chats limpos; 1 chat restante. Por favor, tente novamente.",
chatsClearedRemain:
"{clearedCount} chats limpos; {remainingCount} chats restantes. Por favor, tente novamente.",
oneChatClearedRemain:
"1 chat limpo; {remainingCount} chats restantes. Por favor, tente novamente.",
oneChatClearedRemainOne: "1 chat limpo; 1 chat restante. Por favor, tente novamente.",
storageClearFailedOne:
"Falha ao limpar o armazenamento; 1 chat pode ter restado. Por favor, tente novamente.",
storageClearFailed:
"Falha ao limpar o armazenamento; {count} chats podem ter restado. Por favor, tente novamente.",
failedToClearChats: "Falha ao limpar os chats",
},
connections: {
title: "Conexões",
description: "Gerencie provedores e conexões externas.",
},
apiKeys: {
title: "API",
description:
"Acesse o Unsloth por meio da API compatível com OpenAI.",
readDocs: "Leia a documentação da API",
noAccess: "Nenhum acesso à API ainda.",
newBadge: "Novo",
accessTokens: "Tokens de acesso",
loadError: "Não foi possível carregar o acesso à API.",
createError: "Não foi possível criar o token de acesso.",
revokeError: "Não foi possível revogar o token de acesso.",
never: "Nunca",
tokenNamePlaceholder: "Nome do token (ex: producao)",
newAccessTokenName: "Nome do novo token de acesso",
createToken: "Criar token",
creating: "Criando...",
newTokenCreated: "Novo token de acesso criado",
accessTokenCopied: "Token de acesso copiado",
copyAccessToken: "Copiar token de acesso",
copyNow: "Copie agora - isto não será exibido novamente.",
usageExamples: "Exemplos de uso",
usageTools: "Ferramentas",
exampleCurlTools: "curl + ferramentas",
examplePythonTools: "Python + ferramentas",
exampleJavaScriptTools: "JavaScript + ferramentas",
exampleCurlAdvanced: "curl + avançado",
examplePythonAdvanced: "Python + avançado",
exampleJavaScriptAdvanced: "JavaScript + avançado",
osUnix: "Linux / macOS / WSL",
osWindows: "Windows",
secureHttps: "HTTPS Seguro",
secureHttpsHint:
"A porta 0.0.0.0 ainda está acessível globalmente. Para segurança total, inicie o Unsloth Studio com --secure para expor apenas este link HTTPS.",
copyTunnelUrl: "Copiar URL do túnel",
copySnippet: "Copiar trecho de código",
copy: "Copiar",
copied: "Copiado",
setupDocs: "Docs de configuração:",
relativeNever: "nunca",
relativeJustNow: "agora mesmo",
relativeHoursAgo: "há {count}h",
relativeDaysAgo: "há {count}d",
relativeMonthsAgo: "há {count} meses",
relativeYearsAgo: "há {count} anos",
expired: "expirado",
today: "hoje",
inDays: "em {count}d",
created: "Criado {value}",
used: "Usado {value}",
expires: "Expira {value}",
actionsFor: "Ações para {name}",
copyPrefix: "Copiar prefixo",
revokeToken: "Revogar token",
revokeTitle: 'Revogar token de acesso "{name}"?',
revokeDescription:
"Aplicativos que usam este token perderão o acesso imediatamente. Esta ação não pode ser desfeita.",
revokeAction: 'Revogar "{name}"',
revoking: "Revogando...",
},
about: {
title: "Sobre",
description:
"Documentação, notas de lançamento, feedback e informações da build.",
studioVersion: "Versão do Unsloth",
packageVersion: "Versão do Pacote",
llamaCppVersion: "Versão do llama.cpp",
hardware: "Hardware",
gpu: "GPU",
cuda: "CUDA",
rocm: "ROCm",
updates: "Atualização",
help: "Ajuda",
documentation: "Documentação",
releaseNotes: "Notas de lançamento",
whatsNew: "O que há de novo",
feedback: "Feedback",
reportIssue: "Reportar um problema",
license: {
sectionTitle: "Licença",
studioLabel: "Unsloth Studio",
studioLicense: "AGPL-3.0",
studioDescription:
"Código aberto sob a licença GNU AGPL v3.0.",
libraryLabel: "Unsloth Core",
libraryLicense: "Apache-2.0",
libraryDescription: "Licenciado sob Apache 2.0.",
},
dangerZone: "Zona de perigo",
shutDownStudio: "Desligar Unsloth Studio",
shutDownStudioDescription:
"Interrompe o servidor Unsloth e encerra sua sessão.",
shutDown: "Desligar",
update: {
title: "Atualizar Unsloth Studio",
commandText: "Texto de {label}",
copied: "Copiado",
copyCommand: "Copiar comando",
commandCopied: "{label} copiado",
copyNamedCommand: "Copiar {label}",
checkingInstall: "Verificando como o Unsloth foi instalado...",
installIntro: "Para instalar ou atualizar o Unsloth:",
localUpdateHeading: "Atualização local",
installCommandUnix: "Comando de instalação para macOS/Linux",
installCommandWindows: "Comando de instalação para Windows",
localInstallDetected:
"Instalação local detectada. Atualize a partir do seu repositório original para evitar substituí-lo pelo PyPI.",
pullThenUpdate: "Puxe as últimas alterações (git pull) e depois execute o instalador local:",
gitPullCommand: "comando git pull",
localInstallerCommand: "comando do instalador local",
sourceInstallDetected:
"Instalação do pacote por código-fonte ou VCS detectada. Reinstale a partir do caminho local original ou URL do Git.",
repoCheckoutFallback:
"Se você ainda tiver o repositório baixado, execute o instalador local a partir dele:",
restartAfterUpdate: "Reinicie o Unsloth após a atualização.",
desktopManaged:
"O aplicativo de desktop mantém seu backend integrado atualizado e avisará quando uma nova versão estiver disponível.",
unknownInstall:
"Não foi possível detectar como o Unsloth foi instalado. Para instalações via instalador ou PyPI, use os comandos acima.",
localCheckout:
"Para instalações de repositório local, execute o instalador local a partir desse diretório:",
docs: "Docs de instalação:",
docsInstall: "Instalação",
docsUpdating: "Atualização",
docsMac: "Mac",
docsWindows: "Windows",
},
},
},
studio: {
routeTitle: "Treinar",
title: "Estúdio de Fine-tuning",
subtitles: {
configure: "Configure e inicie o treinamento",
trainingInProgress: "Treinamento em andamento",
viewPastRuns: "Visualizar execuções de treino anteriores",
viewingPastRun: "Visualizando execução anterior",
},
tabs: {
configure: "Configurar",
currentRun: "Execução Atual",
history: "Histórico",
},
loadingRuntime: "Carregando ambiente de execução de treino...",
backToHistory: "Voltar ao histórico",
sections: {
model: "Modelo",
dataset: "Dataset",
params: "Parâmetros",
training: "Treinamento",
charts: "Gráficos",
progress: "Progresso do Treinamento",
},
configure: {
title: "Configurar",
description: "Escolha um modelo, dataset e configurações de treinamento.",
startTraining: "Iniciar Treinamento",
starting: "Iniciando...",
loadingModel: "Carregando modelo...",
checkingDataset: "Verificando dataset...",
trainingConfig: "Configuração de Treino",
},
model: {
title: "Modelo",
description: "Selecione o modelo base e o método de treinamento",
fasterTrainingBadge: "Treinamento 2x Mais Rápido",
baseModel: "Modelo base",
localModel: "Modelo Local",
localModelTooltip:
"Caminho para um modelo baixado localmente ou um repositório HF customizado.",
scanningLocalAndCachedModels: "Escaneando modelos locais e em cache...",
scanning: "Escaneando...",
scanningLocalModels: "Escaneando modelos locais...",
noLocalModelsFound: "Nenhum modelo local encontrado",
noLocalModelsFoundManual: "Nenhum modelo local encontrado. Insira o caminho manualmente.",
failedToLoadLocalModels: "Falha ao carregar modelos locais",
hfCache: "Cache do HF",
customFolders: "Pastas Customizadas",
localDir: "Diretório local",
huggingFaceModel: "Modelo do Hugging Face",
huggingFaceModelTooltip:
"Busque modelos no Hugging Face ou escolha da nossa lista recomendada.",
searchModels: "Buscar modelos...",
searching: "Buscando...",
noModelsFound: "Nenhum modelo encontrado",
needsVram: "Precisa de ~{vram}GB de VRAM (GPU: {gpu}GB)",
tightVram: "~{vram}GB de VRAM (limite na {gpu}GB)",
vramEstimate: "~{vram}GB de VRAM",
method: "Método",
methodTooltip:
"O QLoRA usa quantização de 4 bits para menor uso de VRAM. O LoRA usa 16 bits. O Full atualiza todos os pesos. O CPT (Continued Pretraining) treina em texto bruto para adaptar o modelo a um novo domínio sem formatação de chat.",
readMore: "Leia mais",
fullFineTune: "Fine-tune Completo (Full)",
checkingToken: "Verificando token...",
getOrUpdateToken: "Obter ou atualizar token",
huggingFaceTokenOptional: "Token do Hugging Face (Opcional)",
continuedPretraining: "Pré-treinamento Contínuo (CPT)",
localModels: "Modelos locais",
localModelsFound: "{count} modelos locais/em cache encontrados",
loadingLocalModels: "Carregando modelos locais...",
},
dataset: {
title: "Dataset",
description: "Selecione ou envie os dados de treinamento",
source: "Origem do dataset",
chooseDataset: "Escolher dataset",
chooseDatasetTooltip:
"Use as abas do pop-up para alternar entre o Hugging Face e as saídas de receitas locais.",
localTab: "Local",
searchHuggingFaceDatasets: "Buscar datasets no Hugging Face...",
searchLocalDatasets: "Buscar datasets locais...",
searching: "Buscando...",
noDatasetsFound: "Nenhum dataset encontrado",
loadingLocalDatasets: "Carregando datasets locais...",
failedToLoadLocalDatasets: "Falha ao carregar datasets locais.",
noLocalDatasetsYet: "Nenhum dataset local ainda.",
noLocalDatasetsMatchSearch: "Nenhum dataset local corresponde à busca.",
openDataRecipes: "Abrir Receitas de Dados",
browsingSource: "Navegando em {browsing}. A seleção atual permanece {current}.",
localDatasets: "Datasets locais",
localDataset: "Dataset local",
localDatasetRows: " / {count} linhas",
huggingFaceDataset: "Dataset do Hugging Face",
localDatasetMetadata: "Metadados do dataset local",
dataRecipeOutput: "Saída da Receita de Dados.",
rows: "Linhas",
columns: "Colunas",
batches: "Lotes",
updated: "Atualizado",
evalDataset: "Dataset de validação (Eval)",
uploading: "Enviando...",
upload: "Upload",
uploadEvalFile: "Enviar arquivo de validação",
evalDatasetDescription:
"Opcional. Se não for fornecido, uma pequena parte será dividida a partir dos dados de treinamento.",
advanced: "Avançado",
targetFormat: "Formato de Destino",
targetFormatTooltip:
"Formato dos seus dados de treinamento. A detecção automática funciona para a maioria dos datasets.",
auto: "Auto",
rawText: "Texto Bruto",
trainSplitStart: "Início da Divisão de Treino",
trainSplitStartTooltip:
"Treine apenas em um subconjunto da sua divisão de treino especificando um índice de linha inicial (inclusivo, baseado em 0). Deixe em branco para começar da primeira linha.",
trainSplitEnd: "Fim da Divisão de Treino",
trainSplitEndTooltip:
"Último índice de linha a ser incluído da divisão de treino (inclusivo, baseado em 0). Por exemplo, defina o Início como 0 e o Fim como 99 para treinar nas primeiras 100 linhas. Deixe em branco para usar todas as linhas restantes.",
endPlaceholder: "Fim",
clear: "Limpar",
dropFileOrClick: "Solte 1 arquivo aqui ou clique para fazer upload",
viewDataset: "Visualizar dataset",
uploadFailed: "Falha no envio",
unknownError: "Erro desconhecido",
unsupportedFileType: "Tipo de arquivo não suportado",
uploadOneFileType: "Envie um arquivo do tipo {types}.",
datasetUploaded: "Dataset enviado",
evalDatasetUploaded: "Dataset de validação enviado",
uploadOneFileAtATime: "Envie um arquivo por vez",
uploadSingleFileDescription:
"O upload do dataset de treinamento aceita apenas um único arquivo.",
checkingToken: "Verificando token...",
getOrUpdateToken: "Obter ou atualizar token",
preview: "Pré-visualizar dataset",
split: "Divisão (Split)",
subset: "Subconjunto (Subset)",
s3: {
title: "Configuração do S3",
description: "Carregue datasets em .parquet, .json, .jsonl ou .csv do Amazon S3",
bucket: "Nome do Bucket",
bucketPlaceholder: "meu-bucket-de-dados-de-treino",
region: "Região da AWS",
regionPlaceholder: "us-east-1",
prefix: "Prefixo do Caminho",
prefixPlaceholder: "datasets/whisper/",
prefixTooltip: "Caminho opcional dentro do bucket para os arquivos do seu dataset",
accessKeyId: "ID da Chave de Acesso",
accessKeyIdPlaceholder: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "Chave de Acesso Secreta",
secretAccessKeyPlaceholder: "Sua chave de acesso secreta da AWS",
useIamRole: "Usar Função IAM",
useIamRoleTooltip: "Usa credenciais de função IAM em vez de chaves de acesso (recomendado para EC2/SageMaker)",
testConnection: "Testar Conexão",
connectionSuccess: "Conectado com sucesso ao bucket S3",
connectionFailed: "Falha ao conectar ao bucket S3",
comingSoon: "Integração com S3 em breve",
comingSoonDescription: "O carregamento de datasets do S3 requer o boto3. Este recurso está em desenvolvimento.",
},
},
params: {
title: "Parâmetros",
description: "Configure os hiperparâmetros de treinamento",
loraSettings: "Configurações do LoRA",
trainingHyperparameters: "Hiperparâmetros de Treinamento",
maxSteps: "Passos Máximos (Max Steps)",
epochs: "Épocas (Epochs)",
useMaxSteps: "Usar Passos Máximos",
useEpochs: "Usar Épocas",
maxStepsTooltip: "Sobrescreve o total de passos do otimizador.",
epochsTooltip: "Número de passagens completas pelo dataset.",
epochsDescription: "Cada época é uma passagem completa pelo seu dataset.",
maxStepsDescription:
"Limita o treinamento a um número fixo de passos do otimizador.",
contextLength: "Comprimento do Contexto",
contextLengthTooltip: "Número máximo de tokens por amostra de treinamento.",
customContextLength: "Insira um valor personalizado",
contextLengthDescription: "Comprimento máximo de sequência para amostras de treino",
learningRate: "Taxa de Aprendizado (Learning Rate)",
learningRateTooltip:
"Tamanho do passo para atualizações de peso. Valores menores treinam mais lentamente, mas com mais estabilidade.",
learningRateDescription:
"Recomendado: 2e-4 para LoRA, 5e-5 para CPT, 2e-5 para fine-tune completo",
embeddingLearningRate: "Taxa de Aprendizado do Embedding",
embeddingLearningRateTooltip:
"Usado apenas quando o CPT está treinando embed_tokens. Os embeddings são mais fáceis de desestabilizar do que os pesos LoRA, por isso geralmente precisam de um LR menor. Deixe em branco para usar lr/10; a faixa típica de funcionamento é de 2x a 10x menor que o LR principal. Aumente apenas se a adaptação de vocabulário ou de tokens de domínio estiver muito lenta.",
embeddingLearningRateDescription:
"Deixe em branco para usar lr/10 (recomendado). A faixa típica é de 2x a 10x menor que a taxa de aprendizado principal.",
rank: "Rank",
rankTooltip:
"Dimensão das matrizes de baixo rank. Maior = mais capacidade.",
alpha: "Alpha",
alphaTooltip: "Fator de escala para atualizações LoRA. Geralmente o dobro do rank.",
dropout: "Dropout",
dropoutTooltip:
"Probabilidade de dropout para as camadas LoRA para reduzir o overfitting.",
visionLayers: "Camadas de visão",
languageLayers: "Camadas de linguagem",
attentionModules: "Módulos de atenção",
mlpModules: "Módulos MLP",
targetModules: "Módulos de Destino",
enableLora: "Ativar LoRA",
trainWithLora: "Treinar com LoRA",
stableRank: "Stable Rank",
memoryEfficient: "Eficiente em Memória",
optimization: "Otimização",
schedule: "Cronograma",
memory: "Memória",
optimizer: "Otimizador",
optimizerTooltip:
"Algoritmo de otimização. Variantes de 8 bits reduzem o uso de memória. Fused é recomendado para modelos de visão.",
lrScheduler: "Agendador de LR",
lrSchedulerTooltip:
"Como a taxa de aprendizado muda ao longo do treino. Linear decai de forma constante; cosine decai em curva.",
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: "Tamanho do Lote (Batch Size)",
batchSizeTooltip: "Amostras processadas por passo. Maior consome mais VRAM.",
gradAccum: "Acúmulo de Gradiente",
gradAccumTooltip: "Simula tamanhos de lote maiores sem gastar VRAM extra.",
weightDecay: "Decaimento de Peso",
weightDecayTooltip: "Regularização L2 para evitar overfitting.",
warmupSteps: "Passos de Aquecimento (Warmup)",
warmupStepsTooltip:
"Aumenta gradualmente a LR no início do treino para garantir estabilidade.",
scheduleEpochsTooltip:
"Número de passagens completas pelo dataset. Defina 0 para rodar por passos máximos.",
saveSteps: "Passos para Salvar",
saveStepsTooltip: "Salva um checkpoint a cada N passos. 0 para desativar.",
evalSteps: "Passos de Validação",
evalStepsTooltip:
"Fração dos passos totais de treino entre as validações (0-1). Defina como 0 para desativar. Ex: 0.01 = valida a cada 1% dos passos.",
seed: "Seed",
seedTooltip: "Semente aleatória para reprodutibilidade.",
gradCheckpoint: "Grad Checkpoint",
gradCheckpointTooltip:
"Troca processamento por memória recalculando as ativações.",
none: "Nenhum",
standard: "Padrão",
enablePacking: "Ativar empacotamento (packing)",
assistantCompletionsOnly: "Apenas respostas do assistente",
readMore: "Leia mais",
},
training: {
title: "Treinamento",
description: "Monitore e controle o treinamento",
chartNoDataTitle: "Nenhum dado de treinamento ainda",
chartNoDataDescription: "Inicie o treinamento para ver o progresso da loss",
startTraining: "Iniciar Treinamento",
starting: "Iniciando...",
loadingModel: "Carregando modelo...",
checkingDataset: "Verificando dataset...",
configLabel: "Configuração de Treino",
upload: "Upload",
uploadConfigTooltip: "Carregar uma configuração YAML salva",
save: "Salvar",
saveConfigTooltip: "Baixar configuração atual como YAML",
reset: "Redefinir",
resetConfigTooltip: "Redefinir para os padrões do modelo",
configLoaded: "Configuração carregada",
failedToLoadConfig: "Falha ao carregar a configuração",
invalidYamlFile: "Arquivo YAML inválido",
failedToReadFile: "Falha ao ler o arquivo",
parametersReset: "Parâmetros redefinidos para os padrões do modelo",
audioIncompatible:
"Este modelo não suporta áudio. Mude para um modelo compatível com áudio ou escolha um dataset sem áudio.",
visionIncompatible:
"O modelo de texto não é compatível com um dataset multimodal. Mude para um modelo de visão ou escolha um dataset apenas de texto.",
cancelTitle: "Cancelar Treinamento",
cancelDescription: "Deseja cancelar a execução de treinamento atual?",
continueAction: "Continuar Treinamento",
cancelAction: "Cancelar Treinamento",
stopTitle: "Interromper Treinamento",
stopDescription: "Escolha como você deseja interromper a execução de treinamento atual.",
stopAction: "Interromper",
stopping: "Interrompendo...",
stopAndSave: "Interromper e Salvar",
compareInChat: "Comparar no Chat",
exportModel: "Exportar Modelo",
milestone: "Marco",
halfwayDone: "Metade concluída. O treinamento passou de 50%.",
doneNextStep:
"Treinamento concluído. Próximo passo: comparar as saídas do modelo base vs fine-tuned.",
},
history: {
title: "Histórico",
emptyTitle: "Nenhuma execução de treino ainda",
emptyDescription:
"Nenhuma execução de treino ainda. Inicie sua primeira execução na aba Configurar.",
loadError: "Falha ao carregar as execuções de treino",
deleteError: "Falha ao excluir a execução de treino. Por favor, tente novamente.",
retry: "Tentar novamente",
loadMore: "Carregar mais",
loading: "Carregando...",
loadingRun: "Carregando execução de treino...",
runNotFound: "Execução não encontrada",
deleteTitle: "Excluir execução de treino?",
deleteDescription:
"Isso excluirá permanentemente esta execução de treino e todas as suas métricas. Esta ação não pode ser desfeita.",
runCount: "{count} execuções",
oneRun: "1 execução",
resume: "Retomar",
resumeTraining: "Retomar treinamento",
resuming: "Retomando...",
deleteRun: "Excluir execução",
loss: "Loss",
steps: "Passos",
lossTrendSparkline: "Minigráfico de tendência da loss",
relativeJustNow: "agora mesmo",
relativeMinutesAgo: "há {count}m",
relativeHoursAgo: "há {count}h",
relativeDaysAgo: "há {count}d",
status: {
completed: "Concluído",
stopped: "Interrompido",
error: "Erro",
running: "Em andamento",
continued: "Continuado",
},
message: {
completed: "Treinamento concluído",
stopped: "Treinamento interrompido",
running: "Treinamento em andamento",
errored: "Treinamento com erro",
},
},
charts: {
settings: "Configurações do Gráfico",
settingsDescription:
"Ajuste a apresentação do gráfico enquanto o treinamento continua rodando.",
openSettings: "Abrir configurações do gráfico",
viewWindow: "Janela de visualização",
viewWindowDescription: "Mostra apenas os passos mais recentes ou o histórico completo.",
window: "Janela",
all: "Tudo",
trainingLoss: "Loss de Treinamento",
trainingLossDescription: "Controle as sobreposições e a suavização EMA.",
smoothing: "Suavização",
smoothingDescription: "Mova para a direita para mais suavização. `0` = bruto.",
showRawLoss: "Mostrar loss bruta",
showSmoothedLoss: "Mostrar loss suavizada",
showAverageLine: "Mostrar linha média",
scaleAndCleanup: "Escala e limpeza",
linear: "Linear",
log: "Log",
noClip: "Sem corte",
clipP99: "Cortar p99",
clipP95: "Cortar p95",
lossAxis: "Eixo da loss",
gradientNormAxis: "Eixo da norma do gradiente",
learningRateAxis: "Eixo da taxa de aprendizado",
resetDefaults: "Redefinir padrões",
loss: "Loss",
smoothed: "Suavizado",
evalLoss: "Loss de Validação",
learningRate: "Taxa de Aprendizado",
lr: "LR",
gradNorm: "Norma do Grad.",
gradientNorm: "Norma do Gradiente",
step: "Passo {step}",
averageValue: "média {value}",
waitingForFirstEvaluationStep: "Aguardando o primeiro passo de validação...",
evaluationNotConfigured: "Validação não configurada",
evalChartWillAppear: "O gráfico aparecerá assim que o eval_steps for alcançado",
setEvalDatasetAndSteps:
"Defina o dataset de validação e eval_steps para acompanhar a loss de validação",
},
progress: {
title: "Progresso do Treinamento",
liveMetrics: "Métricas de treino em tempo real",
exportGguf: "Exportar para GGUF",
openConfig: "Abrir configuração de treino",
configLabel: "Configuração de Treino",
hyperparams: "Hiperparâmetros",
epochs: "Épocas",
batchSize: "Tamanho do lote",
learningRate: "Taxa de aprendizado",
optimizer: "Otimizador",
maxSteps: "Passos máximos",
contextLength: "Comprimento do contexto",
warmupSteps: "Passos de warmup",
rank: "Rank",
alpha: "Alpha",
dropout: "Dropout",
variant: "Variante",
epoch: "Época {value}",
percentComplete: "{percent}% completo",
stepProgress: "Passo {current} / {total}",
loss: "Loss",
lr: "LR",
gradNorm: "Norma do Grad.",
model: "Modelo",
method: "Método",
elapsed: "Decorrido: {value}",
eta: "ETA: {value}",
stepsPerSecond: "{value} passos/s",
noStepsPerSecond: "-- passos/s",
tokens: "Tokens: {value}",
gpuMonitor: "Monitor da GPU",
live: "Ao vivo",
utilization: "Utilização",
temperature: "Temperatura",
vram: "VRAM",
power: "Energia",
phase: {
idle: "Ocioso",
downloadingModel: "Baixando modelo",
downloadingDataset: "Baixando dataset",
loadingModel: "Carregando modelo",
loadingDataset: "Carregando dataset",
configuring: "Configurando",
training: "Treinando",
completed: "Concluído",
error: "Erro",
stopped: "Interrompido",
},
},
trainingStart: {
ready: "Pronto",
downloading: "Baixando",
preparing: "Preparando",
left: "restam {eta}",
downloaded: "{size} baixados",
terminalStart: "> treinamento do unsloth iniciado...",
preparingResources: "> Preparando modelo e dataset...",
gettingReady: "> Estamos deixando tudo pronto para a sua execução...",
waitingForFirstStep: "> {message} | aguardando o primeiro passo... ({step})",
resumingTraining: "Retomando treinamento...",
startingTraining: "iniciando treinamento...",
dataset: "Dataset",
datasetStreaming: "Dataset: streaming (sem download completo)",
modelWeights: "Pesos do modelo",
},
tour: {
guidedTour: "Tour Guiado",
},
},
} as const;

View file

@ -4,19 +4,26 @@
import { getLocale } from "./locale-store";
import { en } from "./locales/en";
import { zhCN } from "./locales/zh-CN";
import { ptBR } from "./locales/pt-br";
import { ja } from "./locales/ja";
import type { InterpolationValues, MessageKey } from "./types";
export const LOCALES = {
en: { label: "English", nativeLabel: "English" },
"zh-CN": { label: "Chinese (Simplified)", nativeLabel: "简体中文" },
ja: { label: "Japanese", nativeLabel: "日本語" },
"pt-BR": { label: "Portuguese (Brazil)", nativeLabel: "Português (Brasil)" },
"ja": { label: "Japanese", nativeLabel: "日本語" },
} as const;
export type Locale = keyof typeof LOCALES;
export type TranslationKey = MessageKey<typeof en>;
export const messages = { en, "zh-CN": zhCN, ja } as const;
export const messages = {
en,
"zh-CN": zhCN,
"pt-BR": ptBR,
ja
} as const;
const PLACEHOLDER_PATTERN = /\{([a-zA-Z0-9_]+)\}/g;
@ -75,4 +82,4 @@ export function isSupportedLocale(value: unknown): value is Locale {
typeof value === "string" &&
Object.prototype.hasOwnProperty.call(LOCALES, value)
);
}
}

View file

@ -1473,12 +1473,14 @@ class TestHardwareAmdBranching:
assert "from . import amd" in source
def test_hardware_branches_on_is_rocm_for_utilization(self):
"""get_gpu_utilization dispatches to amd.py via _smi_query when IS_ROCM."""
"""get_gpu_utilization dispatches visible metrics through amd.py on ROCm."""
hw_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "hardware.py"
source = hw_path.read_text(encoding = "utf-8")
func_start = source.find("def get_gpu_utilization")
func_body = source[func_start : source.find("\ndef ", func_start + 1)]
assert '_smi_query("get_primary_gpu_utilization"' in func_body
assert "_smi_query(" in func_body
assert '"get_visible_gpu_utilization"' in func_body
assert "_reconcile_rocm_unified_memory" in func_body
smi = source[
source.find("def _smi_query") : source.find("\ndef ", source.find("def _smi_query") + 1)
]

View file

@ -565,30 +565,50 @@ def _reload_gguf(save_dir: Path, metrics: dict) -> int:
raise SystemExit(f"no .gguf files in {save_dir}")
gguf_path = gguf_files[0]
# This is a save/reload-integrity smoke; a few generated tokens are enough.
# Keep llama.cpp bounded on macOS runners where BF16 GGUF decode is CPU-bound.
n_predict = os.environ.get("UNSLOTH_GGUF_RELOAD_N", "8")
n_threads = os.environ.get("UNSLOTH_GGUF_RELOAD_THREADS", str(os.cpu_count() or 4))
reload_timeout = int(os.environ.get("UNSLOTH_GGUF_RELOAD_TIMEOUT", "420"))
with Phase("reload_gguf", metrics):
proc = subprocess.run(
[
str(llama_cli),
"-m",
str(gguf_path),
"-p",
PROMPT,
"-n",
"24",
"--temp",
"0",
"--seed",
str(SEED),
"-no-cnv",
"--no-warmup",
],
capture_output = True,
text = True,
timeout = 300,
# Hand llama-cli an immediate EOF; without it -no-cnv can still leave the
# process blocked reading stdin, which times out instead of generating.
stdin = subprocess.DEVNULL,
)
argv = [
str(llama_cli),
"-m",
str(gguf_path),
"-p",
PROMPT,
"-n",
n_predict,
"-t",
n_threads,
"--temp",
"0",
"--seed",
str(SEED),
"-c",
"256",
"--no-warmup",
]
try:
proc = subprocess.run(
argv,
capture_output = True,
text = True,
timeout = reload_timeout,
# Newer llama.cpp keeps llama-cli in chat mode; exit after one reply.
input = "/exit\n",
)
except subprocess.TimeoutExpired as exc:
def _decode(stream) -> str:
if isinstance(stream, bytes):
return stream.decode("utf-8", errors = "replace")
return stream or ""
print(f" [reload:gguf] TIMEOUT stdout:\n{_decode(exc.stdout)[:1000]}", flush = True)
print(f" [reload:gguf] TIMEOUT stderr:\n{_decode(exc.stderr)[:1000]}", flush = True)
raise
metrics["llama_cli_returncode"] = proc.returncode
metrics["generation"] = (proc.stdout or "")[:1500]