Merge nightly into feature/colab-notebook - resolved setup.sh conflicts

This commit is contained in:
Leo Borcherding 2026-02-17 15:51:14 -06:00
commit 84b9a8aef6
124 changed files with 3875 additions and 1487 deletions

1
.gitignore vendored
View file

@ -18,6 +18,7 @@ unsloth_compiled_cache/
# ML artifacts (large files)
outputs/
exports/
*.gguf
*.safetensors

View file

@ -153,23 +153,54 @@ if [ "$IS_COLAB" = true ]; then
# Colab: install packages directly without venv
run_quiet "pip upgrade" pip install --upgrade pip
echo " Installing unsloth-zoo + unsloth..."
run_quiet "pip install unsloth" pip install unsloth-zoo unsloth
run_quiet "pip install unsloth" pip install -r "$SCRIPT_DIR/studio/backend/requirements/base.txt"
echo " Installing additional unsloth dependencies..."
run_quiet "pip install extras" pip install --no-cache-dir -r "$SCRIPT_DIR/studio/backend/requirements/extras.txt"
run_quiet "pip install extras" pip install --no-deps --no-cache-dir -r "$SCRIPT_DIR/studio/backend/requirements/extras-no-deps.txt"
run_quiet "pip install torchao+transformers" pip install --force-reinstall --no-cache-dir -r "$SCRIPT_DIR/studio/backend/requirements/overrides.txt"
run_quiet "pip install triton_kernels" pip install --no-deps -r "$SCRIPT_DIR/studio/backend/requirements/triton-kernels.txt"
# Patch: override llama_cpp.py with fix from unsloth-zoo branch
LLAMA_CPP_DST="$(pip show unsloth-zoo | grep -i '^Location:' | awk '{print $2}')/unsloth_zoo/llama_cpp.py"
curl -sSL "https://raw.githubusercontent.com/unslothai/unsloth-zoo/refs/heads/main/unsloth_zoo/llama_cpp.py" \
-o "$LLAMA_CPP_DST"
echo " Installing studio dependencies..."
run_quiet "pip install extras" pip install typer fastapi uvicorn pydantic matplotlib pandas nest_asyncio "datasets==4.3.0" pyjwt easydict addict
run_quiet "pip install studio" pip install -r "$SCRIPT_DIR/studio/backend/requirements/studio.txt"
echo "✅ Python dependencies installed"
else
# Local: create venv
# Local: create venv (always start fresh to preserve correct install order)
rm -rf .venv
"$BEST_PY" -m venv .venv
source .venv/bin/activate
run_quiet "pip upgrade" pip install --upgrade pip
echo " Installing unsloth-zoo + unsloth..."
run_quiet "pip install unsloth" pip install unsloth-zoo unsloth
run_quiet "pip install unsloth" pip install -r "$SCRIPT_DIR/studio/backend/requirements/base.txt"
echo " Installing additional unsloth dependencies..."
run_quiet "pip install extras" pip install --no-cache-dir -r "$SCRIPT_DIR/studio/backend/requirements/extras.txt"
run_quiet "pip install extras" pip install --no-deps --no-cache-dir -r "$SCRIPT_DIR/studio/backend/requirements/extras-no-deps.txt"
run_quiet "pip install torchao+transformers" pip install --force-reinstall --no-cache-dir -r "$SCRIPT_DIR/studio/backend/requirements/overrides.txt"
run_quiet "pip install triton_kernels" pip install --no-deps -r "$SCRIPT_DIR/studio/backend/requirements/triton-kernels.txt"
# Patch: override llama_cpp.py with fix from unsloth-zoo branch
LLAMA_CPP_DST="$(pip show unsloth-zoo | grep -i '^Location:' | awk '{print $2}')/unsloth_zoo/llama_cpp.py"
curl -sSL "https://raw.githubusercontent.com/unslothai/unsloth-zoo/refs/heads/main/unsloth_zoo/llama_cpp.py" \
-o "$LLAMA_CPP_DST"
echo " Installing studio dependencies..."
run_quiet "pip install extras" pip install typer fastapi uvicorn pydantic matplotlib pandas nest_asyncio "datasets==4.3.0" pyjwt easydict addict
run_quiet "pip install studio" pip install -r "$SCRIPT_DIR/studio/backend/requirements/studio.txt"
echo "✅ Python dependencies installed"
# ── 7. WSL: pre-install GGUF build dependencies ──
# On WSL, sudo requires a password and can't be entered during GGUF export
# (runs in a non-interactive subprocess). Install build deps here instead.
if grep -qi microsoft /proc/version 2>/dev/null; then
echo ""
echo "⚠️ WSL detected — installing build dependencies for GGUF export..."
echo " You may be prompted for your password."
sudo apt-get update -y
sudo apt-get install -y build-essential cmake curl git libcurl4-openssl-dev
echo "✅ GGUF build dependencies installed"
fi
fi
# ── 7. Add shell alias (skip in Colab) ──
# ── 8. Add shell alias (skip in Colab) ──
# Note: venv activation does NOT persist across terminal sessions.
# This alias hardcodes the venv python path so users don't need to activate.
if [ "$IS_COLAB" = false ]; then

View file

@ -3,13 +3,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 5e-5
batch_size: 2
gradient_accumulation_steps: 4
warmup_ratio: 0.1
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.01
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 4
gradient_accumulation_steps: 2
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 2
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 8
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.01
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 4096
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 1
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 4096
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 4
gradient_accumulation_steps: 2
warmup_steps: 10
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -3,13 +3,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.01
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 5e-5
batch_size: 4
gradient_accumulation_steps: 1
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 2
# num_epochs: 2
num_epochs: 0
learning_rate: 2e-4
batch_size: 1
gradient_accumulation_steps: 4
warmup_ratio: 0.03
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 1024
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 1
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 2
# num_epochs: 2
num_epochs: 0
learning_rate: 2e-4
batch_size: 1
gradient_accumulation_steps: 4
warmup_ratio: 0.03
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 4096
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 4
gradient_accumulation_steps: 1
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 1024
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 1
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 2048
num_epochs: 5
# num_epochs: 5
num_epochs: 0
learning_rate: 2e-5
batch_size: 1
gradient_accumulation_steps: 8
warmup_steps: 0
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.01
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 8192
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 5e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 2
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 4
gradient_accumulation_steps: 2
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 1
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 1
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -3,13 +3,14 @@
# Also applies to: "unsloth/mistral-7b-v0.3", "mistralai/Mistral-7B-v0.3",
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 4
gradient_accumulation_steps: 2
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 5e-5
batch_size: 4
gradient_accumulation_steps: 2
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -3,13 +3,14 @@
training:
max_seq_length: 2048
num_epochs: 1
# num_epochs: 1
num_epochs: 0
learning_rate: 5e-5
batch_size: 32
gradient_accumulation_steps: 1
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 1
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 4096
num_epochs: 1
# num_epochs: 1
num_epochs: 0
learning_rate: 2e-5
batch_size: 2
gradient_accumulation_steps: 4
warmup_ratio: 0.1
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.1
random_seed: 3407
packing: true

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 448
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 1e-4
batch_size: 1
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 4096
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 1e-5
batch_size: 1
gradient_accumulation_steps: 64
warmup_ratio: 0.1
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 42
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 1
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -4,13 +4,14 @@
training:
max_seq_length: 32768
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 1024
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 5e-5
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -5,13 +5,14 @@
training:
max_seq_length: 2048
num_epochs: 4
# num_epochs: 4
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 0
save_steps: 0
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false

View file

@ -18,6 +18,45 @@ from core.inference import get_inference_backend
logger = logging.getLogger(__name__)
def _is_wsl():
"""Detect if running under Windows Subsystem for Linux."""
try:
return "microsoft" in open("/proc/version").read().lower()
except Exception:
return False
def _apply_wsl_sudo_patch():
"""On WSL, monkey-patch do_we_need_sudo() to return False.
WSL doesn't have passwordless sudo, and do_we_need_sudo() runs
`sudo apt-get update` which hangs waiting for a stdin password
inside a non-interactive subprocess. setup.sh pre-installs the
build dependencies on WSL, so sudo is not needed at runtime.
"""
if not _is_wsl():
return
try:
import unsloth_zoo.llama_cpp as llama_cpp_module
def _wsl_do_we_need_sudo(system_type="debian"):
logger.info(
"WSL detected — skipping sudo check "
"(build deps pre-installed by setup.sh)"
)
return False
llama_cpp_module.do_we_need_sudo = _wsl_do_we_need_sudo
logger.info(
"Applied WSL sudo patch to "
"unsloth_zoo.llama_cpp.do_we_need_sudo"
)
except Exception as e:
logger.warning(f"Could not apply WSL sudo patch: {e}")
# Model card template
MODEL_CARD = \
"""---
@ -236,7 +275,8 @@ class ExportBackend:
push_to_hub: bool = False,
repo_id: Optional[str] = None,
hf_token: Optional[str] = None,
private: bool = False) -> Tuple[bool, str]:
private: bool = False,
base_model_id: Optional[str] = None) -> Tuple[bool, str]:
"""
Export base model (for non-PEFT models).
@ -266,8 +306,8 @@ class ExportBackend:
logger.info(f"Pushing base model to Hub: {repo_id}")
# Get base model name
base_model = self.current_model.config._name_or_path
# Get base model name from request or model config
base_model = base_model_id or self.current_model.config._name_or_path or "unknown"
# Create repo
hf_api = HfApi(token=hf_token)
@ -352,6 +392,9 @@ class ExportBackend:
os.chdir(save_directory)
logger.info(f"Changed directory to: {save_directory}")
# On WSL, patch out sudo check before llama.cpp build
_apply_wsl_sudo_patch()
# Now save (will save in current directory)
self.current_model.save_pretrained_gguf(
"model", # Base filename

View file

@ -4,6 +4,7 @@ Training backend for FastAPI integration
import matplotlib.pyplot as plt
from typing import Any, Generator, Tuple
import logging
import math
from .trainer import get_trainer, TrainingProgress
from utils.hardware import clear_gpu_cache
@ -28,6 +29,8 @@ class TrainingBackend:
self.loss_history = []
self.lr_history = []
self.step_history = []
self.grad_norm_history = []
self.grad_norm_step_history = []
self.eval_loss_history = []
self.eval_step_history = []
self.eval_enabled = False
@ -43,6 +46,14 @@ class TrainingBackend:
self.loss_history.append(progress.loss)
self.lr_history.append(progress.learning_rate)
self.step_history.append(progress.step)
if progress.step >= 0 and progress.grad_norm is not None:
try:
grad_norm = float(progress.grad_norm)
except (TypeError, ValueError):
grad_norm = None
if grad_norm is not None and math.isfinite(grad_norm):
self.grad_norm_history.append(grad_norm)
self.grad_norm_step_history.append(progress.step)
if progress.eval_loss is not None:
self.eval_loss_history.append(progress.eval_loss)
self.eval_step_history.append(progress.step)
@ -144,6 +155,8 @@ class TrainingBackend:
self.loss_history = []
self.lr_history = []
self.step_history = []
self.grad_norm_history = []
self.grad_norm_step_history = []
self.eval_loss_history = []
self.eval_step_history = []
self.eval_enabled = False

View file

@ -12,6 +12,8 @@ from .models import (
ModelCheckpoints,
CheckpointListResponse,
ModelDetails,
LocalModelInfo,
LocalModelListResponse,
LoRAInfo,
LoRAScanResponse,
ModelListResponse,
@ -59,6 +61,8 @@ __all__ = [
"TrainingProgress",
# Model management schemas
"ModelDetails",
"LocalModelInfo",
"LocalModelListResponse",
"LoRAInfo",
"LoRAScanResponse",
"ModelListResponse",

View file

@ -72,6 +72,10 @@ class ExportCommonOptions(BaseModel):
False,
description="If True, create a private repository on the Hub (where applicable)",
)
base_model_id: Optional[str] = Field(
None,
description="HuggingFace model ID of the base model (for model card metadata)",
)
class ExportMergedModelRequest(ExportCommonOptions):

View file

@ -2,7 +2,7 @@
Pydantic schemas for Model Management API
"""
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from typing import Optional, List, Dict, Any, Literal
class CheckpointInfo(BaseModel):
@ -21,6 +21,18 @@ class ModelCheckpoints(BaseModel):
default_factory=list,
description="List of checkpoints for this training run (final + intermediate)",
)
base_model: Optional[str] = Field(
None,
description="Base model name from adapter_config.json or config.json",
)
peft_type: Optional[str] = Field(
None,
description="PEFT type (e.g. LORA) if adapter training, None for full fine-tune",
)
lora_rank: Optional[int] = Field(
None,
description="LoRA rank (r) if applicable",
)
class CheckpointListResponse(BaseModel):
@ -62,3 +74,34 @@ class ModelListResponse(BaseModel):
models: List[ModelDetails] = Field(default_factory=list, description="List of models")
default_models: List[str] = Field(default_factory=list, description="List of default model IDs")
class LocalModelInfo(BaseModel):
"""Discovered local model candidate."""
id: str = Field(..., description="Identifier to use for loading/training")
display_name: str = Field(..., description="Display label")
path: str = Field(..., description="Local path where model data was discovered")
source: Literal["models_dir", "hf_cache"] = Field(
...,
description="Discovery source",
)
model_id: Optional[str] = Field(
None,
description="HF repo id for cached models, e.g. org/model",
)
updated_at: Optional[float] = Field(
None,
description="Unix timestamp of latest observed update",
)
class LocalModelListResponse(BaseModel):
"""Response schema for listing local/cached models."""
models_dir: str = Field(..., description="Directory scanned for custom local models")
hf_cache_dir: Optional[str] = Field(
None,
description="HF cache root that was scanned",
)
models: List[LocalModelInfo] = Field(
default_factory=list,
description="Discovered local/cached models",
)

View file

@ -19,6 +19,8 @@ class TrainingMetricsResponse(BaseModel):
loss_history: List[float] = Field(default_factory=list, description="Loss values per step")
lr_history: List[float] = Field(default_factory=list, description="Learning rate per step")
step_history: List[int] = Field(default_factory=list, description="Step numbers")
grad_norm_history: List[float] = Field(default_factory=list, description="Gradient norm values")
grad_norm_step_history: List[int] = Field(default_factory=list, description="Step numbers for gradient norm values")
current_loss: Optional[float] = Field(None, description="Most recent loss value")
current_lr: Optional[float] = Field(None, description="Most recent learning rate")
current_step: Optional[int] = Field(None, description="Most recent step number")

View file

@ -104,7 +104,7 @@ class TrainingStatus(BaseModel):
metric_history: Optional[dict] = Field(
None,
description="Full metric history arrays for chart recovery after SSE reconnection. "
"Keys: 'steps', 'loss', 'lr' — each a list of numeric values.",
"Keys: 'steps', 'loss', 'lr', 'grad_norm', 'grad_norm_steps' — each a list of numeric values.",
)
@ -122,4 +122,3 @@ class TrainingProgress(BaseModel):
grad_norm: Optional[float] = Field(None, description="L2 norm of gradients, computed before gradient clipping")
num_tokens: Optional[int] = Field(None, description="Total number of tokens processed so far")
eval_loss: Optional[float] = Field(None, description="Eval loss from the most recent evaluation step")

View file

@ -1,7 +0,0 @@
fastapi>=0.100.0
uvicorn>=0.27.0
pydantic>=2.0
torch
psutil
nest-asyncio>=1.5.8

View file

@ -0,0 +1,3 @@
# Core unsloth packages
unsloth-zoo
unsloth

View file

@ -0,0 +1,13 @@
# Audio extras (installed with --no-deps --no-cache-dir)
descript-audio-codec
descript-audiotools
julius
torchcodec
snac
# TRL and related packages
trl==0.23.1
git+https://github.com/meta-pytorch/OpenEnv.git
executorch==1.0.1
torch-c-dlpack-ext
sentence_transformers==5.2.0

View file

@ -0,0 +1,56 @@
# OpenEnv dependencies
tomli
tomli-w
# ExecuTorch dependencies
ruamel.yaml
coremltools
expecttest
flatbuffers
hydra-core
hypothesis
kgb
parameterized
pytest<9.0
pytest-json-report
pytest-rerunfailures==15.1
pytest-xdist
# Also needed by sentence_transformers
scikit-learn==1.7.1
# Additional extras
pybind11
langid
jiwer
omegaconf
einx
pyloudnorm
openai-whisper
uroman
MeCab
loguru
flatten_dict
ffmpy
randomname
argbind
tiktoken
ftfy
importlib-resources
librosa
markdown2
matplotlib
pystoi
soundfile
tensorboard
torch-stoi
evaluate
timm
transformers-cfg
open_spiel
addict
easydict
einops
tabulate
fastmcp>=2.0.0
openai>=2.7.2
websockets>=13.0,<14

View file

@ -0,0 +1,7 @@
# Torch AO overrides (installed with --force-reinstall --no-cache-dir)
torchao==0.14.0
transformers==4.57.1
pytorch_tokenizers
# Kernel packages
kernels

View file

@ -0,0 +1,13 @@
# Studio UI backend dependencies
typer
fastapi
uvicorn
pydantic
matplotlib
pandas
nest_asyncio
datasets==4.3.0
pyjwt
easydict
addict
gradio>=4.0.0

View file

@ -0,0 +1,2 @@
# Triton kernels (installed with --no-deps, from source)
triton_kernels @ git+https://github.com/triton-lang/triton.git@release/3.6.x#subdirectory=python/triton_kernels

View file

@ -190,6 +190,7 @@ async def export_base_model(
repo_id=request.repo_id,
hf_token=request.hf_token,
private=request.private,
base_model_id=request.base_model_id,
)
if not success:

View file

@ -43,6 +43,8 @@ except ImportError:
from models import (
CheckpointInfo,
CheckpointListResponse,
LocalModelInfo,
LocalModelListResponse,
ModelCheckpoints,
ModelDetails,
LoRAScanResponse,
@ -64,6 +66,116 @@ if not logger.handlers:
logger.setLevel(logging.INFO)
def _resolve_hf_cache_dir() -> Path:
"""Resolve local HF cache root used by hub downloads."""
try:
from huggingface_hub.constants import HF_HUB_CACHE
return Path(HF_HUB_CACHE)
except Exception:
return Path.home() / ".cache" / "huggingface" / "hub"
def _scan_models_dir(models_dir: Path) -> List[LocalModelInfo]:
if not models_dir.exists() or not models_dir.is_dir():
return []
found: List[LocalModelInfo] = []
for child in models_dir.iterdir():
if not child.is_dir():
continue
has_model_files = (
(child / "config.json").exists()
or (child / "adapter_config.json").exists()
or any(child.glob("*.safetensors"))
or any(child.glob("*.bin"))
)
if not has_model_files:
continue
try:
updated_at = child.stat().st_mtime
except OSError:
updated_at = None
found.append(
LocalModelInfo(
id=str(child),
display_name=child.name,
path=str(child),
source="models_dir",
updated_at=updated_at,
),
)
return found
def _scan_hf_cache(cache_dir: Path) -> List[LocalModelInfo]:
if not cache_dir.exists() or not cache_dir.is_dir():
return []
found: List[LocalModelInfo] = []
for repo_dir in cache_dir.glob("models--*"):
if not repo_dir.is_dir():
continue
repo_name = repo_dir.name[len("models--"):]
if not repo_name:
continue
model_id = repo_name.replace("--", "/")
try:
updated_at = repo_dir.stat().st_mtime
except OSError:
updated_at = None
found.append(
LocalModelInfo(
id=model_id,
model_id=model_id,
display_name=model_id.split("/")[-1],
path=str(repo_dir),
source="hf_cache",
updated_at=updated_at,
),
)
return found
@router.get("/local", response_model=LocalModelListResponse)
async def list_local_models(
models_dir: str = Query(default="./models", description="Directory to scan for local model folders"),
current_subject: str = Depends(get_current_subject),
):
"""
List local model candidates from custom models dir and HF cache.
"""
try:
models_root = Path(models_dir).expanduser().resolve()
hf_cache_dir = _resolve_hf_cache_dir()
local_models = _scan_models_dir(models_root) + _scan_hf_cache(hf_cache_dir)
deduped: dict[str, LocalModelInfo] = {}
for model in local_models:
if model.id not in deduped:
deduped[model.id] = model
models = sorted(
deduped.values(),
key=lambda item: (item.updated_at or 0),
reverse=True,
)
return LocalModelListResponse(
models_dir=str(models_root),
hf_cache_dir=str(hf_cache_dir),
models=models,
)
except Exception as e:
logger.error(f"Error listing local models: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to list local models: {str(e)}",
)
@router.get("/list")
@ -294,8 +406,11 @@ async def list_checkpoints(
CheckpointInfo(display_name=display_name, path=path, loss=loss)
for display_name, path, loss in checkpoints
],
base_model=metadata.get("base_model"),
peft_type=metadata.get("peft_type"),
lora_rank=metadata.get("lora_rank"),
)
for model_name, checkpoints in raw_models
for model_name, checkpoints, metadata in raw_models
]
return CheckpointListResponse(

View file

@ -325,6 +325,8 @@ async def reset_training(
backend.loss_history = []
backend.lr_history = []
backend.step_history = []
backend.grad_norm_history = []
backend.grad_norm_step_history = []
return {"status": "ok"}
except Exception as e:
logger.error(f"Error resetting training: {e}", exc_info=True)
@ -408,6 +410,8 @@ async def get_training_status(
"steps": list(backend.step_history),
"loss": list(backend.loss_history),
"lr": list(backend.lr_history),
"grad_norm": list(getattr(backend, "grad_norm_history", [])),
"grad_norm_steps": list(getattr(backend, "grad_norm_step_history", [])),
"eval_loss": list(backend.eval_loss_history),
"eval_steps": list(backend.eval_step_history),
}
@ -445,6 +449,8 @@ async def get_training_metrics(
loss_history = backend.loss_history
lr_history = backend.lr_history
step_history = backend.step_history
grad_norm_history = getattr(backend, "grad_norm_history", [])
grad_norm_step_history = getattr(backend, "grad_norm_step_history", [])
# Get current values
current_loss = loss_history[-1] if loss_history else None
@ -455,6 +461,8 @@ async def get_training_metrics(
loss_history=loss_history,
lr_history=lr_history,
step_history=step_history,
grad_norm_history=grad_norm_history,
grad_norm_step_history=grad_norm_step_history,
current_loss=current_loss,
current_lr=current_lr,
current_step=current_step,
@ -505,6 +513,8 @@ async def stream_training_progress(
total_steps: int,
epoch: Optional[float] = None,
progress: Optional[Any] = None,
grad_norm_override: Optional[float] = None,
eval_loss_override: Optional[float] = None,
) -> TrainingProgress:
total = max(total_steps, 0)
if step < 0 or total == 0:
@ -517,9 +527,13 @@ async def stream_training_progress(
# Get actual values from progress object if available
elapsed_seconds = getattr(progress, 'elapsed_seconds', None) if progress else None
eta_seconds = getattr(progress, 'eta_seconds', None) if progress else None
grad_norm = getattr(progress, 'grad_norm', None) if progress else None
grad_norm = grad_norm_override
if grad_norm is None and progress:
grad_norm = getattr(progress, 'grad_norm', None)
num_tokens = getattr(progress, 'num_tokens', None) if progress else None
eval_loss = getattr(progress, 'eval_loss', None) if progress else None
eval_loss = eval_loss_override
if eval_loss is None and progress:
eval_loss = getattr(progress, 'eval_loss', None)
return TrainingProgress(
job_id=job_id,
@ -558,6 +572,13 @@ async def stream_training_progress(
# ── Replay missed steps on reconnect ─────────────────────
if resume_from_step is not None and backend.step_history:
replayed = 0
grad_norm_by_step = {
step_val: grad_val
for step_val, grad_val in zip(
getattr(backend, "grad_norm_step_history", []),
getattr(backend, "grad_norm_history", []),
)
}
for i, step_val in enumerate(backend.step_history):
if step_val > resume_from_step:
loss_val = backend.loss_history[i] if i < len(backend.loss_history) else 0.0
@ -567,7 +588,15 @@ async def stream_training_progress(
)
total_replay = getattr(tp_replay, "total_steps", step_val) if tp_replay else step_val
epoch_replay = getattr(tp_replay, "epoch", None) if tp_replay else None
payload = build_progress(step_val, loss_val, lr_val, total_replay, epoch_replay, progress=tp_replay)
payload = build_progress(
step_val,
loss_val,
lr_val,
total_replay,
epoch_replay,
progress=tp_replay,
grad_norm_override=grad_norm_by_step.get(step_val),
)
yield format_sse(payload.model_dump_json(), event="progress", event_id=step_val)
replayed += 1
if replayed:

View file

@ -31,12 +31,13 @@ def _read_checkpoint_loss(checkpoint_path: Path) -> Optional[float]:
def scan_checkpoints(
outputs_dir: str = "./outputs",
) -> List[Tuple[str, List[Tuple[str, str, Optional[float]]]]]:
) -> List[Tuple[str, List[Tuple[str, str, Optional[float]]], dict]]:
"""
Scan outputs folder for training runs and their checkpoints.
Returns:
List of tuples: [(model_name, [(display_name, checkpoint_path, loss), ...]), ...]
List of tuples: [(model_name, [(display_name, checkpoint_path, loss), ...], metadata), ...]
metadata keys: base_model, peft_type, lora_rank (all optional)
The first entry in each checkpoint list is the main adapter; its loss is
set to the loss of the last (highest-step) intermediate checkpoint.
"""
@ -58,6 +59,32 @@ def scan_checkpoints(
if not (config_file.exists() or adapter_config.exists()):
continue
# Extract training metadata from adapter_config.json / config.json
metadata: dict = {}
try:
if adapter_config.exists():
cfg = json.loads(adapter_config.read_text())
metadata["base_model"] = cfg.get("base_model_name_or_path")
metadata["peft_type"] = cfg.get("peft_type")
metadata["lora_rank"] = cfg.get("r")
elif config_file.exists():
cfg = json.loads(config_file.read_text())
metadata["base_model"] = cfg.get("_name_or_path")
except Exception:
pass
# Fallback: extract base model name from folder name
# e.g. "unsloth_Llama-3.2-3B-Instruct_1771227800" → "unsloth/Llama-3.2-3B-Instruct"
if not metadata.get("base_model"):
parts = item.name.rsplit("_", 1)
if len(parts) == 2 and parts[1].isdigit():
name_part = parts[0]
idx = name_part.find("_")
if idx > 0:
metadata["base_model"] = name_part[:idx] + "/" + name_part[idx + 1:]
else:
metadata["base_model"] = name_part
# This is a valid training run
checkpoints = []
@ -79,7 +106,7 @@ def scan_checkpoints(
last_checkpoint_loss = checkpoints[-1][2]
checkpoints[0] = (checkpoints[0][0], checkpoints[0][1], last_checkpoint_loss)
models.append((item.name, checkpoints))
models.append((item.name, checkpoints, metadata))
logger.debug(f"Found model: {item.name} with {len(checkpoints)} checkpoint(s)")
# Sort by modification time (newest first)

View file

@ -112,7 +112,10 @@ function ModelSelectorContent({
<PopoverContent
align="start"
data-tour={dataTour}
className={cn("w-[440px] min-w-[440px] gap-0 p-2", className)}
className={cn(
"w-[min(440px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] min-w-0 gap-0 p-2",
className,
)}
>
<Tabs defaultValue="hub" className="w-full">
<TabsList className="mb-2 w-full">

View file

@ -3,6 +3,13 @@ import {
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import {
AiChat02Icon,
@ -29,6 +36,7 @@ const NAV_ITEMS = [
export function Navbar() {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const [logoHovered, setLogoHovered] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const tourId =
pathname === "/studio"
@ -39,9 +47,16 @@ export function Navbar() {
? "export"
: null;
const openTour = () => {
if (!tourId) return;
window.dispatchEvent(
new CustomEvent(TOUR_OPEN_EVENT, { detail: { id: tourId } }),
);
};
return (
<header className="top-0 z-40 h-16 w-full">
<div className="mx-auto flex h-full max-w-7xl items-center justify-between px-6">
<div className="mx-auto flex h-full max-w-7xl items-center justify-between px-4 sm:px-6">
{/* Left: logo */}
<div
className="relative flex items-center gap-2.5 cursor-pointer select-none"
@ -55,7 +70,7 @@ export function Navbar() {
animate={{ rotate: logoHovered ? 360 : 0 }}
transition={{ duration: 0.5, ease: [0.165, 0.84, 0.44, 1] }}
/>
<span className="text-2xl font-bold tracking-wide font-heading">
<span className="text-xl font-bold tracking-wide font-heading sm:text-2xl">
unsloth
</span>
<AnimatePresence>
@ -76,7 +91,7 @@ export function Navbar() {
{/* Center: pill nav */}
<nav
data-tour="navbar"
className="flex items-center rounded-full border border-border bg-card p-1 ring-1 ring-foreground/5"
className="hidden items-center rounded-full border border-border bg-card p-1 ring-1 ring-foreground/5 md:flex"
>
{NAV_ITEMS.map((item) => {
const active = pathname === item.href;
@ -138,8 +153,8 @@ export function Navbar() {
})}
</nav>
{/* Right: docs link */}
<div className="flex items-center gap-2">
{/* Right: docs/tour desktop */}
<div className="hidden items-center gap-2 md:flex">
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>
<a
@ -177,11 +192,7 @@ export function Navbar() {
{tourId ? (
<button
type="button"
onClick={() => {
window.dispatchEvent(
new CustomEvent(TOUR_OPEN_EVENT, { detail: { id: tourId } }),
);
}}
onClick={openTour}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Tour"
>
@ -189,6 +200,77 @@ export function Navbar() {
</button>
) : null}
</div>
{/* Right: mobile */}
<div className="flex items-center gap-2 md:hidden">
{tourId ? (
<button
type="button"
onClick={openTour}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Tour"
>
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
</button>
) : null}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger asChild={true}>
<button
type="button"
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground"
aria-label="Open navigation menu"
>
Menu
</button>
</SheetTrigger>
<SheetContent side="right" className="w-[300px] p-4">
<SheetHeader>
<SheetTitle>Navigate</SheetTitle>
</SheetHeader>
<div className="mt-6 flex flex-col gap-2">
{NAV_ITEMS.filter((item) => item.enabled).map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
to={item.href}
onClick={() => setMobileOpen(false)}
className={cn(
"rounded-md border px-3 py-2 text-sm font-medium",
active
? "border-foreground bg-foreground text-background"
: "border-border text-foreground hover:bg-accent",
)}
>
{item.label}
</Link>
);
})}
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="mt-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-foreground hover:bg-accent"
onClick={() => setMobileOpen(false)}
>
Learn more (Docs)
</a>
{tourId ? (
<button
type="button"
className="rounded-md border border-border px-3 py-2 text-left text-sm font-medium text-foreground hover:bg-accent"
onClick={() => {
openTour();
setMobileOpen(false);
}}
>
Start tour
</button>
) : null}
</div>
</SheetContent>
</Sheet>
</div>
</div>
</header>
);

View file

@ -42,19 +42,21 @@ function AlertDialogOverlay({
);
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm";
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
function AlertDialogContent({
className,
size = "default",
overlayClassName,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm";
overlayClassName?: string;
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay className={overlayClassName} />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/5 gap-6 rounded-4xl p-6 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-md group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className,

View file

@ -1,9 +1,10 @@
import { LightRays } from "@/components/ui/light-rays";
import { Card } from "@/components/ui/card";
import { AuthForm } from "./components/auth-form";
export function LoginPage() {
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-6 py-10 md:px-10">
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-8 sm:px-6 sm:py-10 md:px-10">
<LightRays
count={6}
color="rgba(34, 197, 94, 0.25)"
@ -12,9 +13,9 @@ export function LoginPage() {
length="70vh"
style={{ opacity: 0.4 }}
/>
<div className="relative z-10 w-full max-w-sm">
<Card className="relative z-10 w-full max-w-sm px-5 py-6 shadow-border ring-1 ring-border sm:px-6 sm:py-8">
<AuthForm mode="login" />
</div>
</Card>
</div>
);
}

View file

@ -1,9 +1,10 @@
import { LightRays } from "@/components/ui/light-rays";
import { Card } from "@/components/ui/card";
import { AuthForm } from "./components/auth-form";
export function SignupPage() {
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-6 py-10 md:px-10">
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-8 sm:px-6 sm:py-10 md:px-10">
<LightRays
count={6}
color="rgba(34, 197, 94, 0.25)"
@ -12,9 +13,9 @@ export function SignupPage() {
length="70vh"
style={{ opacity: 0.4 }}
/>
<div className="relative z-10 w-full max-w-sm">
<Card className="relative z-10 w-full max-w-sm px-5 py-6 shadow-border ring-1 ring-border sm:px-6 sm:py-8">
<AuthForm mode="signup" />
</div>
</Card>
</div>
);
}

View file

@ -6,6 +6,13 @@ import {
import { Thread } from "@/components/assistant-ui/thread";
import { Button } from "@/components/ui/button";
import { SidebarProvider, SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import {
@ -29,6 +36,10 @@ import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { ChatSettingsPanel } from "./chat-settings-sheet";
import { db } from "./db";
import { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
import {
clearTrainingCompareHandoff,
getTrainingCompareHandoff,
} from "./lib/training-compare-handoff";
import { ChatRuntimeProvider } from "./runtime-provider";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import {
@ -41,6 +52,43 @@ import { ThreadSidebar } from "./thread-sidebar";
import type { ChatView } from "./types";
import { buildChatTourSteps } from "./tour";
type LoraCandidate = {
id: string;
baseModel: string;
updatedAt?: number;
};
function normalizeModelRef(value: string | null | undefined): string {
return value?.trim().toLowerCase() ?? "";
}
function pickBestLoraForBase(
loras: LoraCandidate[],
baseModel: string | null,
): LoraCandidate | null {
if (loras.length === 0) return null;
const sorted = [...loras].sort(
(a, b) => (b.updatedAt ?? -1) - (a.updatedAt ?? -1),
);
const normalizedBase = normalizeModelRef(baseModel);
if (!normalizedBase) return sorted[0];
const exact = sorted.find(
(lora) => normalizeModelRef(lora.baseModel) === normalizedBase,
);
if (exact) return exact;
const partial = sorted.find((lora) => {
const normalizedLoraBase = normalizeModelRef(lora.baseModel);
if (!normalizedLoraBase) return false;
return (
normalizedLoraBase.includes(normalizedBase) ||
normalizedBase.includes(normalizedLoraBase)
);
});
return partial ?? sorted[0];
}
const SingleContent = memo(function SingleContent({
threadId,
newThreadNonce,
@ -86,7 +134,10 @@ const CompareContent = memo(function CompareContent({
return (
<CompareHandlesProvider handlesRef={handlesRef}>
<div className="flex min-h-0 flex-1 flex-col">
<div data-tour="chat-compare-view" className="grid min-h-0 flex-1 grid-cols-2 px-0">
<div
data-tour="chat-compare-view"
className="grid min-h-0 flex-1 grid-cols-1 px-0 md:grid-cols-2"
>
<div className="flex min-h-0 flex-col">
<div className="px-3 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
@ -104,8 +155,8 @@ const CompareContent = memo(function CompareContent({
</ChatRuntimeProvider>
</div>
</div>
<div className="flex min-h-0 flex-col">
<div className="text-end px-3 py-1.5">
<div className="flex min-h-0 flex-col border-t border-border/60 md:border-t-0 md:border-l">
<div className="px-3 py-1.5 text-start md:text-end">
<span className="text-[10px] font-semibold uppercase tracking-wider text-primary">
Fine-tuned (LoRA)
</span>
@ -137,8 +188,23 @@ function InlineSidebar({
children: ReactNode;
side?: "left" | "right";
}) {
const { state } = useSidebar();
const { state, isMobile, openMobile, setOpenMobile } = useSidebar();
const collapsed = state === "collapsed";
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile}>
<SheetContent side={side} className="w-[18rem] p-0">
<SheetHeader className="sr-only">
<SheetTitle>Chat sidebar</SheetTitle>
<SheetDescription>Chat threads and actions</SheetDescription>
</SheetHeader>
<div className="h-full overflow-auto">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group shrink-0 h-full"
@ -207,7 +273,9 @@ export function ChatPage(): ReactElement {
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const viewBeforeCompareRef = useRef<ChatView | null>(null);
const [viewBeforeCompare, setViewBeforeCompare] = useState<ChatView | null>(
null,
);
const inferenceParams = useChatRuntimeStore((state) => state.params);
const setInferenceParams = useChatRuntimeStore((state) => state.setParams);
const autoTitle = useChatRuntimeStore((state) => state.autoTitle);
@ -216,6 +284,13 @@ export function ChatPage(): ReactElement {
const lorasFromStore = useChatRuntimeStore((state) => state.loras);
const modelsError = useChatRuntimeStore((state) => state.modelsError);
const { refresh, selectModel, ejectModel } = useChatModelRuntime();
const refreshRef = useRef(refresh);
const selectModelRef = useRef(selectModel);
useEffect(() => {
refreshRef.current = refresh;
selectModelRef.current = selectModel;
}, [refresh, selectModel]);
const canCompare = useMemo(() => {
const selected = inferenceParams.checkpoint;
if (!selected) return false;
@ -262,18 +337,15 @@ export function ChatPage(): ReactElement {
const openSidebar = useCallback(() => setSidebarOpen(true), []);
const enterCompare = useCallback(() => {
if (viewBeforeCompareRef.current == null) {
viewBeforeCompareRef.current = view;
}
setViewBeforeCompare((prev) => prev ?? view);
setView({ mode: "compare", pairId: crypto.randomUUID() });
}, [view]);
const exitCompare = useCallback(() => {
const prev = viewBeforeCompareRef.current;
if (!prev) return;
viewBeforeCompareRef.current = null;
setView(prev);
}, []);
if (!viewBeforeCompare) return;
setView(viewBeforeCompare);
setViewBeforeCompare(null);
}, [viewBeforeCompare]);
const models = useMemo<ModelOption[]>(
() =>
@ -297,9 +369,69 @@ export function ChatPage(): ReactElement {
);
useEffect(() => {
if (getTrainingCompareHandoff()) return;
void refresh();
}, [refresh]);
useEffect(() => {
const handoff = getTrainingCompareHandoff();
if (!handoff) return;
console.info("[chat-handoff] received", handoff);
function clearHandoff(): void {
clearTrainingCompareHandoff();
}
let canceled = false;
void (async () => {
try {
console.info("[chat-handoff] refreshing models+loras");
await refreshRef.current();
if (canceled) return;
const state = useChatRuntimeStore.getState();
const targetLora = pickBestLoraForBase(state.loras, handoff.baseModel);
if (targetLora) {
console.info("[chat-handoff] loading lora", {
id: targetLora.id,
baseModel: targetLora.baseModel,
});
await selectModelRef.current({ id: targetLora.id, isLora: true });
if (canceled) return;
setView({ mode: "compare", pairId: crypto.randomUUID() });
clearHandoff();
console.info("[chat-handoff] loaded lora + opened compare");
return;
}
if (
handoff.baseModel &&
state.models.some((model) => model.id === handoff.baseModel)
) {
console.info("[chat-handoff] no lora match, loading base", {
id: handoff.baseModel,
});
await selectModelRef.current({ id: handoff.baseModel, isLora: false });
if (canceled) return;
} else {
console.warn("[chat-handoff] no lora/base match found", {
requestedBaseModel: handoff.baseModel,
loraCount: state.loras.length,
modelCount: state.models.length,
});
}
clearHandoff();
console.info("[chat-handoff] completed");
} catch (error) {
console.error("[chat-handoff] failed", error);
clearHandoff();
}
})();
return () => {
canceled = true;
};
}, []);
const tourSteps = useMemo(
() =>
buildChatTourSteps({
@ -332,18 +464,21 @@ export function ChatPage(): ReactElement {
useEffect(() => {
if (tour.open) return;
if (!modelSelectorLocked) return;
setModelSelectorLocked(false);
setModelSelectorOpen(false);
const timeoutId = window.setTimeout(() => {
setModelSelectorLocked(false);
setModelSelectorOpen(false);
}, 0);
return () => window.clearTimeout(timeoutId);
}, [modelSelectorLocked, tour.open]);
return (
<div className="h-[calc(100vh-4rem)] bg-background overflow-hidden">
<div className="h-[calc(100dvh-4rem)] bg-background overflow-hidden">
<GuidedTour {...tour.tourProps} />
<SidebarProvider
defaultOpen={true}
open={sidebarOpen}
onOpenChange={setSidebarOpen}
className="!min-h-0 h-full max-w-7xl mx-auto px-4"
className="!min-h-0 h-full w-full max-w-7xl mx-auto px-2 sm:px-4"
style={
{
"--sidebar-width": "14rem",
@ -362,7 +497,7 @@ export function ChatPage(): ReactElement {
</InlineSidebar>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex h-11 shrink-0 items-center px-2">
<div className="flex h-11 shrink-0 items-center px-1.5 sm:px-2">
<div className="flex items-center gap-1">
<SidebarTrigger />
<TopBarActions
@ -381,6 +516,7 @@ export function ChatPage(): ReactElement {
onOpenChange={handleModelSelectorOpenChange}
triggerDataTour="chat-model-selector"
contentDataTour="chat-model-selector-popover"
className="max-w-[62vw] sm:max-w-none"
/>
</div>
{modelsError && (

View file

@ -7,3 +7,4 @@ export {
} from "./chat-settings-sheet";
export { useChatRuntimeStore } from "./stores/chat-runtime-store";
export { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";

View file

@ -0,0 +1,53 @@
const TRAINING_COMPARE_HANDOFF_KEY = "chat:training-compare-handoff:v1";
const HANDOFF_MAX_AGE_MS = 15 * 60 * 1000;
export type TrainingCompareHandoff = {
intent: "compare";
baseModel: string | null;
requestedAt: number;
};
export function setTrainingCompareHandoff(baseModel: string | null): void {
if (typeof window === "undefined") return;
const payload: TrainingCompareHandoff = {
intent: "compare",
baseModel,
requestedAt: Date.now(),
};
window.sessionStorage.setItem(
TRAINING_COMPARE_HANDOFF_KEY,
JSON.stringify(payload),
);
}
export function getTrainingCompareHandoff(): TrainingCompareHandoff | null {
if (typeof window === "undefined") return null;
const raw = window.sessionStorage.getItem(TRAINING_COMPARE_HANDOFF_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<TrainingCompareHandoff>;
if (parsed.intent !== "compare") return null;
if (typeof parsed.requestedAt !== "number") return null;
if (Date.now() - parsed.requestedAt > HANDOFF_MAX_AGE_MS) {
clearTrainingCompareHandoff();
return null;
}
return {
intent: "compare",
baseModel:
typeof parsed.baseModel === "string" ? parsed.baseModel : null,
requestedAt: parsed.requestedAt,
};
} catch {
clearTrainingCompareHandoff();
return null;
}
}
export function clearTrainingCompareHandoff(): void {
if (typeof window === "undefined") return;
window.sessionStorage.removeItem(TRAINING_COMPARE_HANDOFF_KEY);
}

View file

@ -0,0 +1,127 @@
import { authFetch } from "@/features/auth";
async function readError(response: Response): Promise<string> {
try {
const payload = (await response.json()) as { detail?: string; message?: string };
return payload.detail || payload.message || `Request failed (${response.status})`;
} catch {
return `Request failed (${response.status})`;
}
}
async function parseJson<T>(response: Response): Promise<T> {
if (!response.ok) {
throw new Error(await readError(response));
}
return (await response.json()) as T;
}
export interface CheckpointInfo {
display_name: string;
path: string;
loss?: number | null;
}
export interface ModelCheckpoints {
name: string;
checkpoints: CheckpointInfo[];
base_model?: string | null;
peft_type?: string | null;
lora_rank?: number | null;
}
export interface CheckpointListResponse {
outputs_dir: string;
models: ModelCheckpoints[];
}
export interface ExportOperationResponse {
success: boolean;
message: string;
details?: Record<string, unknown> | null;
}
export async function fetchCheckpoints(): Promise<CheckpointListResponse> {
const response = await authFetch("/api/models/checkpoints");
return parseJson<CheckpointListResponse>(response);
}
export async function loadCheckpoint(params: {
checkpoint_path: string;
max_seq_length?: number;
load_in_4bit?: boolean;
}): Promise<ExportOperationResponse> {
const response = await authFetch("/api/export/load-checkpoint", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
return parseJson<ExportOperationResponse>(response);
}
export async function exportMerged(params: {
save_directory: string;
format_type?: string;
push_to_hub?: boolean;
repo_id?: string | null;
hf_token?: string | null;
private?: boolean;
}): Promise<ExportOperationResponse> {
const response = await authFetch("/api/export/export/merged", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
return parseJson<ExportOperationResponse>(response);
}
export async function exportBase(params: {
save_directory: string;
push_to_hub?: boolean;
repo_id?: string | null;
hf_token?: string | null;
private?: boolean;
base_model_id?: string | null;
}): Promise<ExportOperationResponse> {
const response = await authFetch("/api/export/export/base", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
return parseJson<ExportOperationResponse>(response);
}
export async function exportGGUF(params: {
save_directory: string;
quantization_method: string;
push_to_hub?: boolean;
repo_id?: string | null;
hf_token?: string | null;
}): Promise<ExportOperationResponse> {
const response = await authFetch("/api/export/export/gguf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
return parseJson<ExportOperationResponse>(response);
}
export async function exportLoRA(params: {
save_directory: string;
push_to_hub?: boolean;
repo_id?: string | null;
hf_token?: string | null;
private?: boolean;
}): Promise<ExportOperationResponse> {
const response = await authFetch("/api/export/export/lora", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
return parseJson<ExportOperationResponse>(response);
}
export async function cleanupExport(): Promise<ExportOperationResponse> {
const response = await authFetch("/api/export/cleanup", { method: "POST" });
return parseJson<ExportOperationResponse>(response);
}

View file

@ -13,8 +13,9 @@ import {
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { ArrowRight01Icon, Key01Icon } from "@hugeicons/core-free-icons";
import { AlertCircleIcon, ArrowRight01Icon, CheckmarkCircle02Icon, Key01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion } from "motion/react";
import { collapseAnim } from "../anim";
@ -41,6 +42,10 @@ interface ExportDialogProps {
onHfTokenChange: (v: string) => void;
privateRepo: boolean;
onPrivateRepoChange: (v: boolean) => void;
onExport: () => void;
exporting: boolean;
exportError: string | null;
exportSuccess: boolean;
}
export function ExportDialog({
@ -49,7 +54,7 @@ export function ExportDialog({
checkpoint,
exportMethod,
quantLevels,
estimatedSize,
estimatedSize: _estimatedSize,
baseModelName,
isAdapter,
destination,
@ -62,150 +67,211 @@ export function ExportDialog({
onHfTokenChange,
privateRepo,
onPrivateRepoChange,
onExport,
exporting,
exportError,
exportSuccess,
}: ExportDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Export Model</DialogTitle>
<DialogDescription>
Choose where to save your exported model.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Button
variant={destination === "local" ? "dark" : "outline"}
onClick={() => onDestinationChange("local")}
className="flex-1"
>
Save Locally
</Button>
<Button
variant={destination === "hub" ? "dark" : "outline"}
onClick={() => onDestinationChange("hub")}
className="flex-1"
>
Push to Hub
</Button>
</div>
<AnimatePresence>
{destination === "hub" && (
<motion.div {...collapseAnim} className="overflow-hidden">
<div className="flex flex-col gap-4 px-0.5">
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Username / Org
</label>
<Input
placeholder="your-username"
value={hfUsername}
onChange={(e) => onHfUsernameChange(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Model Name
</label>
<Input
placeholder="my-model-gguf"
value={modelName}
onChange={(e) => onModelNameChange(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">
HF Write Token
</label>
<a
href="https://huggingface.co/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[11px] text-emerald-600 hover:text-emerald-700 transition-colors"
>
Get token
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-3"
/>
</a>
</div>
<InputGroup>
<InputGroupAddon>
<HugeiconsIcon icon={Key01Icon} className="size-4" />
</InputGroupAddon>
<InputGroupInput
type="password"
placeholder="hf_..."
value={hfToken}
onChange={(e) => onHfTokenChange(e.target.value)}
/>
</InputGroup>
<p className="text-[11px] text-muted-foreground/70">
Leave empty if already logged in via CLI.
</p>
</div>
<div className="flex items-center gap-3">
<Switch
id="private-repo"
size="sm"
checked={privateRepo}
onCheckedChange={onPrivateRepoChange}
/>
<label
htmlFor="private-repo"
className="text-xs font-medium cursor-pointer"
>
Private Repository
</label>
</div>
<Dialog
open={open}
onOpenChange={(v) => {
if (exporting) return;
onOpenChange(v);
}}
>
<DialogContent className="sm:max-w-lg" onInteractOutside={(e) => { if (exporting) e.preventDefault(); }}>
{exportSuccess ? (
<>
<div className="flex flex-col items-center gap-3 py-6">
<div className="flex size-12 items-center justify-center rounded-full bg-emerald-500/10">
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="size-6 text-emerald-500" />
</div>
<div className="text-center">
<h3 className="text-lg font-semibold">Export Complete</h3>
<p className="mt-1 text-sm text-muted-foreground">
{destination === "hub"
? "Model successfully pushed to Hugging Face Hub."
: "Model saved locally."}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Summary */}
<div className="rounded-xl bg-muted/50 p-3 text-xs text-muted-foreground flex flex-col gap-1">
<div className="flex justify-between">
<span>Base Model</span>
<span className="font-medium text-foreground">{baseModelName}</span>
</div>
<div className="flex justify-between">
<span>{isAdapter ? "Checkpoint" : "Model"}</span>
<span className="font-medium text-foreground">{checkpoint}</span>
</div>
<div className="flex justify-between">
<span>Export Method</span>
<span className="font-medium text-foreground">
{EXPORT_METHODS.find((m) => m.value === exportMethod)?.title}
</span>
</div>
{exportMethod === "gguf" && quantLevels.length > 0 && (
<div className="flex justify-between">
<span>Quantizations</span>
<span className="font-medium text-foreground">
{quantLevels.join(", ")}
</span>
</div>
)}
<div className="flex justify-between">
<DialogFooter>
<Button onClick={() => onOpenChange(false)}>Done</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>Export Model</DialogTitle>
<DialogDescription>
Choose where to save your exported model.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Button
variant={destination === "local" ? "dark" : "outline"}
onClick={() => onDestinationChange("local")}
disabled={exporting}
className="flex-1"
>
Save Locally
</Button>
<Button
variant={destination === "hub" ? "dark" : "outline"}
onClick={() => onDestinationChange("hub")}
disabled={exporting}
className="flex-1"
>
Push to Hub
</Button>
</div>
<AnimatePresence>
{destination === "hub" && (
<motion.div {...collapseAnim} className="overflow-hidden">
<div className="flex flex-col gap-4 px-0.5">
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Username / Org
</label>
<Input
placeholder="your-username"
value={hfUsername}
onChange={(e) => onHfUsernameChange(e.target.value)}
disabled={exporting}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Model Name
</label>
<Input
placeholder="my-model-gguf"
value={modelName}
onChange={(e) => onModelNameChange(e.target.value)}
disabled={exporting}
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">
HF Write Token
</label>
<a
href="https://huggingface.co/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[11px] text-emerald-600 hover:text-emerald-700 transition-colors"
>
Get token
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-3"
/>
</a>
</div>
<InputGroup>
<InputGroupAddon>
<HugeiconsIcon icon={Key01Icon} className="size-4" />
</InputGroupAddon>
<InputGroupInput
type="password"
placeholder="hf_..."
value={hfToken}
onChange={(e) => onHfTokenChange(e.target.value)}
disabled={exporting}
/>
</InputGroup>
<p className="text-[11px] text-muted-foreground/70">
Leave empty if already logged in via CLI.
</p>
</div>
<div className="flex items-center gap-3">
<Switch
id="private-repo"
size="sm"
checked={privateRepo}
onCheckedChange={onPrivateRepoChange}
disabled={exporting}
/>
<label
htmlFor="private-repo"
className="text-xs font-medium cursor-pointer"
>
Private Repository
</label>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Error banner */}
{exportError && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<HugeiconsIcon icon={AlertCircleIcon} className="size-4 mt-0.5 shrink-0" />
<span>{exportError}</span>
</div>
)}
{/* Summary */}
<div className="rounded-xl bg-muted/50 p-3 text-xs text-muted-foreground flex flex-col gap-1">
<div className="flex justify-between">
<span>Base Model</span>
<span className="font-medium text-foreground">{baseModelName}</span>
</div>
<div className="flex justify-between">
<span>{isAdapter ? "Checkpoint" : "Model"}</span>
<span className="font-medium text-foreground">{checkpoint}</span>
</div>
<div className="flex justify-between">
<span>Export Method</span>
<span className="font-medium text-foreground">
{EXPORT_METHODS.find((m) => m.value === exportMethod)?.title}
</span>
</div>
{exportMethod === "gguf" && quantLevels.length > 0 && (
<div className="flex justify-between">
<span>Quantizations</span>
<span className="font-medium text-foreground">
{quantLevels.join(", ")}
</span>
</div>
)}
{/* TODO: unhide once estimated size comes from the backend API */}
{/* <div className="flex justify-between">
<span>Est. size</span>
<span className="font-medium text-foreground">{estimatedSize}</span>
</div>
</div>
</div> */}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => onOpenChange(false)}>Start Export</Button>
</DialogFooter>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={exporting}
>
Cancel
</Button>
<Button onClick={onExport} disabled={exporting}>
{exporting ? (
<span className="flex items-center gap-2">
<Spinner className="size-4" />
Exporting
</span>
) : (
"Start Export"
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);

View file

@ -8,80 +8,56 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useTrainingRuntimeStore } from "@/features/training";
import { useTrainingConfigStore } from "@/features/training";
import { isAdapterMethod } from "@/types/training";
import { InformationCircleIcon, PackageIcon } from "@hugeicons/core-free-icons";
import { AlertCircleIcon, InformationCircleIcon, PackageIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion } from "motion/react";
import { useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { collapseAnim } from "./anim";
import type { ModelCheckpoints } from "./api/export-api";
import {
cleanupExport,
exportBase,
exportGGUF,
exportLoRA,
exportMerged,
fetchCheckpoints,
loadCheckpoint,
} from "./api/export-api";
import { ExportDialog } from "./components/export-dialog";
import { MethodPicker } from "./components/method-picker";
import { QuantPicker } from "./components/quant-picker";
import {
type ExportMethod,
GUIDE_STEPS,
METHOD_LABELS,
getEstimatedSize,
} from "./constants";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { exportTourSteps } from "./tour";
export function ExportPage() {
const {
trainingMethod,
selectedModel,
saveSteps,
epochs,
loraRank,
hfToken,
setHfToken,
} = useTrainingConfigStore(
const { hfToken, setHfToken } = useTrainingConfigStore(
useShallow((s) => ({
trainingMethod: s.trainingMethod,
selectedModel: s.selectedModel,
saveSteps: s.saveSteps,
epochs: s.epochs,
loraRank: s.loraRank,
hfToken: s.hfToken,
setHfToken: s.setHfToken,
})),
);
const totalSteps = useTrainingRuntimeStore((state) => state.totalSteps);
const isAdapter = isAdapterMethod(trainingMethod);
const checkpoints = useMemo(() => {
if (isAdapter) {
const interval = saveSteps > 0 ? saveSteps : 100;
const total = totalSteps > 0 ? totalSteps : 500;
const entries: { value: string; label: string; detail: string }[] = [];
for (let step = interval; step <= total; step += interval) {
const loss = (1.5 - (step / total) * 0.7).toFixed(2);
entries.push({
value: `checkpoint-${step}`,
label: `checkpoint-${step}`,
detail: step === total ? `Best Loss: ${loss}` : `Loss: ${loss}`,
});
}
return entries.reverse();
}
return [
{
value: "final-model",
label: "Final Model",
detail: "Full fine-tuned weights",
},
];
}, [isAdapter, saveSteps, totalSteps]);
// ---- API-driven checkpoint state ----
const [models, setModels] = useState<ModelCheckpoints[]>([]);
const [loadingCheckpoints, setLoadingCheckpoints] = useState(true);
const [checkpointError, setCheckpointError] = useState<string | null>(null);
const [selectedModelIdx, setSelectedModelIdx] = useState<string | null>(null);
const [checkpoint, setCheckpoint] = useState<string | null>(null);
const [exportMethod, setExportMethod] = useState<ExportMethod | null>(null);
const [quantLevels, setQuantLevels] = useState<string[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
@ -91,11 +67,68 @@ export function ExportPage() {
const [modelName, setModelName] = useState("");
const [privateRepo, setPrivateRepo] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [exportSuccess, setExportSuccess] = useState(false);
const tour = useGuidedTourController({
id: "export",
steps: exportTourSteps,
});
// ---- Fetch checkpoints on mount ----
useEffect(() => {
let cancelled = false;
setLoadingCheckpoints(true);
setCheckpointError(null);
fetchCheckpoints()
.then((data) => {
if (!cancelled) {
setModels(data.models);
}
})
.catch((err) => {
if (!cancelled) {
setCheckpointError(
err instanceof Error ? err.message : "Failed to load checkpoints",
);
}
})
.finally(() => {
if (!cancelled) setLoadingCheckpoints(false);
});
return () => {
cancelled = true;
};
}, []);
// ---- Derived state ----
const selectedModelData = useMemo(
() =>
selectedModelIdx != null
? models.find((m) => m.name === selectedModelIdx) ?? null
: null,
[models, selectedModelIdx],
);
const checkpointsForModel = useMemo(
() => selectedModelData?.checkpoints ?? [],
[selectedModelData],
);
// Derive training info from selected model's API metadata
const baseModelName = selectedModelData?.base_model ?? "—";
const isAdapter = !!selectedModelData?.peft_type;
const loraRank = selectedModelData?.lora_rank ?? null;
const trainingMethodLabel = selectedModelData?.peft_type
? "LoRA / QLoRA"
: "Full Fine-tune";
// Reset checkpoint when the selected model changes
useEffect(() => {
setCheckpoint(null);
}, [selectedModelIdx]);
const handleMethodChange = (method: ExportMethod) => {
setExportMethod(method);
if (method !== "gguf") {
@ -108,11 +141,103 @@ export function ExportPage() {
checkpoint &&
exportMethod &&
(exportMethod !== "gguf" || quantLevels.length > 0);
const baseModelName = selectedModel ?? "—";
// ---- Export handler ----
const handleExport = useCallback(async () => {
if (!checkpoint) return;
const selectedCp = checkpointsForModel.find(
(cp) => cp.display_name === checkpoint,
);
if (!selectedCp) return;
setExporting(true);
setExportError(null);
setExportSuccess(false);
const saveDir = `./exports/${selectedModelIdx ?? "model"}/${checkpoint}`;
const pushToHub = destination === "hub";
const repoId = pushToHub && hfUsername && modelName
? `${hfUsername}/${modelName}`
: undefined;
const token = pushToHub && hfToken ? hfToken : undefined;
try {
// 1. Load checkpoint
await loadCheckpoint({ checkpoint_path: selectedCp.path });
// 2. Run export based on method
if (exportMethod === "merged") {
if (isAdapter) {
await exportMerged({
save_directory: saveDir,
push_to_hub: pushToHub,
repo_id: repoId,
hf_token: token,
private: privateRepo,
});
} else {
await exportBase({
save_directory: saveDir,
push_to_hub: pushToHub,
repo_id: repoId,
hf_token: token,
private: privateRepo,
base_model_id: selectedModelData?.base_model,
});
}
} else if (exportMethod === "gguf") {
for (const quant of quantLevels) {
await exportGGUF({
save_directory: saveDir,
quantization_method: quant,
push_to_hub: pushToHub,
repo_id: repoId,
hf_token: token,
});
}
} else if (exportMethod === "lora") {
await exportLoRA({
save_directory: saveDir,
push_to_hub: pushToHub,
repo_id: repoId,
hf_token: token,
private: privateRepo,
});
}
setExportSuccess(true);
} catch (err) {
setExportError(
err instanceof Error ? err.message : "Export failed",
);
} finally {
try {
await cleanupExport();
} catch {
// cleanup is best-effort
}
setExporting(false);
}
}, [
checkpoint,
checkpointsForModel,
selectedModelIdx,
selectedModelData,
exportMethod,
isAdapter,
quantLevels,
destination,
hfUsername,
modelName,
hfToken,
privateRepo,
]);
// ---- Render ----
return (
<div className="min-h-screen bg-background">
<main className="mx-auto max-w-7xl px-6 py-4">
<main className="mx-auto max-w-7xl px-4 py-4 sm:px-6">
<GuidedTour {...tour.tourProps} />
<div className="mb-8 flex flex-col gap-0.5">
@ -132,141 +257,236 @@ export function ExportPage() {
featured={true}
className="shadow-border ring-1 ring-border"
>
{/* Top row: Checkpoint + metadata | Guide */}
<div className="grid grid-cols-2 gap-8">
<div className="flex flex-col gap-4 ">
<div className="flex flex-col gap-2">
<label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
{isAdapter ? "Checkpoint" : "Model"}
<Tooltip>
<TooltipTrigger asChild={true}>
<button
type="button"
className="text-foreground/70 hover:text-foreground"
>
<HugeiconsIcon
icon={InformationCircleIcon}
className="size-3"
/>
</button>
</TooltipTrigger>
<TooltipContent>
Choose a saved checkpoint to export. Lower loss generally
means better quality.{" "}
<a
href="https://unsloth.ai/docs/basics/inference-and-deployment"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
Read more
</a>
</TooltipContent>
</Tooltip>
</label>
<Select value={checkpoint ?? ""} onValueChange={setCheckpoint}>
<SelectTrigger data-tour="export-checkpoint" className="w-full">
<SelectValue
placeholder={
isAdapter ? "Select a checkpoint…" : "Select model…"
}
/>
</SelectTrigger>
<SelectContent>
{checkpoints.map((cp) => (
<SelectItem key={cp.value} value={cp.value}>
<span className="flex items-center gap-2">
{cp.label}
<span className="text-muted-foreground text-xs">
{cp.detail}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Loading / error states */}
{loadingCheckpoints && (
<div className="flex items-center gap-2 py-6 justify-center text-sm text-muted-foreground">
<Spinner className="size-4" />
Loading checkpoints
</div>
)}
<div className="rounded-xl bg-muted/50 p-3 flex flex-col gap-2">
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
Training Info
</span>
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Base Model</span>
<span className="font-medium">{baseModelName}</span>
{checkpointError && (
<div className="flex items-center gap-2 py-6 justify-center text-sm text-destructive">
<HugeiconsIcon icon={AlertCircleIcon} className="size-4" />
{checkpointError}
</div>
)}
{!loadingCheckpoints && !checkpointError && (
<>
{/* Top row: Dropdowns + metadata | Guide */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-8">
<div className="flex flex-col gap-4">
{/* Training run dropdown */}
<div data-tour="export-training-run" className="flex flex-col gap-2">
<label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Training Run
<Tooltip>
<TooltipTrigger asChild={true}>
<button
type="button"
className="text-foreground/70 hover:text-foreground"
>
<HugeiconsIcon
icon={InformationCircleIcon}
className="size-3"
/>
</button>
</TooltipTrigger>
<TooltipContent>
Select the training run that produced the checkpoints
you want to export.
</TooltipContent>
</Tooltip>
</label>
<Select
value={selectedModelIdx ?? ""}
onValueChange={setSelectedModelIdx}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
models.length === 0
? "No training runs found"
: "Select a training run…"
}
/>
</SelectTrigger>
<SelectContent>
{models.map((m) => {
const tsMatch = m.name.match(/_(\d{10,})$/);
const displayName = tsMatch ? m.name.slice(0, tsMatch.index) : m.name;
const timeStr = tsMatch
? new Date(Number(tsMatch[1]) * 1000).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
})
: null;
return (
<SelectItem key={m.name} value={m.name}>
<span className="flex items-center gap-2">
{displayName}
<span className="text-muted-foreground text-xs">
{m.checkpoints.length} checkpoint
{m.checkpoints.length !== 1 ? "s" : ""}
</span>
{timeStr && (
<span className="text-muted-foreground text-xs">
· {timeStr}
</span>
)}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Method</span>
<span className="font-medium">
{METHOD_LABELS[trainingMethod] ?? trainingMethod}
{/* Checkpoint dropdown */}
<div data-tour="export-checkpoint" className="flex flex-col gap-2">
<label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Checkpoint
<Tooltip>
<TooltipTrigger asChild={true}>
<button
type="button"
className="text-foreground/70 hover:text-foreground"
>
<HugeiconsIcon
icon={InformationCircleIcon}
className="size-3"
/>
</button>
</TooltipTrigger>
<TooltipContent>
Choose a saved checkpoint to export. Lower loss
generally means better quality.{" "}
<a
href="https://unsloth.ai/docs/basics/inference-and-deployment"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
Read more
</a>
</TooltipContent>
</Tooltip>
</label>
<Select
value={checkpoint ?? ""}
onValueChange={setCheckpoint}
disabled={!selectedModelIdx}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
!selectedModelIdx
? "Select a training run first"
: checkpointsForModel.length === 0
? "No checkpoints found"
: "Select a checkpoint…"
}
/>
</SelectTrigger>
<SelectContent>
{checkpointsForModel.map((cp) => (
<SelectItem key={cp.path} value={cp.display_name}>
<span className="flex items-center gap-2">
{cp.display_name}
{cp.loss != null && (
<span className="text-muted-foreground text-xs">
loss: {cp.loss.toFixed(4)}
</span>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-xl bg-muted/50 p-3 flex flex-col gap-2">
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
Training Info
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Checkpoints</span>
<span className="font-medium">{checkpoints.length}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Epochs</span>
<span className="font-medium">{epochs}</span>
</div>
{isAdapter && (
<div className="flex justify-between">
<span className="text-muted-foreground">LoRA Rank</span>
<span className="font-medium">{loraRank}</span>
<div className="grid grid-cols-1 gap-x-6 gap-y-1.5 text-xs sm:grid-cols-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Base Model</span>
<span className="font-medium">{baseModelName}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Method</span>
<span className="font-medium">
{trainingMethodLabel}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Checkpoints</span>
<span className="font-medium">
{checkpointsForModel.length}
</span>
</div>
{isAdapter && (
<div className="flex justify-between">
<span className="text-muted-foreground">LoRA Rank</span>
<span className="font-medium">{loraRank}</span>
</div>
)}
</div>
)}
</div>
</div>
<div className="flex flex-col gap-2.5">
<span className="text-xs font-medium text-muted-foreground">
Quick Guide
</span>
<ol className="flex flex-col gap-3">
{GUIDE_STEPS.map((step, i) => (
<li
key={step}
className="flex items-start gap-2 text-xs text-muted-foreground"
>
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-semibold">
{i + 1}
</span>
{step}
</li>
))}
</ol>
</div>
</div>
</div>
<div className="flex flex-col gap-2.5">
<span className="text-xs font-medium text-muted-foreground">
Quick Guide
</span>
<ol className="flex flex-col gap-3">
{GUIDE_STEPS.map((step, i) => (
<li
key={step}
className="flex items-start gap-2 text-xs text-muted-foreground"
>
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-semibold">
{i + 1}
</span>
{step}
</li>
))}
</ol>
</div>
</div>
<MethodPicker value={exportMethod} onChange={handleMethodChange} />
<MethodPicker value={exportMethod} onChange={handleMethodChange} />
<AnimatePresence>
{exportMethod === "gguf" && (
<motion.div {...collapseAnim} className="overflow-hidden">
<QuantPicker value={quantLevels} onChange={setQuantLevels} />
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{exportMethod === "gguf" && (
<motion.div {...collapseAnim} className="overflow-hidden">
<QuantPicker value={quantLevels} onChange={setQuantLevels} />
</motion.div>
)}
</AnimatePresence>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<HugeiconsIcon
icon={InformationCircleIcon}
className="size-3.5"
/>
<span>Est. size: {estimatedSize} · Free disk space: 120 GB</span>
</div>
<Button
data-tour="export-cta"
disabled={!canExport}
onClick={() => setDialogOpen(true)}
>
Export Model
</Button>
</div>
<Separator />
<div className="flex items-center justify-end">
{/* TODO: unhide once estimated size comes from the backend API */}
{/* <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<HugeiconsIcon
icon={InformationCircleIcon}
className="size-3.5"
/>
<span>Est. size: {estimatedSize} · Free disk space: 120 GB</span>
</div> */}
<Button
data-tour="export-cta"
disabled={!canExport}
onClick={() => { setExportSuccess(false); setExportError(null); setDialogOpen(true); }}
>
Export Model
</Button>
</div>
</>
)}
</SectionCard>
</main>
@ -289,6 +509,10 @@ export function ExportPage() {
onHfTokenChange={setHfToken}
privateRepo={privateRepo}
onPrivateRepoChange={setPrivateRepo}
onExport={handleExport}
exporting={exporting}
exportError={exportError}
exportSuccess={exportSuccess}
/>
</div>
);

View file

@ -1,6 +1,17 @@
import type { TourStep } from "@/features/tour";
export const exportTourSteps: TourStep[] = [
{
id: "training-run",
target: "export-training-run",
title: "Pick training run",
body: (
<>
Start by selecting the training run. Each run groups the checkpoints
produced by that specific fine-tuning job.
</>
),
},
{
id: "checkpoint",
target: "export-checkpoint",

View file

@ -1,4 +1,5 @@
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { motion } from "motion/react";
interface SplashScreenProps {
@ -11,57 +12,61 @@ export function SplashScreen({
onGoToStudio,
}: SplashScreenProps) {
return (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-gradient-to-b from-background via-background to-primary/5">
{/* Mascot */}
<motion.img
src="/Sloth emojis/Sloth loca pc.png"
alt="Sloth mascot"
className="size-30"
initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
type: "spring",
duration: 0.7,
bounce: 0.3,
delay: 0.1,
}}
/>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-b from-background via-background to-primary/5 p-6">
<Card className="w-full max-w-md px-8 py-8 shadow-border ring-1 ring-border">
{/* Mascot */}
<div className="flex justify-center">
<motion.img
src="/Sloth emojis/Sloth loca pc.png"
alt="Sloth mascot"
className="size-30"
initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
type: "spring",
duration: 0.7,
bounce: 0.3,
delay: 0.1,
}}
/>
</div>
{/* Brand text */}
<motion.div
className="flex flex-col items-center gap-1 mt-4"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
ease: [0.165, 0.84, 0.44, 1],
delay: 0.4,
}}
>
<h1 className="text-2xl font-semibold tracking-tight">
Unsloth Studio
</h1>
<p className="text-sm text-muted-foreground">Fine-tune LLMs faster</p>
</motion.div>
{/* Brand text */}
<motion.div
className="mt-4 flex flex-col items-center gap-1"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
ease: [0.165, 0.84, 0.44, 1],
delay: 0.4,
}}
>
<h1 className="text-2xl font-semibold tracking-tight">
Unsloth Studio
</h1>
<p className="text-sm text-muted-foreground">Fine-tune LLMs faster</p>
</motion.div>
{/* Buttons */}
<motion.div
className="flex flex-col gap-3 mt-8"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
ease: [0.165, 0.84, 0.44, 1],
delay: 0.8,
}}
>
<Button size="lg" onClick={onStartOnboarding}>
Start Onboarding
</Button>
<Button size="lg" variant="outline" onClick={onGoToStudio}>
Skip Onboarding
</Button>
</motion.div>
{/* Buttons */}
<motion.div
className="mt-8 flex flex-col gap-3"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
ease: [0.165, 0.84, 0.44, 1],
delay: 0.8,
}}
>
<Button size="lg" onClick={onStartOnboarding}>
Start Onboarding
</Button>
<Button size="lg" variant="outline" onClick={onGoToStudio}>
Skip Onboarding
</Button>
</motion.div>
</Card>
</div>
);
}

View file

@ -28,6 +28,8 @@ import { useShallow } from "zustand/react/shallow";
export function HyperparametersStep() {
const {
trainingMethod,
maxSteps,
setMaxSteps,
epochs,
setEpochs,
contextLength,
@ -43,6 +45,8 @@ export function HyperparametersStep() {
} = useTrainingConfigStore(
useShallow((s) => ({
trainingMethod: s.trainingMethod,
maxSteps: s.maxSteps,
setMaxSteps: s.setMaxSteps,
epochs: s.epochs,
setEpochs: s.setEpochs,
contextLength: s.contextLength,
@ -60,6 +64,8 @@ export function HyperparametersStep() {
const showLoraParams =
trainingMethod === "lora" || trainingMethod === "qlora";
const maxStepsSliderMax = Math.max(500, maxSteps, 30);
const epochsSliderMax = Math.max(10, epochs, 1);
return (
<FieldGroup>
@ -68,7 +74,7 @@ export function HyperparametersStep() {
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<FieldLabel className="flex items-center gap-1.5 !text-sm text-muted-foreground">
Epochs
Max Steps
<Tooltip>
<TooltipTrigger asChild={true}>
<button
@ -82,7 +88,7 @@ export function HyperparametersStep() {
</button>
</TooltipTrigger>
<TooltipContent>
Number of times to iterate over the entire dataset.{" "}
Override total steps. Set 0 to use epochs instead.{" "}
<a
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
target="_blank"
@ -96,21 +102,21 @@ export function HyperparametersStep() {
</FieldLabel>
<div className="flex items-center gap-3">
<Slider
value={[epochs]}
onValueChange={([v]) => setEpochs(v)}
min={1}
max={10}
value={[Math.min(maxStepsSliderMax, Math.max(0, maxSteps))]}
onValueChange={([v]) => setMaxSteps(v)}
min={0}
max={maxStepsSliderMax}
step={1}
className="w-40"
/>
<input
type="number"
value={epochs}
onChange={(e) => setEpochs(Number(e.target.value))}
min={1}
max={10}
value={maxSteps}
onChange={(e) => setMaxSteps(Number(e.target.value))}
min={0}
max={maxStepsSliderMax}
step={1}
className="w-12 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary/30 [&::-webkit-inner-spin-button]:appearance-none"
className="w-16 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary/30 [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>
@ -196,6 +202,56 @@ export function HyperparametersStep() {
className="w-32 font-mono"
/>
</div>
<div className="flex items-center justify-between">
<FieldLabel className="flex items-center gap-1.5 !text-sm text-muted-foreground">
Epochs
<Tooltip>
<TooltipTrigger asChild={true}>
<button
type="button"
className="text-muted-foreground/50 hover:text-muted-foreground"
>
<HugeiconsIcon
icon={InformationCircleIcon}
className="size-3.5"
/>
</button>
</TooltipTrigger>
<TooltipContent>
Number of full passes over the dataset. Set 0 to run by max
steps.{" "}
<a
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
Read more
</a>
</TooltipContent>
</Tooltip>
</FieldLabel>
<div className="flex items-center gap-3">
<Slider
value={[Math.min(epochsSliderMax, Math.max(0, epochs))]}
onValueChange={([v]) => setEpochs(v)}
min={0}
max={epochsSliderMax}
step={1}
className="w-40"
/>
<input
type="number"
value={epochs}
onChange={(e) => setEpochs(Number(e.target.value))}
min={0}
max={epochsSliderMax}
step={1}
className="w-12 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary/30 [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>
</div>
</FieldSet>

View file

@ -45,7 +45,7 @@ import {
Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
export function ModelSelectionStep() {
@ -53,6 +53,7 @@ export function ModelSelectionStep() {
modelType,
selectedModel,
setSelectedModel,
ensureModelDefaultsLoaded,
trainingMethod,
setTrainingMethod,
hfToken,
@ -62,6 +63,7 @@ export function ModelSelectionStep() {
modelType: s.modelType,
selectedModel: s.selectedModel,
setSelectedModel: s.setSelectedModel,
ensureModelDefaultsLoaded: s.ensureModelDefaultsLoaded,
trainingMethod: s.trainingMethod,
setTrainingMethod: s.setTrainingMethod,
hfToken: s.hfToken,
@ -91,6 +93,10 @@ export function ModelSelectionStep() {
hfResults.length,
);
useEffect(() => {
ensureModelDefaultsLoaded();
}, [selectedModel, ensureModelDefaultsLoaded]);
return (
<FieldGroup>
<Field>

View file

@ -31,19 +31,19 @@ export function WizardContent() {
return (
<main className="flex-1 flex flex-col overflow-y-auto">
<header className="flex items-center gap-4 p-6 pb-4">
<img src={mascotSrc} alt="Unsloth mascot" className="size-14" />
<header className="flex flex-wrap items-start gap-3 p-4 pb-3 sm:p-6 sm:pb-4">
<img src={mascotSrc} alt="Unsloth mascot" className="size-12 sm:size-14" />
<div className="flex flex-col min-w-0">
<h1 className="text-xl font-semibold">{stepConfig.title}</h1>
<h1 className="text-lg font-semibold sm:text-xl">{stepConfig.title}</h1>
<p className="text-sm text-muted-foreground">
{stepConfig.description}
</p>
</div>
<p className="ml-auto shrink-0 text-xs text-muted-foreground uppercase tracking-wider">
<p className="ml-auto hidden shrink-0 text-xs text-muted-foreground uppercase tracking-wider md:block">
Step {currentStep} of {STEPS.length}
</p>
</header>
<div className="flex-1 p-6 pt-2">
<div className="flex-1 p-4 pt-1.5 sm:p-6 sm:pt-2">
<StepComponent />
</div>
</main>

View file

@ -54,7 +54,7 @@ export function WizardLayout() {
}, [isFinalStep]);
return (
<div className="relative min-h-screen flex items-center justify-center p-8 bg-gradient-to-br from-primary/5 via-background to-primary/3 overflow-hidden">
<div className="relative min-h-screen flex items-center justify-center overflow-hidden bg-gradient-to-br from-primary/5 via-background to-primary/3 p-4 sm:p-6 md:p-8">
{showSplash && (
<SplashScreen
onStartOnboarding={() => setShowSplash(false)}
@ -81,7 +81,7 @@ export function WizardLayout() {
ease: [0.165, 0.84, 0.44, 1],
}}
>
<Card className="relative z-10 w-full !gap-0 h-[640px] flex flex-row overflow-hidden !p-0 !m-0 shadow-border ring-1 ring-border">
<Card className="relative z-10 w-full !gap-0 !m-0 !p-0 flex min-h-[560px] flex-col overflow-hidden shadow-border ring-1 ring-border md:min-h-[620px] md:flex-row lg:h-[660px]">
<WizardSidebar />
<div className="flex-1 flex flex-col">
<WizardContent />

Some files were not shown because too many files have changed in this diff Show more