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:
parent
91f4ec7ba7
commit
22cd26f75d
25 changed files with 2689 additions and 383 deletions
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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") == ""
|
||||
|
||||
|
||||
# =====================================================================
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
160
studio/frontend/src/components/floating-monitor.tsx
Normal file
160
studio/frontend/src/components/floating-monitor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
)
|
||||
);
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
477
studio/frontend/src/features/settings/tabs/resources-tab.tsx
Normal file
477
studio/frontend/src/features/settings/tabs/resources-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
130
studio/frontend/src/hooks/use-system.ts
Normal file
130
studio/frontend/src/hooks/use-system.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { 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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.");
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
934
studio/frontend/src/i18n/locales/pt-br.ts
Normal file
934
studio/frontend/src/i18n/locales/pt-br.ts
Normal 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;
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue