Merge branch 'main' into pip
This commit is contained in:
commit
6700dd60f0
27 changed files with 787 additions and 137 deletions
34
install.ps1
34
install.ps1
|
|
@ -663,15 +663,29 @@ shell.Run cmd, 0, False
|
|||
# CUDA wheels. Missing dependencies (transformers, trl, peft, etc.)
|
||||
# are still pulled in because they are new, not upgrades.
|
||||
#
|
||||
# ── Helper: find no-torch-runtime.txt ──
|
||||
function Find-NoTorchRuntimeFile {
|
||||
if ($StudioLocalInstall -and (Test-Path (Join-Path $RepoRoot "studio\backend\requirements\no-torch-runtime.txt"))) {
|
||||
return Join-Path $RepoRoot "studio\backend\requirements\no-torch-runtime.txt"
|
||||
}
|
||||
$installed = Get-ChildItem -Path $VenvDir -Recurse -Filter "no-torch-runtime.txt" -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -like "*studio*backend*requirements*no-torch-runtime.txt" } |
|
||||
Select-Object -ExpandProperty FullName -First 1
|
||||
return $installed
|
||||
}
|
||||
|
||||
if ($_Migrated) {
|
||||
# Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state
|
||||
# in the new venv location, while preserving existing torch/CUDA
|
||||
Write-Host "==> Upgrading unsloth in migrated environment..."
|
||||
if ($SkipTorch) {
|
||||
# No-torch: install runtime deps via [huggingfacenotorch] extras,
|
||||
# then unsloth-zoo with --no-deps to avoid pulling torch.
|
||||
uv pip install --python $VenvPython --reinstall-package unsloth "unsloth[huggingfacenotorch]>=2026.3.14"
|
||||
uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo unsloth-zoo
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.14" unsloth-zoo
|
||||
$NoTorchReq = Find-NoTorchRuntimeFile
|
||||
if ($NoTorchReq) {
|
||||
uv pip install --python $VenvPython --no-deps -r $NoTorchReq
|
||||
}
|
||||
} else {
|
||||
uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.14" unsloth-zoo
|
||||
}
|
||||
|
|
@ -693,10 +707,13 @@ shell.Run cmd, 0, False
|
|||
|
||||
Write-Host "==> Installing unsloth (this may take a few minutes)..."
|
||||
if ($SkipTorch) {
|
||||
# No-torch: install runtime deps via [huggingfacenotorch] extras,
|
||||
# then unsloth-zoo with --no-deps to avoid pulling torch.
|
||||
uv pip install --python $VenvPython --upgrade-package unsloth "unsloth[huggingfacenotorch]>=2026.3.14"
|
||||
uv pip install --python $VenvPython --no-deps --upgrade-package unsloth-zoo unsloth-zoo
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.3.14" unsloth-zoo
|
||||
$NoTorchReq = Find-NoTorchRuntimeFile
|
||||
if ($NoTorchReq) {
|
||||
uv pip install --python $VenvPython --no-deps -r $NoTorchReq
|
||||
}
|
||||
if ($StudioLocalInstall) {
|
||||
Write-Host "==> Overlaying local repo (editable)..."
|
||||
uv pip install --python $VenvPython -e $RepoRoot --no-deps
|
||||
|
|
@ -737,6 +754,7 @@ shell.Run cmd, 0, False
|
|||
return
|
||||
}
|
||||
# Tell setup.ps1 to skip base package installation (install.ps1 already did it)
|
||||
# Tell setup.ps1 to skip base package installation (install.ps1 already did it)
|
||||
$env:SKIP_STUDIO_BASE = "1"
|
||||
$env:STUDIO_PACKAGE_NAME = $PackageName
|
||||
$env:UNSLOTH_NO_TORCH = if ($SkipTorch) { "true" } else { "false" }
|
||||
|
|
|
|||
45
install.sh
45
install.sh
|
|
@ -891,6 +891,21 @@ fi
|
|||
# ── Resolve repo root (for --local installs) ──
|
||||
_REPO_ROOT="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)"
|
||||
|
||||
# ── Helper: find no-torch-runtime.txt (local repo or site-packages) ──
|
||||
_find_no_torch_runtime() {
|
||||
# Check local repo first (for --local installs)
|
||||
if [ -f "$_REPO_ROOT/studio/backend/requirements/no-torch-runtime.txt" ]; then
|
||||
echo "$_REPO_ROOT/studio/backend/requirements/no-torch-runtime.txt"
|
||||
return
|
||||
fi
|
||||
# Check inside installed package
|
||||
_rt=$(find "$VENV_DIR" -path "*/studio/backend/requirements/no-torch-runtime.txt" -print -quit 2>/dev/null || echo "")
|
||||
if [ -n "$_rt" ]; then
|
||||
echo "$_rt"
|
||||
return
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Detect GPU and choose PyTorch index URL ──
|
||||
# Mirrors Get-TorchIndexUrl in install.ps1.
|
||||
# On CPU-only machines this returns the cpu index, avoiding the solver
|
||||
|
|
@ -947,13 +962,17 @@ if [ "$_MIGRATED" = true ]; then
|
|||
# in the new venv location, while preserving existing torch/CUDA
|
||||
echo "==> Upgrading unsloth in migrated environment..."
|
||||
if [ "$SKIP_TORCH" = true ]; then
|
||||
# No-torch: install runtime deps via [huggingfacenotorch] extras,
|
||||
# then unsloth-zoo with --no-deps to avoid pulling torch.
|
||||
uv pip install --python "$_VENV_PY" \
|
||||
--reinstall-package unsloth \
|
||||
"unsloth[huggingfacenotorch]>=2026.3.14"
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps (current
|
||||
# PyPI metadata still declares torch as a hard dep), then install
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps
|
||||
# to prevent transitive torch resolution.
|
||||
uv pip install --python "$_VENV_PY" --no-deps \
|
||||
--reinstall-package unsloth-zoo unsloth-zoo
|
||||
--reinstall-package unsloth --reinstall-package unsloth-zoo \
|
||||
"unsloth>=2026.3.14" unsloth-zoo
|
||||
_NO_TORCH_RT="$(_find_no_torch_runtime)"
|
||||
if [ -n "$_NO_TORCH_RT" ]; then
|
||||
uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
|
||||
fi
|
||||
else
|
||||
uv pip install --python "$_VENV_PY" \
|
||||
--reinstall-package unsloth --reinstall-package unsloth-zoo \
|
||||
|
|
@ -975,13 +994,15 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
# Fresh: Step 2 - install unsloth, preserving pre-installed torch
|
||||
echo "==> Installing unsloth (this may take a few minutes)..."
|
||||
if [ "$SKIP_TORCH" = true ]; then
|
||||
# No-torch: install runtime deps via [huggingfacenotorch] extras,
|
||||
# then unsloth-zoo with --no-deps to avoid pulling torch.
|
||||
uv pip install --python "$_VENV_PY" \
|
||||
--upgrade-package unsloth \
|
||||
"unsloth[huggingfacenotorch]>=2026.3.14"
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
uv pip install --python "$_VENV_PY" --no-deps \
|
||||
--upgrade-package unsloth-zoo unsloth-zoo
|
||||
--upgrade-package unsloth --upgrade-package unsloth-zoo \
|
||||
"unsloth>=2026.3.14" unsloth-zoo
|
||||
_NO_TORCH_RT="$(_find_no_torch_runtime)"
|
||||
if [ -n "$_NO_TORCH_RT" ]; then
|
||||
uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
|
||||
fi
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
echo "==> Overlaying local repo (editable)..."
|
||||
uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ class LlamaCppBackend:
|
|||
self._effective_context_length: Optional[int] = None
|
||||
self._chat_template: Optional[str] = None
|
||||
self._supports_reasoning: bool = False
|
||||
self._reasoning_always_on: bool = False
|
||||
self._supports_tools: bool = False
|
||||
self._cache_type_kv: Optional[str] = None
|
||||
self._reasoning_default: bool = True
|
||||
|
|
@ -107,6 +108,10 @@ class LlamaCppBackend:
|
|||
def supports_reasoning(self) -> bool:
|
||||
return self._supports_reasoning
|
||||
|
||||
@property
|
||||
def reasoning_always_on(self) -> bool:
|
||||
return self._reasoning_always_on
|
||||
|
||||
@property
|
||||
def reasoning_default(self) -> bool:
|
||||
return self._reasoning_default
|
||||
|
|
@ -550,6 +555,7 @@ class LlamaCppBackend:
|
|||
self._context_length = None
|
||||
self._chat_template = None
|
||||
self._supports_reasoning = False
|
||||
self._reasoning_always_on = False
|
||||
self._supports_tools = False
|
||||
self._n_layers = None
|
||||
self._n_kv_heads = None
|
||||
|
|
@ -627,6 +633,20 @@ class LlamaCppBackend:
|
|||
logger.info(
|
||||
"GGUF metadata: model supports reasoning (DeepSeek thinking)"
|
||||
)
|
||||
# Models with hardcoded <think> tags or reasoning_content
|
||||
# in their chat template always produce thinking output
|
||||
# (no toggle to disable it).
|
||||
if not self._supports_reasoning:
|
||||
if (
|
||||
"<think>" in tpl
|
||||
and "</think>" in tpl
|
||||
or "reasoning_content" in tpl
|
||||
):
|
||||
self._supports_reasoning = True
|
||||
self._reasoning_always_on = True
|
||||
logger.info(
|
||||
"GGUF metadata: model always reasons (<think> tags in template)"
|
||||
)
|
||||
# Detect tool calling support from chat template
|
||||
tool_markers = [
|
||||
"{%- if tools %}",
|
||||
|
|
@ -1272,7 +1292,18 @@ class LlamaCppBackend:
|
|||
|
||||
self._gguf_path = gguf_path
|
||||
self._hf_repo = hf_repo
|
||||
self._hf_variant = hf_variant
|
||||
# For local GGUF files, extract variant from filename if not provided
|
||||
if hf_variant:
|
||||
self._hf_variant = hf_variant
|
||||
elif gguf_path:
|
||||
try:
|
||||
from utils.models.model_config import _extract_quant_label
|
||||
|
||||
self._hf_variant = _extract_quant_label(gguf_path)
|
||||
except Exception:
|
||||
self._hf_variant = None
|
||||
else:
|
||||
self._hf_variant = None
|
||||
self._is_vision = is_vision
|
||||
self._model_identifier = model_identifier
|
||||
|
||||
|
|
@ -1284,7 +1315,7 @@ class LlamaCppBackend:
|
|||
)
|
||||
|
||||
# Wait for llama-server to become healthy
|
||||
if not self._wait_for_health(timeout = 120.0):
|
||||
if not self._wait_for_health(timeout = 600.0):
|
||||
self._kill_process()
|
||||
raise RuntimeError(
|
||||
"llama-server failed to start. "
|
||||
|
|
@ -1318,6 +1349,7 @@ class LlamaCppBackend:
|
|||
self._effective_context_length = None
|
||||
self._chat_template = None
|
||||
self._supports_reasoning = False
|
||||
self._reasoning_always_on = False
|
||||
self._supports_tools = False
|
||||
self._cache_type_kv = None
|
||||
self._n_layers = None
|
||||
|
|
@ -1367,27 +1399,33 @@ class LlamaCppBackend:
|
|||
"""Kill orphaned llama-server processes started by studio.
|
||||
|
||||
Only kills processes whose resolved binary lives under a known
|
||||
Unsloth install directory to avoid terminating unrelated
|
||||
llama-server instances on the machine.
|
||||
Studio install directory (or matches an exact env-var override)
|
||||
to avoid terminating unrelated llama-server instances.
|
||||
|
||||
Mirrors every location that _find_llama_server_binary() can
|
||||
return from so that orphans from any supported install path
|
||||
are still cleaned up.
|
||||
|
||||
Uses psutil for cross-platform support (Linux, macOS, Windows).
|
||||
Falls back to pgrep + /proc/<pid>/exe on Linux when psutil is
|
||||
not installed.
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
try:
|
||||
# Build the same set of directories that _find_llama_server_binary
|
||||
# searches, so we only kill servers we could have started.
|
||||
# -- Build the ownership allowlist --------------------------------
|
||||
# Two kinds of matches:
|
||||
# exact_binaries -- env var overrides (exact path match only)
|
||||
# install_roots -- directory trees that are Studio-owned
|
||||
# (binary must be *under* one of these)
|
||||
install_roots: list[Path] = []
|
||||
|
||||
# ~/.unsloth/llama.cpp (primary install location)
|
||||
# Primary install dir (setup.sh / prebuilt installer)
|
||||
install_roots.append(Path.home() / ".unsloth" / "llama.cpp")
|
||||
|
||||
# Legacy: in-tree build
|
||||
# Legacy in-tree build dirs (older setup.sh versions)
|
||||
project_root = Path(__file__).resolve().parents[4]
|
||||
install_roots.append(project_root / "llama.cpp")
|
||||
|
||||
|
|
@ -1418,40 +1456,103 @@ class LlamaCppBackend:
|
|||
|
||||
my_pid = os.getpid()
|
||||
|
||||
for proc in psutil.process_iter(["pid", "name", "exe"]):
|
||||
try:
|
||||
if proc.info["pid"] == my_pid:
|
||||
# -- Enumerate processes -------------------------------------------
|
||||
# Prefer psutil (cross-platform). Fall back to pgrep + /proc on
|
||||
# Linux when psutil is not installed.
|
||||
try:
|
||||
import psutil
|
||||
|
||||
has_psutil = True
|
||||
except ImportError:
|
||||
has_psutil = False
|
||||
|
||||
if has_psutil:
|
||||
for proc in psutil.process_iter(["pid", "name", "exe"]):
|
||||
try:
|
||||
if proc.info["pid"] == my_pid:
|
||||
continue
|
||||
|
||||
name = proc.info.get("name") or ""
|
||||
if not name.lower().startswith("llama-server"):
|
||||
continue
|
||||
|
||||
exe = proc.info.get("exe")
|
||||
if not exe:
|
||||
continue
|
||||
|
||||
exe_path = Path(exe).resolve()
|
||||
|
||||
# Check ownership: exact binary match OR binary is
|
||||
# under a known install root (proper ancestry, not
|
||||
# substring).
|
||||
is_ours = exe_path in exact_binaries or any(
|
||||
exe_path.is_relative_to(root) for root in resolved_roots
|
||||
)
|
||||
if not is_ours:
|
||||
continue
|
||||
|
||||
proc.kill()
|
||||
logger.info(
|
||||
f"Killed orphaned llama-server process "
|
||||
f"(pid={proc.info['pid']})"
|
||||
)
|
||||
except (
|
||||
psutil.NoSuchProcess,
|
||||
psutil.AccessDenied,
|
||||
psutil.ZombieProcess,
|
||||
):
|
||||
pass
|
||||
else:
|
||||
# -- Fallback: pgrep + /proc/<pid>/exe (Linux only) -----------
|
||||
if sys.platform != "linux":
|
||||
return
|
||||
result = subprocess.run(
|
||||
["pgrep", "-a", "-f", "llama-server"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return
|
||||
|
||||
for line in result.stdout.strip().splitlines():
|
||||
parts = line.strip().split(None, 1)
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
pid = int(parts[0])
|
||||
if pid == my_pid:
|
||||
continue
|
||||
|
||||
name = proc.info.get("name") or ""
|
||||
if not name.lower().startswith("llama-server"):
|
||||
continue
|
||||
# Resolve the actual executable. /proc/<pid>/exe is a
|
||||
# symlink to the real binary and avoids all cmdline-
|
||||
# parsing ambiguities (spaces in paths, argv rewriting).
|
||||
# Fall back to the first cmdline token when /proc is
|
||||
# unavailable.
|
||||
proc_exe = Path(f"/proc/{pid}/exe")
|
||||
try:
|
||||
binary = proc_exe.resolve(strict = True)
|
||||
except (OSError, ValueError):
|
||||
cmdline = parts[1]
|
||||
token = cmdline.split()[0] if cmdline.strip() else ""
|
||||
if not token:
|
||||
continue
|
||||
binary = Path(token).resolve(strict = False)
|
||||
|
||||
exe = proc.info.get("exe")
|
||||
if not exe:
|
||||
continue
|
||||
|
||||
exe_path = Path(exe).resolve()
|
||||
|
||||
# Check if this binary is one we manage
|
||||
is_ours = exe_path in exact_binaries or any(
|
||||
exe_path.is_relative_to(root) for root in resolved_roots
|
||||
owned = binary in exact_binaries or any(
|
||||
binary.is_relative_to(root) for root in resolved_roots
|
||||
)
|
||||
if not is_ours:
|
||||
if not owned:
|
||||
continue
|
||||
|
||||
proc.kill()
|
||||
logger.info(
|
||||
f"Killed orphaned llama-server process (pid={proc.info['pid']})"
|
||||
)
|
||||
except (
|
||||
psutil.NoSuchProcess,
|
||||
psutil.AccessDenied,
|
||||
psutil.ZombieProcess,
|
||||
):
|
||||
pass
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
logger.info(f"Killed orphaned llama-server process (pid={pid})")
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except PermissionError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning("Error during orphan server cleanup", exc_info = True)
|
||||
|
||||
def _cleanup(self):
|
||||
"""atexit handler to ensure llama-server is terminated."""
|
||||
|
|
@ -2135,6 +2236,11 @@ class LlamaCppBackend:
|
|||
# ── Structured tool_calls ──
|
||||
tc_deltas = delta.get("tool_calls")
|
||||
if tc_deltas:
|
||||
# Once visible content has been
|
||||
# emitted, do not reclassify this
|
||||
# turn as a tool call.
|
||||
if _last_emitted:
|
||||
continue
|
||||
has_structured_tc = True
|
||||
detect_state = _S_DRAINING
|
||||
for tc_d in tc_deltas:
|
||||
|
|
@ -2362,7 +2468,18 @@ class LlamaCppBackend:
|
|||
tool_calls = None
|
||||
content_text = content_accum
|
||||
if has_structured_tc:
|
||||
tool_calls = [tool_calls_acc[i] for i in sorted(tool_calls_acc)]
|
||||
# Filter out incomplete fragments (e.g. from
|
||||
# truncation by max_tokens or disconnect).
|
||||
tool_calls = [
|
||||
tool_calls_acc[i]
|
||||
for i in sorted(tool_calls_acc)
|
||||
if (
|
||||
tool_calls_acc[i]
|
||||
.get("function", {})
|
||||
.get("name", "")
|
||||
.strip()
|
||||
)
|
||||
] or None
|
||||
if (
|
||||
not tool_calls
|
||||
and auto_heal_tool_calls
|
||||
|
|
|
|||
|
|
@ -156,12 +156,28 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
|
|||
except Exception as e:
|
||||
logger.warning("Could not read adapter_config.json: %s", e)
|
||||
|
||||
# Auto-enable trust_remote_code for unsloth/* transformers 5.x models
|
||||
# (matches the training worker logic in core/training/worker.py)
|
||||
trust_remote_code = config.get("trust_remote_code", False)
|
||||
if not trust_remote_code:
|
||||
from utils.transformers_version import needs_transformers_5
|
||||
|
||||
model_name = config["model_name"]
|
||||
if needs_transformers_5(model_name) and model_name.lower().startswith(
|
||||
"unsloth/"
|
||||
):
|
||||
trust_remote_code = True
|
||||
logger.info(
|
||||
"Auto-enabled trust_remote_code for unsloth/* transformers 5.x model: %s",
|
||||
model_name,
|
||||
)
|
||||
|
||||
success = backend.load_model(
|
||||
config = mc,
|
||||
max_seq_length = config.get("max_seq_length", 2048),
|
||||
load_in_4bit = load_in_4bit,
|
||||
hf_token = hf_token,
|
||||
trust_remote_code = config.get("trust_remote_code", False),
|
||||
trust_remote_code = trust_remote_code,
|
||||
)
|
||||
|
||||
if success:
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ if os.getenv("ENVIRONMENT_TYPE", "production") == "production":
|
|||
# warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||
# warnings.filterwarnings("ignore", module="triton.*")
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
|
|
@ -53,6 +53,7 @@ from routes import (
|
|||
training_router,
|
||||
)
|
||||
from auth import storage
|
||||
from auth.authentication import get_current_subject
|
||||
from utils.hardware import detect_hardware, get_device, DeviceType
|
||||
import utils.hardware.hardware as _hw_module
|
||||
|
||||
|
|
@ -184,6 +185,34 @@ async def health_check():
|
|||
}
|
||||
|
||||
|
||||
@app.post("/api/shutdown")
|
||||
async def shutdown_server(
|
||||
request: Request,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""Gracefully shut down the Unsloth Studio server.
|
||||
|
||||
Called by the frontend quit dialog so users can stop the server from the UI
|
||||
without needing to use the CLI or kill the process manually.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
async def _delayed_shutdown():
|
||||
await asyncio.sleep(0.2) # Let the HTTP response return first
|
||||
trigger = getattr(request.app.state, "trigger_shutdown", None)
|
||||
if trigger is not None:
|
||||
trigger()
|
||||
else:
|
||||
# Fallback when not launched via run_server() (e.g. direct uvicorn)
|
||||
import signal
|
||||
import os
|
||||
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
|
||||
request.app.state._shutdown_task = asyncio.create_task(_delayed_shutdown())
|
||||
return {"status": "shutting_down"}
|
||||
|
||||
|
||||
@app.get("/api/system")
|
||||
async def get_system_info():
|
||||
"""Get system information"""
|
||||
|
|
|
|||
|
|
@ -136,6 +136,10 @@ class LoadResponse(BaseModel):
|
|||
False,
|
||||
description = "Whether model supports thinking/reasoning mode (enable_thinking)",
|
||||
)
|
||||
reasoning_always_on: bool = Field(
|
||||
False,
|
||||
description = "Whether reasoning is always on (hardcoded <think> tags, not toggleable)",
|
||||
)
|
||||
supports_tools: bool = Field(
|
||||
False,
|
||||
description = "Whether model supports tool calling (web search, etc.)",
|
||||
|
|
@ -193,6 +197,9 @@ class InferenceStatusResponse(BaseModel):
|
|||
supports_reasoning: bool = Field(
|
||||
False, description = "Whether the active model supports reasoning/thinking mode"
|
||||
)
|
||||
reasoning_always_on: bool = Field(
|
||||
False, description = "Whether reasoning is always on (not toggleable)"
|
||||
)
|
||||
supports_tools: bool = Field(
|
||||
False, description = "Whether the active model supports tool calling"
|
||||
)
|
||||
|
|
|
|||
35
studio/backend/requirements/no-torch-runtime.txt
Normal file
35
studio/backend/requirements/no-torch-runtime.txt
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Runtime dependencies for no-torch (GGUF-only) mode.
|
||||
# Installed with --no-deps to prevent transitive torch resolution
|
||||
# from packages like accelerate, peft, trl, sentence-transformers.
|
||||
#
|
||||
# Includes unsloth's own direct deps (typer, pydantic, pyyaml,
|
||||
# nest-asyncio) since unsloth is also installed with --no-deps
|
||||
# (current PyPI metadata still declares torch as a hard dep).
|
||||
|
||||
# unsloth direct deps (from pyproject.toml [project].dependencies)
|
||||
typer
|
||||
pydantic
|
||||
pyyaml
|
||||
nest-asyncio
|
||||
|
||||
# HF ecosystem (from [huggingfacenotorch] extras in pyproject.toml)
|
||||
wheel>=0.42.0
|
||||
packaging
|
||||
numpy
|
||||
tqdm
|
||||
psutil
|
||||
tyro
|
||||
protobuf
|
||||
sentencepiece>=0.2.0
|
||||
safetensors>=0.4.3
|
||||
datasets>=3.4.1,!=4.0.*,!=4.1.0,<4.4.0
|
||||
accelerate>=0.34.1
|
||||
peft>=0.18.0,!=0.11.0
|
||||
huggingface_hub>=0.34.0
|
||||
hf_transfer
|
||||
diffusers
|
||||
transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.3.0
|
||||
trl>=0.18.2,!=0.19.0,<=0.24.0
|
||||
sentence-transformers
|
||||
cut_cross_entropy
|
||||
pillow
|
||||
|
|
@ -388,7 +388,7 @@ def _extract_text_from_file(file_path: Path, ext: str) -> str:
|
|||
import pymupdf4llm
|
||||
|
||||
raw = pymupdf4llm.to_markdown(
|
||||
str(file_path), write_images = False, show_progress = False
|
||||
str(file_path), write_images = False, show_progress = False, use_ocr = False
|
||||
)
|
||||
elif ext == ".docx":
|
||||
import mammoth
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ async def load_model(
|
|||
inference = inference_config,
|
||||
context_length = llama_backend.context_length,
|
||||
supports_reasoning = llama_backend.supports_reasoning,
|
||||
reasoning_always_on = llama_backend.reasoning_always_on,
|
||||
chat_template = llama_backend.chat_template,
|
||||
)
|
||||
else:
|
||||
|
|
@ -280,6 +281,7 @@ async def load_model(
|
|||
inference = inference_config,
|
||||
context_length = llama_backend.context_length,
|
||||
supports_reasoning = llama_backend.supports_reasoning,
|
||||
reasoning_always_on = llama_backend.reasoning_always_on,
|
||||
supports_tools = llama_backend.supports_tools,
|
||||
cache_type_kv = llama_backend.cache_type_kv,
|
||||
chat_template = llama_backend.chat_template,
|
||||
|
|
@ -609,6 +611,7 @@ async def get_status(
|
|||
loaded = [_model_id],
|
||||
inference = _inference_cfg,
|
||||
supports_reasoning = llama_backend.supports_reasoning,
|
||||
reasoning_always_on = llama_backend.reasoning_always_on,
|
||||
supports_tools = llama_backend.supports_tools,
|
||||
context_length = llama_backend.context_length,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -271,6 +271,7 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
|
|||
found.append(
|
||||
LocalModelInfo(
|
||||
id = str(model_dir),
|
||||
model_id = f"{child.name}/{model_dir.stem}",
|
||||
display_name = model_dir.stem,
|
||||
path = str(model_dir),
|
||||
source = "lmstudio",
|
||||
|
|
@ -725,13 +726,40 @@ async def get_gguf_variants(
|
|||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""
|
||||
List available GGUF quantization variants for a HuggingFace repo.
|
||||
List available GGUF quantization variants for a HuggingFace repo
|
||||
or a local directory (e.g. LM Studio model folder).
|
||||
|
||||
Returns all available quantization variants (Q4_K_M, Q8_0, BF16, etc.)
|
||||
with file sizes, whether the model supports vision, and the recommended
|
||||
default variant.
|
||||
"""
|
||||
try:
|
||||
from utils.models.model_config import is_local_path, list_local_gguf_variants
|
||||
|
||||
# Local directory path (e.g. LM Studio models) — scan filesystem
|
||||
if is_local_path(repo_id):
|
||||
variants, has_vision = list_local_gguf_variants(repo_id)
|
||||
|
||||
filenames = [v.filename for v in variants]
|
||||
best = _pick_best_gguf(filenames)
|
||||
default_variant = _extract_quant_label(best) if best else None
|
||||
|
||||
return GgufVariantsResponse(
|
||||
repo_id = repo_id,
|
||||
variants = [
|
||||
GgufVariantDetail(
|
||||
filename = v.filename,
|
||||
quant = v.quant,
|
||||
size_bytes = v.size_bytes,
|
||||
downloaded = True, # all local variants are downloaded
|
||||
)
|
||||
for v in variants
|
||||
],
|
||||
has_vision = has_vision,
|
||||
default_variant = default_variant,
|
||||
)
|
||||
|
||||
# Remote HuggingFace repo — query HF API
|
||||
variants, has_vision = list_gguf_variants(repo_id, hf_token = hf_token)
|
||||
|
||||
# Determine default variant
|
||||
|
|
|
|||
|
|
@ -337,6 +337,15 @@ def run_server(
|
|||
|
||||
atexit.register(_remove_pid_file)
|
||||
|
||||
# Expose a shutdown callable via app.state so the /api/shutdown endpoint
|
||||
# can trigger graceful shutdown without circular imports.
|
||||
def _trigger_shutdown():
|
||||
_graceful_shutdown(_server)
|
||||
if _shutdown_event is not None:
|
||||
_shutdown_event.set()
|
||||
|
||||
app.state.trigger_shutdown = _trigger_shutdown
|
||||
|
||||
if not silent:
|
||||
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
|
||||
print_studio_access_banner(
|
||||
|
|
|
|||
|
|
@ -973,6 +973,73 @@ def list_gguf_variants(
|
|||
return variants, has_vision
|
||||
|
||||
|
||||
def list_local_gguf_variants(
|
||||
directory: str,
|
||||
) -> tuple[list[GgufVariantInfo], bool]:
|
||||
"""List GGUF quantization variants in a local directory.
|
||||
|
||||
Mirrors :func:`list_gguf_variants` but reads from the filesystem
|
||||
instead of the HuggingFace API. Aggregates shard sizes by quant
|
||||
label so that split GGUFs appear as a single variant.
|
||||
|
||||
Returns:
|
||||
(variants, has_vision): list of non-mmproj GGUF variants + vision flag.
|
||||
"""
|
||||
p = Path(directory)
|
||||
if not p.is_dir():
|
||||
return [], False
|
||||
|
||||
quant_totals: dict[str, int] = {}
|
||||
quant_first_file: dict[str, str] = {}
|
||||
has_vision = False
|
||||
|
||||
for f in sorted(p.glob("*.gguf")):
|
||||
if _is_mmproj(f.name):
|
||||
has_vision = True
|
||||
continue
|
||||
try:
|
||||
size = f.stat().st_size
|
||||
except OSError:
|
||||
size = 0
|
||||
quant = _extract_quant_label(f.name)
|
||||
quant_totals[quant] = quant_totals.get(quant, 0) + size
|
||||
if quant not in quant_first_file:
|
||||
quant_first_file[quant] = f.name
|
||||
|
||||
variants = [
|
||||
GgufVariantInfo(
|
||||
filename = quant_first_file[q],
|
||||
quant = q,
|
||||
size_bytes = s,
|
||||
)
|
||||
for q, s in quant_totals.items()
|
||||
]
|
||||
variants.sort(key = lambda v: -v.size_bytes)
|
||||
return variants, has_vision
|
||||
|
||||
|
||||
def _find_local_gguf_by_variant(directory: str, variant: str) -> Optional[str]:
|
||||
"""Find the GGUF file in *directory* matching a quantization *variant*.
|
||||
|
||||
For sharded GGUFs (multiple files with the same quant label), returns
|
||||
the first shard (sorted by name) which is what ``llama-server -m`` expects.
|
||||
|
||||
Returns the resolved absolute path, or ``None`` if no match.
|
||||
"""
|
||||
p = Path(directory)
|
||||
if not p.is_dir():
|
||||
return None
|
||||
|
||||
matches = sorted(
|
||||
f
|
||||
for f in p.glob("*.gguf")
|
||||
if not _is_mmproj(f.name) and _extract_quant_label(f.name) == variant
|
||||
)
|
||||
if matches:
|
||||
return str(matches[0].resolve())
|
||||
return None
|
||||
|
||||
|
||||
def detect_gguf_model_remote(
|
||||
repo_id: str,
|
||||
hf_token: Optional[str] = None,
|
||||
|
|
@ -1530,7 +1597,10 @@ class ModelConfig:
|
|||
|
||||
# Auto-detect GGUF models (check before LoRA/vision detection)
|
||||
if is_local:
|
||||
gguf_file = detect_gguf_model(path)
|
||||
if gguf_variant:
|
||||
gguf_file = _find_local_gguf_by_variant(path, gguf_variant)
|
||||
else:
|
||||
gguf_file = detect_gguf_model(path)
|
||||
if gguf_file:
|
||||
display_name = Path(gguf_file).stem
|
||||
logger.info(f"Detected local GGUF model: {gguf_file}")
|
||||
|
|
|
|||
|
|
@ -133,27 +133,28 @@ def lmstudio_model_dirs() -> list[Path]:
|
|||
def _setup_cache_env() -> None:
|
||||
"""Set cache environment variables for HuggingFace, uv, and vLLM.
|
||||
|
||||
HuggingFace cache variables are only set when the legacy Unsloth HF
|
||||
cache already exists, preserving existing model locations. New
|
||||
installations leave HF at its own defaults.
|
||||
Respects the standard HF cache resolution chain: explicit ``HF_HOME``
|
||||
/ ``HF_HUB_CACHE`` env vars take priority, then ``XDG_CACHE_HOME``,
|
||||
then the platform default (``~/.cache/huggingface``). The legacy
|
||||
Unsloth cache is still *scanned* for models but is never set as the
|
||||
active download target.
|
||||
|
||||
Only sets variables that are not already set by the user, so
|
||||
explicit overrides (e.g. HF_HOME=/data/hf) are respected.
|
||||
Works on Linux, macOS, and Windows.
|
||||
"""
|
||||
root = cache_root()
|
||||
hf_dir = root / "huggingface"
|
||||
xdg_cache = Path(
|
||||
os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")
|
||||
).expanduser()
|
||||
hf_default = xdg_cache / "huggingface"
|
||||
defaults: dict[str, str] = {
|
||||
"HF_HOME": str(hf_default),
|
||||
"HF_HUB_CACHE": str(hf_default / "hub"),
|
||||
"HF_XET_CACHE": str(hf_default / "xet"),
|
||||
"UV_CACHE_DIR": str(root / "uv"),
|
||||
"VLLM_CACHE_ROOT": str(root / "vllm"),
|
||||
}
|
||||
# Preserve legacy HF cache for existing installations
|
||||
legacy_hub = hf_dir / "hub"
|
||||
if legacy_hub.is_dir() and any(legacy_hub.iterdir()):
|
||||
defaults["HF_HOME"] = str(hf_dir)
|
||||
defaults["HF_HUB_CACHE"] = str(legacy_hub)
|
||||
defaults["HF_XET_CACHE"] = str(hf_dir / "xet")
|
||||
|
||||
for key, value in defaults.items():
|
||||
if key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ import {
|
|||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { deleteCachedModel, listCachedGguf, listCachedModels, listGgufVariants } from "@/features/chat/api/chat-api";
|
||||
import type { CachedGgufRepo, CachedModelRepo } from "@/features/chat/api/chat-api";
|
||||
import { deleteCachedModel, listCachedGguf, listCachedModels, listGgufVariants, listLocalModels } from "@/features/chat/api/chat-api";
|
||||
import type { CachedGgufRepo, CachedModelRepo, LocalModelInfo } from "@/features/chat/api/chat-api";
|
||||
import type { GgufVariantDetail } from "@/features/chat/types/api";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import {
|
||||
|
|
@ -203,17 +203,20 @@ function GgufVariantExpander({
|
|||
};
|
||||
}, [repoId]);
|
||||
|
||||
// Covers Unix absolute (/), Windows drive (C:\, D:/), UNC (\\server), relative (./, ../), tilde (~/)
|
||||
const isLocalPath = /^(\/|\.{1,2}[\\\/]|~[\\\/]|[A-Za-z]:[\\\/]|\\\\)/.test(repoId);
|
||||
|
||||
const handleVariantClick = useCallback(
|
||||
(quant: string, downloaded?: boolean, sizeBytes?: number) => {
|
||||
onSelect(repoId, {
|
||||
source: "hub",
|
||||
source: isLocalPath ? "local" : "hub",
|
||||
isLora: false,
|
||||
ggufVariant: quant,
|
||||
isDownloaded: downloaded,
|
||||
isDownloaded: isLocalPath ? true : downloaded,
|
||||
expectedBytes: sizeBytes,
|
||||
});
|
||||
},
|
||||
[repoId, onSelect],
|
||||
[repoId, isLocalPath, onSelect],
|
||||
);
|
||||
|
||||
// GGUF fit classification matching llama-server's _select_gpus logic:
|
||||
|
|
@ -380,6 +383,17 @@ function extractParamLabel(id: string): string | undefined {
|
|||
// Module-level caches so re-mounting the popover shows results instantly
|
||||
let _cachedGgufCache: CachedGgufRepo[] = [];
|
||||
let _cachedModelsCache: CachedModelRepo[] = [];
|
||||
let _lmStudioCache: LocalModelInfo[] = [];
|
||||
|
||||
/** Sort LM Studio models with unsloth publisher first. */
|
||||
function sortLmStudio(models: LocalModelInfo[]): LocalModelInfo[] {
|
||||
return [...models].sort((a, b) => {
|
||||
const aUnsloth = (a.model_id ?? "").startsWith("unsloth/") ? 0 : 1;
|
||||
const bUnsloth = (b.model_id ?? "").startsWith("unsloth/") ? 0 : 1;
|
||||
if (aUnsloth !== bUnsloth) return aUnsloth - bUnsloth;
|
||||
return (a.model_id ?? a.display_name).localeCompare(b.model_id ?? b.display_name);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Hub Model Picker ──────────────────────────────────────────
|
||||
|
||||
|
|
@ -413,12 +427,28 @@ export function HubModelPicker({
|
|||
const alreadyCached = _cachedGgufCache.length > 0 || _cachedModelsCache.length > 0;
|
||||
const [cachedReady, setCachedReady] = useState(alreadyCached);
|
||||
|
||||
// LM Studio local models -- module-level cache so re-mounting the
|
||||
// popover does not flash an empty section (same pattern as GGUF/models).
|
||||
const [lmStudioModels, setLmStudioModels] = useState<LocalModelInfo[]>(_lmStudioCache);
|
||||
|
||||
const refreshCachedLists = useCallback(() => {
|
||||
listCachedGguf().then((v) => { _cachedGgufCache = v; setCachedGguf(v); }).catch(() => {});
|
||||
listCachedModels().then((v) => { _cachedModelsCache = v; setCachedModels(v); }).catch(() => {});
|
||||
listLocalModels().then((res) => {
|
||||
const next = sortLmStudio(res.models.filter((m) => m.source === "lmstudio"));
|
||||
_lmStudioCache = next;
|
||||
setLmStudioModels(next);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Always refresh LM Studio models (not gated by alreadyCached)
|
||||
listLocalModels().then((res) => {
|
||||
const next = sortLmStudio(res.models.filter((m) => m.source === "lmstudio"));
|
||||
_lmStudioCache = next;
|
||||
setLmStudioModels(next);
|
||||
}).catch(() => {});
|
||||
|
||||
if (alreadyCached) return;
|
||||
let done = 0;
|
||||
const check = () => { if (++done >= 2) setCachedReady(true); };
|
||||
|
|
@ -686,6 +716,40 @@ export function HubModelPicker({
|
|||
</>
|
||||
) : null}
|
||||
|
||||
{!showHfSection && chatOnly && lmStudioModels.length > 0 ? (
|
||||
<>
|
||||
<ListLabel>LM Studio</ListLabel>
|
||||
{lmStudioModels.map((m) => {
|
||||
const isGguf = isGgufRepo(m.id) || isGgufRepo(m.display_name);
|
||||
return (
|
||||
<div key={m.id}>
|
||||
<ModelRow
|
||||
label={m.model_id ?? m.display_name}
|
||||
meta={isGguf || m.path.endsWith(".gguf") ? "GGUF" : "Local"}
|
||||
selected={value === m.id}
|
||||
onClick={() => {
|
||||
if (isGguf) {
|
||||
setExpandedGguf((prev) => (prev === m.id ? null : m.id));
|
||||
} else {
|
||||
onSelect(m.id, { source: "local", isLora: false, isDownloaded: true });
|
||||
}
|
||||
}}
|
||||
vramStatus={null}
|
||||
/>
|
||||
{expandedGguf === m.id && (
|
||||
<GgufVariantExpander
|
||||
repoId={m.id}
|
||||
onSelect={onSelect}
|
||||
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
|
||||
systemRamGb={gpu.available ? gpu.systemRamAvailableGb : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!showHfSection && cachedReady ? (
|
||||
<>
|
||||
<ListLabel>{"\uD83E\uDDA5"} Recommended</ListLabel>
|
||||
|
|
@ -837,6 +901,8 @@ export function LoraModelPicker({
|
|||
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [expandedGguf, setExpandedGguf] = useState<string | null>(null);
|
||||
const gpu = useGpuInfo();
|
||||
|
||||
const normalized = useMemo(
|
||||
() =>
|
||||
|
|
@ -846,11 +912,17 @@ export function LoraModelPicker({
|
|||
baseModel: model.baseModel || model.description || "Unknown base model",
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const baseCmp = a.baseModel.localeCompare(b.baseModel);
|
||||
if (baseCmp !== 0) return baseCmp;
|
||||
// Prioritize unsloth publisher within LM Studio group
|
||||
if (a.baseModel === "LM Studio" && b.baseModel === "LM Studio") {
|
||||
const aUnsloth = a.name.startsWith("unsloth/") ? 0 : 1;
|
||||
const bUnsloth = b.name.startsWith("unsloth/") ? 0 : 1;
|
||||
if (aUnsloth !== bUnsloth) return aUnsloth - bUnsloth;
|
||||
}
|
||||
const aTime = a.updatedAt ?? -1;
|
||||
const bTime = b.updatedAt ?? -1;
|
||||
if (aTime !== bTime) return bTime - aTime;
|
||||
const baseCmp = a.baseModel.localeCompare(b.baseModel);
|
||||
if (baseCmp !== 0) return baseCmp;
|
||||
return a.name.localeCompare(b.name);
|
||||
}),
|
||||
[loraModels],
|
||||
|
|
@ -905,34 +977,53 @@ export function LoraModelPicker({
|
|||
{index > 0 ? <div className="my-1" /> : null}
|
||||
<ListLabel>{baseModel}</ListLabel>
|
||||
{adapters.map((adapter) => {
|
||||
const isLocal = adapter.source === "local";
|
||||
const isExported = adapter.source === "exported";
|
||||
const isMerged = adapter.exportType === "merged";
|
||||
const isGguf = adapter.exportType === "gguf";
|
||||
const tag = isGguf
|
||||
? "GGUF"
|
||||
: isExported
|
||||
? isMerged ? "Merged" : "LoRA"
|
||||
: "LoRA";
|
||||
const meta = isExported ? `${tag} · Exported` : tag;
|
||||
const isLocalGgufDir = isLocal && (isGgufRepo(adapter.id) || isGgufRepo(adapter.name));
|
||||
const tag = isLocal
|
||||
? isLocalGgufDir ? "GGUF" : "Local"
|
||||
: isGguf
|
||||
? "GGUF"
|
||||
: isExported
|
||||
? isMerged ? "Merged" : "LoRA"
|
||||
: "LoRA";
|
||||
const meta = isLocal ? (isLocalGgufDir ? "GGUF" : "Local") : isExported ? `${tag} · Exported` : tag;
|
||||
return (
|
||||
<ModelRow
|
||||
key={adapter.id}
|
||||
label={adapter.name}
|
||||
meta={meta}
|
||||
selected={value === adapter.id}
|
||||
onClick={() => onSelect(adapter.id, {
|
||||
source: isExported ? "exported" : "lora",
|
||||
isLora: !isMerged && !isGguf,
|
||||
})}
|
||||
tooltipText={
|
||||
<>
|
||||
<span className="block break-words">{adapter.name}</span>
|
||||
<span className="block mt-1 text-[10px] text-muted-foreground break-all">
|
||||
{adapter.id}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div key={adapter.id}>
|
||||
<ModelRow
|
||||
label={adapter.name}
|
||||
meta={meta}
|
||||
selected={value === adapter.id}
|
||||
onClick={() => {
|
||||
if (isLocalGgufDir) {
|
||||
setExpandedGguf((prev) => (prev === adapter.id ? null : adapter.id));
|
||||
} else {
|
||||
onSelect(adapter.id, {
|
||||
source: isLocal ? "local" : isExported ? "exported" : "lora",
|
||||
isLora: !isLocal && !isMerged && !isGguf,
|
||||
});
|
||||
}
|
||||
}}
|
||||
tooltipText={
|
||||
<>
|
||||
<span className="block break-words">{adapter.name}</span>
|
||||
<span className="block mt-1 text-[10px] text-muted-foreground break-all">
|
||||
{adapter.id}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{expandedGguf === adapter.id && (
|
||||
<GgufVariantExpander
|
||||
repoId={adapter.id}
|
||||
onSelect={onSelect}
|
||||
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
|
||||
systemRamGb={gpu.available ? gpu.systemRamAvailableGb : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ export interface ModelOption {
|
|||
export interface LoraModelOption extends ModelOption {
|
||||
baseModel?: string;
|
||||
updatedAt?: number;
|
||||
source?: "training" | "exported";
|
||||
source?: "training" | "exported" | "local";
|
||||
exportType?: "lora" | "merged" | "gguf";
|
||||
}
|
||||
|
||||
export interface ModelSelectorChangeMeta {
|
||||
source: "hub" | "lora" | "exported";
|
||||
source: "hub" | "lora" | "exported" | "local";
|
||||
isLora: boolean;
|
||||
ggufVariant?: string;
|
||||
isDownloaded?: boolean;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ArrowRight01Icon,
|
||||
Cancel01Icon,
|
||||
Book03Icon,
|
||||
BubbleChatIcon,
|
||||
ChefHatIcon,
|
||||
|
|
@ -29,8 +30,9 @@ import { useTrainingRuntimeStore } from "@/features/training";
|
|||
import { usePlatformStore } from "@/config/env";
|
||||
import { Link, useRouterState } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { TOUR_OPEN_EVENT } from "@/features/tour";
|
||||
import { ShutdownDialog } from "@/components/shutdown-dialog";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ label: "Studio", href: "/studio", icon: ZapIcon, enabled: true },
|
||||
|
|
@ -50,9 +52,35 @@ export function Navbar() {
|
|||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
const isTrainingRunning = useTrainingRuntimeStore((s) => s.isTrainingRunning);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [shutdownOpen, setShutdownOpen] = useState(false);
|
||||
|
||||
const chatOnly = usePlatformStore((s) => s.isChatOnly());
|
||||
|
||||
// Warn before closing the tab only when training is running (data loss risk).
|
||||
// We store the handler in a ref so removeUnloadHandler() can clean it up
|
||||
// before the "Server stopped" page renders.
|
||||
const unloadHandlerRef = useRef<((e: BeforeUnloadEvent) => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (!useTrainingRuntimeStore.getState().isTrainingRunning) return;
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
};
|
||||
unloadHandlerRef.current = handler;
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const removeUnloadHandler = () => {
|
||||
if (unloadHandlerRef.current) {
|
||||
window.removeEventListener("beforeunload", unloadHandlerRef.current);
|
||||
unloadHandlerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const tourId = getTourId(pathname);
|
||||
|
||||
const openTour = () => {
|
||||
|
|
@ -63,6 +91,7 @@ export function Navbar() {
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="relative top-0 z-40 h-16 w-full">
|
||||
<div className="mx-auto grid h-full max-w-7xl grid-cols-[1fr_auto_1fr] items-center px-4 sm:px-6">
|
||||
{/* Left: logo */}
|
||||
|
|
@ -206,6 +235,16 @@ export function Navbar() {
|
|||
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
|
||||
<span className="text-sm font-medium">Tour</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShutdownOpen(true)}
|
||||
className="-mr-1.5 flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Shut down Unsloth Studio server"
|
||||
aria-label="Shut down Unsloth Studio server"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right: mobile */}
|
||||
|
|
@ -292,6 +331,17 @@ export function Navbar() {
|
|||
Start tour
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-left text-sm font-medium text-foreground hover:bg-accent"
|
||||
onClick={() => {
|
||||
setMobileOpen(false);
|
||||
setShutdownOpen(true);
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-5" />
|
||||
Quit Unsloth Studio
|
||||
</button>
|
||||
<div className="mt-2 flex items-center justify-between rounded-md border border-border px-3 py-2">
|
||||
<span className="text-sm font-medium text-foreground">Theme</span>
|
||||
<AnimatedThemeToggler
|
||||
|
|
@ -306,5 +356,12 @@ export function Navbar() {
|
|||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ShutdownDialog
|
||||
open={shutdownOpen}
|
||||
onOpenChange={setShutdownOpen}
|
||||
onBeforeShutdown={removeUnloadHandler}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
84
studio/frontend/src/components/shutdown-dialog.tsx
Normal file
84
studio/frontend/src/components/shutdown-dialog.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// 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 { toastError } from "@/shared/toast";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface ShutdownDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Called right before the shutdown API request so callers can remove the
|
||||
* beforeunload listener — otherwise the "Server stopped" page would still
|
||||
* trigger a "Leave site?" prompt when the user tries to close it. */
|
||||
onBeforeShutdown?: () => void;
|
||||
}
|
||||
|
||||
export function ShutdownDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onBeforeShutdown,
|
||||
}: ShutdownDialogProps) {
|
||||
const [stopping, setStopping] = useState(false);
|
||||
|
||||
const handleStop = async () => {
|
||||
setStopping(true);
|
||||
let accepted = false;
|
||||
try {
|
||||
const res = await authFetch("/api/shutdown", { method: "POST" });
|
||||
accepted = res.ok;
|
||||
if (!accepted) {
|
||||
toastError("Failed to shut down server");
|
||||
setStopping(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Network error — shutdown request never reached the server
|
||||
toastError("Could not reach server");
|
||||
setStopping(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onBeforeShutdown?.();
|
||||
document.body.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;gap:12px">
|
||||
<p style="font-size:1.1rem;font-weight:600;margin:0">Unsloth Studio has stopped.</p>
|
||||
<p style="font-size:0.9rem;color:#888;margin:0">You can now close this tab.</p>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Stop Unsloth Studio?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will shut down the server. Any active training or inference
|
||||
jobs will be terminated. You can restart it any time from the
|
||||
desktop shortcut.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleStop}
|
||||
disabled={stopping}
|
||||
variant="destructive"
|
||||
>
|
||||
{stopping ? "Stopping…" : "Stop server"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -307,6 +307,7 @@ async function autoLoadSmallestModel(): Promise<boolean> {
|
|||
useChatRuntimeStore.setState({
|
||||
ggufContextLength: loadResp.context_length ?? 131072,
|
||||
supportsReasoning: loadResp.supports_reasoning ?? false,
|
||||
reasoningAlwaysOn: loadResp.reasoning_always_on ?? false,
|
||||
reasoningEnabled: loadResp.supports_reasoning ?? false,
|
||||
supportsTools: loadResp.supports_tools ?? false,
|
||||
toolsEnabled: loadResp.supports_tools ?? false,
|
||||
|
|
@ -392,6 +393,7 @@ async function autoLoadSmallestModel(): Promise<boolean> {
|
|||
useChatRuntimeStore.setState({
|
||||
ggufContextLength: loadResp.context_length ?? 131072,
|
||||
supportsReasoning: loadResp.supports_reasoning ?? false,
|
||||
reasoningAlwaysOn: loadResp.reasoning_always_on ?? false,
|
||||
reasoningEnabled: loadResp.supports_reasoning ?? false,
|
||||
supportsTools: loadResp.supports_tools ?? false,
|
||||
toolsEnabled: loadResp.supports_tools ?? false,
|
||||
|
|
|
|||
|
|
@ -125,6 +125,27 @@ export async function getDownloadProgress(
|
|||
return parseJsonOrThrow(response);
|
||||
}
|
||||
|
||||
export interface LocalModelInfo {
|
||||
id: string;
|
||||
display_name: string;
|
||||
path: string;
|
||||
source: "models_dir" | "hf_cache" | "lmstudio";
|
||||
model_id?: string | null;
|
||||
updated_at?: number | null;
|
||||
}
|
||||
|
||||
interface LocalModelListResponse {
|
||||
models_dir: string;
|
||||
hf_cache_dir?: string | null;
|
||||
lmstudio_dirs: string[];
|
||||
models: LocalModelInfo[];
|
||||
}
|
||||
|
||||
export async function listLocalModels(): Promise<LocalModelListResponse> {
|
||||
const response = await authFetch("/api/models/local");
|
||||
return parseJsonOrThrow<LocalModelListResponse>(response);
|
||||
}
|
||||
|
||||
export async function listCachedGguf(): Promise<CachedGgufRepo[]> {
|
||||
const response = await authFetch("/api/models/cached-gguf");
|
||||
const data = await parseJsonOrThrow<{ cached: CachedGgufRepo[] }>(response);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import {
|
|||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
||||
import { listLocalModels } from "./api/chat-api";
|
||||
import { ChatSettingsPanel } from "./chat-settings-sheet";
|
||||
import { ContextUsageBar } from "./components/context-usage-bar";
|
||||
import { ModelLoadInlineStatus } from "./components/model-load-status";
|
||||
|
|
@ -578,22 +579,36 @@ export function ChatPage(): ReactElement {
|
|||
[modelsFromStore],
|
||||
);
|
||||
|
||||
const loraModels = useMemo<LoraModelOption[]>(
|
||||
() =>
|
||||
lorasFromStore.map((lora) => ({
|
||||
id: lora.id,
|
||||
name: lora.name,
|
||||
baseModel: lora.baseModel,
|
||||
updatedAt: lora.updatedAt,
|
||||
source: lora.source,
|
||||
exportType: lora.exportType,
|
||||
})),
|
||||
[lorasFromStore],
|
||||
);
|
||||
const [localModels, setLocalModels] = useState<LoraModelOption[]>([]);
|
||||
|
||||
const loraModels = useMemo<LoraModelOption[]>(() => {
|
||||
const fromLoras = lorasFromStore.map((lora) => ({
|
||||
id: lora.id,
|
||||
name: lora.name,
|
||||
baseModel: lora.baseModel,
|
||||
updatedAt: lora.updatedAt,
|
||||
source: lora.source,
|
||||
exportType: lora.exportType,
|
||||
}));
|
||||
return [...fromLoras, ...localModels];
|
||||
}, [lorasFromStore, localModels]);
|
||||
|
||||
useEffect(() => {
|
||||
if (getTrainingCompareHandoff()) return;
|
||||
void refresh();
|
||||
void listLocalModels().then((res) => {
|
||||
setLocalModels(
|
||||
res.models
|
||||
.filter((m) => m.source === "lmstudio" || m.source === "models_dir")
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.source === "lmstudio" && m.model_id ? m.model_id : m.display_name,
|
||||
baseModel: m.source === "lmstudio" ? "LM Studio" : "Local models",
|
||||
updatedAt: m.updated_at ?? undefined,
|
||||
source: "local" as const,
|
||||
})),
|
||||
);
|
||||
}).catch(() => {});
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -238,9 +238,11 @@ export function useChatModelRuntime() {
|
|||
|
||||
// Restore reasoning/tools support flags and context length
|
||||
const supportsReasoning = statusRes.supports_reasoning ?? false;
|
||||
const reasoningAlwaysOn = statusRes.reasoning_always_on ?? false;
|
||||
const supportsTools = statusRes.supports_tools ?? false;
|
||||
useChatRuntimeStore.setState({
|
||||
supportsReasoning,
|
||||
reasoningAlwaysOn,
|
||||
supportsTools,
|
||||
ggufContextLength: statusRes.is_gguf ? (statusRes.context_length ?? null) : null,
|
||||
});
|
||||
|
|
@ -420,10 +422,12 @@ export function useChatModelRuntime() {
|
|||
&& customContextLength !== nativeCtx
|
||||
? customContextLength
|
||||
: null;
|
||||
const reasoningAlwaysOn = loadResponse.reasoning_always_on ?? false;
|
||||
useChatRuntimeStore.setState({
|
||||
ggufContextLength: nativeCtx,
|
||||
supportsReasoning: loadResponse.supports_reasoning ?? false,
|
||||
reasoningEnabled: reasoningDefault,
|
||||
reasoningAlwaysOn,
|
||||
reasoningEnabled: reasoningAlwaysOn ? true : reasoningDefault,
|
||||
supportsTools: loadResponse.supports_tools ?? false,
|
||||
toolsEnabled: loadResponse.supports_tools ?? false,
|
||||
codeToolsEnabled: loadResponse.supports_tools ?? false,
|
||||
|
|
|
|||
|
|
@ -241,6 +241,7 @@ export function SharedComposer({
|
|||
(s) => !!s.params.checkpoint && !s.modelLoading,
|
||||
);
|
||||
const supportsReasoning = useChatRuntimeStore((s) => s.supportsReasoning);
|
||||
const reasoningAlwaysOn = useChatRuntimeStore((s) => s.reasoningAlwaysOn);
|
||||
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
|
||||
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
|
||||
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
|
||||
|
|
@ -528,6 +529,7 @@ export function SharedComposer({
|
|||
type="button"
|
||||
disabled={reasoningDisabled}
|
||||
onClick={() => {
|
||||
if (reasoningAlwaysOn) return;
|
||||
const next = !reasoningEnabled;
|
||||
setReasoningEnabled(next);
|
||||
// Qwen3/3.5: adjust params for thinking on/off
|
||||
|
|
@ -544,13 +546,13 @@ export function SharedComposer({
|
|||
"flex items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-medium transition-colors",
|
||||
reasoningDisabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: reasoningEnabled
|
||||
: (reasoningEnabled || reasoningAlwaysOn)
|
||||
? "bg-primary/10 text-primary hover:bg-primary/20"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
|
||||
)}
|
||||
aria-label={reasoningEnabled ? "Disable thinking" : "Enable thinking"}
|
||||
>
|
||||
{reasoningEnabled && !reasoningDisabled ? (
|
||||
{(reasoningEnabled || reasoningAlwaysOn) && !reasoningDisabled ? (
|
||||
<LightbulbIcon className="size-3" />
|
||||
) : (
|
||||
<LightbulbOffIcon className="size-3" />
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ type ChatRuntimeStore = {
|
|||
activeGgufVariant: string | null;
|
||||
ggufContextLength: number | null;
|
||||
supportsReasoning: boolean;
|
||||
reasoningAlwaysOn: boolean;
|
||||
reasoningEnabled: boolean;
|
||||
supportsTools: boolean;
|
||||
toolsEnabled: boolean;
|
||||
|
|
@ -213,6 +214,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
|
|||
activeGgufVariant: null,
|
||||
ggufContextLength: null,
|
||||
supportsReasoning: false,
|
||||
reasoningAlwaysOn: false,
|
||||
reasoningEnabled: true,
|
||||
supportsTools: false,
|
||||
toolsEnabled: false,
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export interface LoadModelResponse {
|
|||
};
|
||||
context_length?: number | null;
|
||||
supports_reasoning?: boolean;
|
||||
reasoning_always_on?: boolean;
|
||||
supports_tools?: boolean;
|
||||
cache_type_kv?: string | null;
|
||||
chat_template?: string | null;
|
||||
|
|
@ -115,6 +116,7 @@ export interface InferenceStatusResponse {
|
|||
trust_remote_code?: boolean;
|
||||
};
|
||||
supports_reasoning?: boolean;
|
||||
reasoning_always_on?: boolean;
|
||||
supports_tools?: boolean;
|
||||
context_length?: number | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,13 @@ import { HugeiconsIcon } from "@hugeicons/react";
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
/** Extract param count label from model name (e.g. "Qwen3-0.6B" -> "0.6B"). */
|
||||
function extractParamLabel(id: string): string | null {
|
||||
const name = id.split("/").pop() ?? id;
|
||||
const match = name.match(/(?:^|[-_])(\d+(?:\.\d+)?)[Bb](?:[-_]|$)/);
|
||||
return match ? `${match[1]}B` : null;
|
||||
}
|
||||
|
||||
export function ModelSelectionStep() {
|
||||
const gpu = useGpuInfo();
|
||||
const {
|
||||
|
|
@ -119,7 +126,7 @@ export function ModelSelectionStep() {
|
|||
const fit = fitMap.get(r.id);
|
||||
map.set(r.id, {
|
||||
status: fit?.status ?? null,
|
||||
detail: r.totalParams ? formatCompact(r.totalParams) : null,
|
||||
detail: r.totalParams ? formatCompact(r.totalParams) : extractParamLabel(r.id),
|
||||
});
|
||||
}
|
||||
return map;
|
||||
|
|
|
|||
|
|
@ -72,6 +72,13 @@ const DARK_CONTENT =
|
|||
const DARK_COMBOBOX_CONTENT =
|
||||
"bg-foreground text-background shadow-xl border-background/10 dark:[--accent:rgba(2,6,23,0.08)] dark:[--accent-foreground:rgb(2,6,23)] dark:[&_[data-slot=combobox-item]]:text-slate-900 dark:[&_.text-muted-foreground]:text-slate-500";
|
||||
|
||||
/** Extract param count label from model name (e.g. "Qwen3-0.6B" -> "0.6B"). */
|
||||
function extractParamLabel(id: string): string | null {
|
||||
const name = id.split("/").pop() ?? id;
|
||||
const match = name.match(/(?:^|[-_])(\d+(?:\.\d+)?)[Bb](?:[-_]|$)/);
|
||||
return match ? `${match[1]}B` : null;
|
||||
}
|
||||
|
||||
export function ModelSection() {
|
||||
const gpu = useGpuInfo();
|
||||
|
||||
|
|
@ -233,7 +240,7 @@ export function ModelSection() {
|
|||
{ est: number; status: VramFitStatus | null; detail: string | null }
|
||||
>();
|
||||
for (const r of hfResults) {
|
||||
const detail = r.totalParams ? formatCompact(r.totalParams) : null;
|
||||
const detail = r.totalParams ? formatCompact(r.totalParams) : extractParamLabel(r.id);
|
||||
const fit = fitMap.get(r.id);
|
||||
map.set(r.id, {
|
||||
est: fit?.est ?? 0,
|
||||
|
|
|
|||
|
|
@ -462,25 +462,27 @@ def install_python_stack() -> int:
|
|||
if skip_base:
|
||||
print(_green(f"✅ {package_name} already installed — skipping base packages"))
|
||||
elif NO_TORCH:
|
||||
# No-torch mode: install runtime deps via [huggingfacenotorch] extras
|
||||
# (safetensors, transformers, datasets, etc.), then unsloth-zoo with
|
||||
# --no-deps to avoid pulling torch.
|
||||
# No-torch update path: install unsloth + unsloth-zoo with --no-deps
|
||||
# (current PyPI metadata still declares torch as a hard dep), then
|
||||
# runtime deps with --no-deps (avoids transitive torch).
|
||||
_progress("base packages (no torch)")
|
||||
pip_install(
|
||||
"Installing unsloth runtime deps (no-torch mode)",
|
||||
"--no-cache-dir",
|
||||
"--upgrade-package",
|
||||
"unsloth",
|
||||
"unsloth[huggingfacenotorch]>=2026.3.14",
|
||||
)
|
||||
pip_install(
|
||||
"Installing unsloth-zoo (no-torch mode)",
|
||||
f"Updating {package_name} + unsloth-zoo (no-torch mode)",
|
||||
"--no-cache-dir",
|
||||
"--no-deps",
|
||||
"--upgrade-package",
|
||||
package_name,
|
||||
"--upgrade-package",
|
||||
"unsloth-zoo",
|
||||
package_name,
|
||||
"unsloth-zoo",
|
||||
)
|
||||
pip_install(
|
||||
"Installing no-torch runtime deps",
|
||||
"--no-cache-dir",
|
||||
"--no-deps",
|
||||
req = REQ_ROOT / "no-torch-runtime.txt",
|
||||
)
|
||||
if local_repo:
|
||||
pip_install(
|
||||
"Overlaying local repo (editable)",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue