merge: nightly into feature/data-reciper-enchansments
This commit is contained in:
commit
b277308b7e
23 changed files with 475 additions and 214 deletions
|
|
@ -268,47 +268,6 @@ class InferenceBackend:
|
|||
logger.error(traceback.format_exc())
|
||||
return False, None
|
||||
|
||||
def load_adapter(self, base_model_name: str, adapter_path: str, adapter_name: str = None) -> bool:
|
||||
"""
|
||||
Load a LoRA adapter onto the base model if it's not already registered.
|
||||
This method is idempotent.
|
||||
"""
|
||||
if base_model_name not in self.models:
|
||||
logger.error(f"Base model {base_model_name} not loaded")
|
||||
return False
|
||||
|
||||
model = self.models[base_model_name].get("model")
|
||||
if model is None:
|
||||
logger.error(f"Model object for {base_model_name} is None.")
|
||||
return False
|
||||
|
||||
if adapter_name is None:
|
||||
adapter_name = adapter_path.split("/")[-1].replace(".", "_")
|
||||
|
||||
# If we've loaded this adapter before, we don't need to do anything.
|
||||
if adapter_name in self.models[base_model_name].get("loaded_adapters", {}):
|
||||
logger.info(f"Adapter '{adapter_name}' is already registered. Skipping.")
|
||||
return True
|
||||
|
||||
try:
|
||||
logger.info(f"Loading new adapter '{adapter_name}' from '{adapter_path}' onto {base_model_name}")
|
||||
|
||||
# Unsloth modifies the model in-place and returns None. Do NOT re-assign.
|
||||
model.load_adapter(adapter_path, adapter_name=adapter_name)
|
||||
|
||||
# Update our internal registry so we don't load it again.
|
||||
self.models[base_model_name]["loaded_adapters"][adapter_name] = adapter_path
|
||||
|
||||
total_adapters = len(getattr(model, 'peft_config', {}))
|
||||
logger.info(f"Adapter '{adapter_name}' loaded successfully. (Total adapters on model: {total_adapters})")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load adapter '{adapter_name}': {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
pass
|
||||
|
||||
def enable_adapter(self, base_model_name: str, adapter_name: str) -> bool:
|
||||
"""Enable specific adapter (for generation)"""
|
||||
if base_model_name not in self.models:
|
||||
|
|
@ -341,55 +300,6 @@ class InferenceBackend:
|
|||
logger.error(f"Failed to disable adapters: {e}")
|
||||
return False
|
||||
|
||||
# In backend/inference.py
|
||||
|
||||
def load_for_eval(self, lora_path: str, max_seq_length: int = 2048,
|
||||
dtype = None, load_in_4bit: bool = True,
|
||||
hf_token: Optional[str] = None) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||
"""
|
||||
Prepare for eval: ensure base model and the specified adapter are loaded.
|
||||
"""
|
||||
try:
|
||||
from utils.models import ModelConfig
|
||||
lora_config = ModelConfig.from_lora_path(lora_path, hf_token)
|
||||
if not lora_config:
|
||||
return False, None, None
|
||||
|
||||
base_model_name = lora_config.base_model
|
||||
|
||||
# 1. Load the base model if it's not already in memory (this logic is correct)
|
||||
if base_model_name not in self.models or not self.models[base_model_name].get("model"):
|
||||
logger.info(f"Base model '{base_model_name}' not loaded, loading now.")
|
||||
base_config = ModelConfig.from_ui_selection(base_model_name, None, is_lora=False)
|
||||
if not self.load_model(base_config, max_seq_length, dtype, load_in_4bit, hf_token):
|
||||
return False, None, None
|
||||
else:
|
||||
logger.info(f"Base model '{base_model_name}' is already in memory.")
|
||||
|
||||
self.active_model_name = base_model_name
|
||||
|
||||
# 2. Delegate to our now-idempotent load_adapter function.
|
||||
# It will handle all cases: first adapter, or subsequent adapters.
|
||||
adapter_name = lora_path.split("/")[-1].replace(".", "_")
|
||||
adapter_success = self.load_adapter(
|
||||
base_model_name=base_model_name,
|
||||
adapter_path=lora_path,
|
||||
adapter_name=adapter_name
|
||||
)
|
||||
|
||||
if not adapter_success:
|
||||
return False, base_model_name, None
|
||||
|
||||
return True, base_model_name, adapter_name
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during load_for_eval: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
return False, None, None
|
||||
pass
|
||||
|
||||
|
||||
def load_for_eval(self, lora_path: str, max_seq_length: int = 2048,
|
||||
dtype = None, load_in_4bit: bool = True,
|
||||
hf_token: Optional[str] = None) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||
|
|
@ -1272,47 +1182,6 @@ class InferenceBackend:
|
|||
"""Get name of currently loading model"""
|
||||
return next(iter(self.loading_models)) if self.loading_models else None
|
||||
|
||||
def load_model_simple(self,
|
||||
model_path: str,
|
||||
hf_token: Optional[str] = None,
|
||||
max_seq_length: int = 2048,
|
||||
load_in_4bit: bool = True) -> bool:
|
||||
"""
|
||||
Simple model loading wrapper for chat interface.
|
||||
Accepts model path as string and handles ModelConfig creation internally.
|
||||
|
||||
Args:
|
||||
model_path: Model name or path (e.g., "unsloth/llama-3-8b")
|
||||
hf_token: HuggingFace token for gated models
|
||||
max_seq_length: Maximum sequence length
|
||||
load_in_4bit: Whether to use 4-bit quantization
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Create config from string path
|
||||
config = ModelConfig.from_ui_selection(
|
||||
model_path,
|
||||
lora_path=None, # No LoRA for chat
|
||||
is_lora=False
|
||||
)
|
||||
|
||||
# Call existing load_model with config
|
||||
return self.load_model(
|
||||
config=config,
|
||||
max_seq_length=max_seq_length,
|
||||
dtype=None, # Auto-detect
|
||||
load_in_4bit=load_in_4bit,
|
||||
hf_token=hf_token
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in load_model_simple: {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def load_model_simple(self,
|
||||
model_path: str,
|
||||
hf_token: Optional[str] = None,
|
||||
|
|
|
|||
|
|
@ -354,7 +354,9 @@ class UnslothTrainer:
|
|||
subset: str = None,
|
||||
train_split: str = "train",
|
||||
eval_split: str = None,
|
||||
eval_steps: float = 0.00) -> Optional[tuple]:
|
||||
eval_steps: float = 0.00,
|
||||
dataset_slice_start: int = None,
|
||||
dataset_slice_end: int = None) -> Optional[tuple]:
|
||||
"""
|
||||
Load and prepare dataset for training.
|
||||
|
||||
|
|
@ -466,6 +468,18 @@ class UnslothTrainer:
|
|||
if dataset is None:
|
||||
raise ValueError("No dataset provided")
|
||||
|
||||
# Apply index range slicing if requested (inclusive on both ends)
|
||||
if dataset_slice_start is not None or dataset_slice_end is not None:
|
||||
total_rows = len(dataset)
|
||||
start = dataset_slice_start if dataset_slice_start is not None else 0
|
||||
end = dataset_slice_end if dataset_slice_end is not None else total_rows - 1
|
||||
# Clamp to valid range
|
||||
start = max(0, min(start, total_rows - 1))
|
||||
end = max(start, min(end, total_rows - 1))
|
||||
dataset = dataset.select(range(start, end + 1))
|
||||
print(f"Sliced dataset to rows [{start}, {end}]: {len(dataset)} of {total_rows} rows\n")
|
||||
self._update_progress(status_message=f"Sliced dataset to {len(dataset)} rows (indices {start}-{end})")
|
||||
|
||||
# Check if stopped before applying template
|
||||
if self.should_stop:
|
||||
print("Stopped before applying chat template\n")
|
||||
|
|
@ -482,6 +496,7 @@ class UnslothTrainer:
|
|||
format_type=format_type,
|
||||
dataset_name=dataset_source,
|
||||
custom_format_mapping=custom_format_mapping,
|
||||
progress_callback=self._update_progress,
|
||||
)
|
||||
|
||||
# Check if stopped during formatting
|
||||
|
|
@ -489,6 +504,14 @@ class UnslothTrainer:
|
|||
print("Stopped during dataset formatting\n")
|
||||
return None
|
||||
|
||||
# Abort if dataset formatting/conversion failed
|
||||
if not dataset_info.get("success", True):
|
||||
errors = dataset_info.get("errors", [])
|
||||
error_msg = "; ".join(errors) if errors else "Dataset formatting failed"
|
||||
logger.error(f"Dataset conversion failed: {error_msg}")
|
||||
self._update_progress(error=error_msg)
|
||||
return None
|
||||
|
||||
self._update_progress(status_message=f"Dataset formatted and ready for training")
|
||||
print(f"Dataset formatted successfully\n")
|
||||
|
||||
|
|
|
|||
|
|
@ -116,7 +116,9 @@ class TrainingBackend:
|
|||
train_split: str = "train",
|
||||
eval_split: str = None,
|
||||
eval_steps: float = 0.00,
|
||||
is_dataset_multimodal: bool = False) -> bool:
|
||||
is_dataset_multimodal: bool = False,
|
||||
dataset_slice_start: int = None,
|
||||
dataset_slice_end: int = None) -> bool:
|
||||
"""
|
||||
Start training.
|
||||
|
||||
|
|
@ -224,6 +226,8 @@ class TrainingBackend:
|
|||
train_split=train_split,
|
||||
eval_split=eval_split,
|
||||
eval_steps=eval_steps,
|
||||
dataset_slice_start=dataset_slice_start,
|
||||
dataset_slice_end=dataset_slice_end,
|
||||
)
|
||||
|
||||
# Unpack: load_and_format_dataset returns (dataset, eval_dataset)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ class CheckFormatResponse(BaseModel):
|
|||
detected_text_column: Optional[str] = None
|
||||
preview_samples: Optional[List[Dict]] = None
|
||||
total_rows: Optional[int] = None
|
||||
warning: Optional[str] = None
|
||||
|
||||
|
||||
class LocalDatasetItem(BaseModel):
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ class TrainingStartRequest(BaseModel):
|
|||
train_split: Optional[str] = Field("train", description="Training split name")
|
||||
eval_split: Optional[str] = Field(None, description="Eval split name. None = auto-detect")
|
||||
eval_steps: float = Field(0.00, description="Fraction of total steps between evals (0-1)")
|
||||
dataset_slice_start: Optional[int] = Field(None, description="Inclusive start row index for dataset slicing")
|
||||
dataset_slice_end: Optional[int] = Field(None, description="Inclusive end row index for dataset slicing")
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import io
|
|||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
import logging
|
||||
|
||||
# Add backend directory to path
|
||||
|
|
@ -16,6 +16,7 @@ if str(backend_path) not in sys.path:
|
|||
|
||||
# Import dataset utilities
|
||||
from utils.datasets import check_dataset_format
|
||||
from auth.authentication import get_current_subject
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -257,7 +258,10 @@ def list_local_datasets() -> LocalDatasetsResponse:
|
|||
|
||||
|
||||
@router.post("/check-format", response_model=CheckFormatResponse)
|
||||
def check_format(request: CheckFormatRequest):
|
||||
def check_format(
|
||||
request: CheckFormatRequest,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""
|
||||
Check if a dataset requires manual column mapping.
|
||||
|
||||
|
|
@ -373,6 +377,21 @@ def check_format(request: CheckFormatRequest):
|
|||
else:
|
||||
preview_samples = _serialize_preview_rows(preview_slice)
|
||||
|
||||
# Lightweight URL-based image detection for VLM datasets
|
||||
warning = None
|
||||
image_col = result.get("detected_image_column")
|
||||
if image_col and image_col in (result.get("columns") or []):
|
||||
try:
|
||||
sample_val = preview_slice[0][image_col]
|
||||
if isinstance(sample_val, str) and sample_val.startswith(("http://", "https://")):
|
||||
warning = (
|
||||
"This dataset contains image URLs instead of embedded images. "
|
||||
"Images will be downloaded during training, which may be slow for large datasets."
|
||||
)
|
||||
logger.info(f"URL-based image column detected: {image_col}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return CheckFormatResponse(
|
||||
requires_manual_mapping=result["requires_manual_mapping"],
|
||||
detected_format=result["detected_format"],
|
||||
|
|
@ -384,6 +403,7 @@ def check_format(request: CheckFormatRequest):
|
|||
detected_text_column=result.get("detected_text_column"),
|
||||
preview_samples=preview_samples,
|
||||
total_rows=total_rows,
|
||||
warning=warning,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
|
|
|
|||
|
|
@ -149,6 +149,8 @@ async def start_training(
|
|||
"train_split": request.train_split,
|
||||
"eval_split": request.eval_split,
|
||||
"eval_steps": request.eval_steps,
|
||||
"dataset_slice_start": request.dataset_slice_start,
|
||||
"dataset_slice_end": request.dataset_slice_end,
|
||||
"custom_format_mapping": request.custom_format_mapping,
|
||||
"num_epochs": request.num_epochs,
|
||||
"learning_rate": request.learning_rate,
|
||||
|
|
|
|||
|
|
@ -593,6 +593,7 @@ def format_and_template_dataset(
|
|||
aliases_for_assistant=["gpt", "assistant", "output",],
|
||||
batch_size=1000,
|
||||
num_proc=None,
|
||||
progress_callback=None,
|
||||
):
|
||||
"""
|
||||
Convenience function that combines format_dataset and apply_chat_template_to_dataset.
|
||||
|
|
@ -638,6 +639,7 @@ def format_and_template_dataset(
|
|||
text_column=user_vlm_text_column,
|
||||
image_column=user_vlm_image_column,
|
||||
dataset_name=dataset_name,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
warnings.append(f"Applied user VLM mapping: image='{user_vlm_image_column}', text='{user_vlm_text_column}'")
|
||||
|
||||
|
|
@ -734,6 +736,7 @@ def format_and_template_dataset(
|
|||
text_column=vlm_text_column,
|
||||
image_column=vlm_image_column,
|
||||
dataset_name=dataset_name,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
if vlm_instruction:
|
||||
|
|
|
|||
|
|
@ -238,24 +238,51 @@ def convert_alpaca_to_chatml(dataset, batch_size=1000, num_proc=None):
|
|||
return dataset.map(_convert, **dataset_map_kwargs)
|
||||
|
||||
|
||||
def _format_eta(seconds):
|
||||
"""Format seconds into a human-readable ETA string."""
|
||||
if seconds < 60:
|
||||
return f"{seconds:.0f}s"
|
||||
elif seconds < 3600:
|
||||
m, s = divmod(int(seconds), 60)
|
||||
return f"{m}m {s}s"
|
||||
else:
|
||||
h, remainder = divmod(int(seconds), 3600)
|
||||
m, _ = divmod(remainder, 60)
|
||||
return f"{h}h {m}m"
|
||||
|
||||
|
||||
def convert_to_vlm_format(
|
||||
dataset,
|
||||
instruction=None,
|
||||
text_column="text",
|
||||
image_column="image",
|
||||
dataset_name=None,
|
||||
progress_callback=None,
|
||||
):
|
||||
"""
|
||||
Converts simple {image, text} format to VLM messages format.
|
||||
|
||||
Returns a LIST, not a HuggingFace Dataset (to preserve PIL Images).
|
||||
|
||||
For URL-based image datasets, runs a 200-sample parallel probe first to
|
||||
estimate download speed and failure rate, then reports time estimate or
|
||||
warning through progress_callback before proceeding with the full conversion.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callable(status_message=str) to report
|
||||
progress to the training overlay.
|
||||
|
||||
Returns:
|
||||
list: List of dicts with 'messages' field
|
||||
"""
|
||||
from PIL import Image
|
||||
from .vlm_processing import generate_smart_vlm_instruction
|
||||
|
||||
def _notify(msg):
|
||||
"""Send status update to the training overlay if callback is available."""
|
||||
if progress_callback:
|
||||
progress_callback(status_message=msg)
|
||||
|
||||
# Generate smart instruction if not provided
|
||||
if instruction is None:
|
||||
instruction_info = generate_smart_vlm_instruction(
|
||||
|
|
@ -281,12 +308,17 @@ def convert_to_vlm_format(
|
|||
|
||||
def _convert_single_sample(sample):
|
||||
"""Convert a single sample to VLM format."""
|
||||
# Get image (might be PIL Image or path)
|
||||
# Get image (might be PIL Image, local path, or URL)
|
||||
image_data = sample[image_column]
|
||||
|
||||
# Handle image paths
|
||||
if isinstance(image_data, str):
|
||||
image_data = Image.open(image_data).convert("RGB")
|
||||
if image_data.startswith(("http://", "https://")):
|
||||
import fsspec
|
||||
from io import BytesIO
|
||||
with fsspec.open(image_data, "rb", expand=True) as f:
|
||||
image_data = Image.open(BytesIO(f.read())).convert("RGB")
|
||||
else:
|
||||
image_data = Image.open(image_data).convert("RGB")
|
||||
|
||||
# Get text
|
||||
text_data = sample[text_column]
|
||||
|
|
@ -317,11 +349,143 @@ def convert_to_vlm_format(
|
|||
# Return dict with messages
|
||||
return {"messages": messages}
|
||||
|
||||
# Use list comprehension and return the LIST directly
|
||||
print(f"🔄 Converting {len(dataset)} samples to VLM format...")
|
||||
converted_list = [_convert_single_sample(sample) for sample in dataset]
|
||||
total = len(dataset)
|
||||
first_image = next(iter(dataset))[image_column]
|
||||
has_urls = isinstance(first_image, str) and first_image.startswith(("http://", "https://"))
|
||||
|
||||
print(f"✅ Converted {len(converted_list)} samples")
|
||||
# ── URL probe: 200 samples with parallel workers to estimate speed + failure rate ──
|
||||
PROBE_SIZE = 200
|
||||
MAX_FAIL_RATE = 0.3
|
||||
|
||||
if has_urls and total > PROBE_SIZE:
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from utils.hardware import safe_num_proc
|
||||
|
||||
num_workers = safe_num_proc()
|
||||
_notify(f"Probing {PROBE_SIZE} image URLs with {num_workers} workers...")
|
||||
print(f"🔍 Probing {PROBE_SIZE}/{total} image URLs with {num_workers} workers...")
|
||||
|
||||
probe_samples = [dataset[i] for i in range(PROBE_SIZE)]
|
||||
probe_ok = 0
|
||||
probe_fail = 0
|
||||
probe_start = time.time()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=num_workers) as executor:
|
||||
futures = {executor.submit(_convert_single_sample, s): s for s in probe_samples}
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
future.result()
|
||||
probe_ok += 1
|
||||
except Exception:
|
||||
probe_fail += 1
|
||||
|
||||
probe_elapsed = time.time() - probe_start
|
||||
probe_total = probe_ok + probe_fail
|
||||
fail_rate = probe_fail / probe_total if probe_total > 0 else 0
|
||||
throughput = probe_total / probe_elapsed if probe_elapsed > 0 else 0
|
||||
|
||||
if fail_rate >= MAX_FAIL_RATE:
|
||||
msg = (
|
||||
f"⚠️ {fail_rate:.0%} of the first {PROBE_SIZE} images failed to download "
|
||||
f"({probe_fail}/{probe_total}). "
|
||||
"This dataset has too many broken or unreachable image URLs. "
|
||||
"Consider using a dataset with embedded images instead."
|
||||
)
|
||||
print(msg)
|
||||
_notify(msg)
|
||||
raise ValueError(msg)
|
||||
|
||||
# Estimate total time for remaining samples
|
||||
remaining = total - PROBE_SIZE
|
||||
estimated_seconds = remaining / throughput if throughput > 0 else 0
|
||||
eta_str = _format_eta(estimated_seconds)
|
||||
|
||||
info_msg = (
|
||||
f"Downloading {total:,} images ({num_workers} workers, ~{throughput:.1f} img/s). "
|
||||
f"Estimated time: ~{eta_str}"
|
||||
)
|
||||
if probe_fail > 0:
|
||||
info_msg += f" | {fail_rate:.0%} broken URLs will be skipped"
|
||||
|
||||
print(f"✅ Probe passed: {probe_ok}/{probe_total} ok, {probe_fail} failed ({fail_rate:.0%}), {throughput:.1f} img/s")
|
||||
print(f"⏱️ Estimated time for {total:,} samples: ~{eta_str}")
|
||||
_notify(info_msg)
|
||||
|
||||
# ── Full conversion with progress ──
|
||||
from tqdm import tqdm
|
||||
|
||||
print(f"🔄 Converting {total} samples to VLM format...")
|
||||
converted_list = []
|
||||
failed_count = 0
|
||||
|
||||
if has_urls:
|
||||
# Parallel conversion for URL-based datasets
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from utils.hardware import safe_num_proc
|
||||
|
||||
num_workers = safe_num_proc()
|
||||
batch_size = 500
|
||||
start_time = time.time()
|
||||
|
||||
for batch_start in range(0, total, batch_size):
|
||||
batch_end = min(batch_start + batch_size, total)
|
||||
batch_samples = [dataset[i] for i in range(batch_start, batch_end)]
|
||||
|
||||
with ThreadPoolExecutor(max_workers=num_workers) as executor:
|
||||
futures = {executor.submit(_convert_single_sample, s): i for i, s in enumerate(batch_samples)}
|
||||
batch_results = [None] * len(batch_samples)
|
||||
for future in as_completed(futures):
|
||||
idx = futures[future]
|
||||
try:
|
||||
batch_results[idx] = future.result()
|
||||
except Exception:
|
||||
failed_count += 1
|
||||
|
||||
converted_list.extend(r for r in batch_results if r is not None)
|
||||
|
||||
# Progress update every batch
|
||||
elapsed = time.time() - start_time
|
||||
done = batch_end
|
||||
rate = done / elapsed if elapsed > 0 else 0
|
||||
remaining_time = (total - done) / rate if rate > 0 else 0
|
||||
eta_str = _format_eta(remaining_time)
|
||||
progress_msg = f"Downloading images: {done:,}/{total:,} ({done*100//total}%) | ~{eta_str} remaining | {failed_count} skipped"
|
||||
print(f" [{done}/{total}] {rate:.1f} img/s, {failed_count} failed, ETA {eta_str}")
|
||||
_notify(progress_msg)
|
||||
else:
|
||||
# Sequential conversion for local/embedded images (fast, no I/O bottleneck)
|
||||
pbar = tqdm(dataset, total=total, desc="Converting VLM samples", unit="sample")
|
||||
for sample in pbar:
|
||||
try:
|
||||
converted_list.append(_convert_single_sample(sample))
|
||||
except Exception:
|
||||
failed_count += 1
|
||||
pbar.set_postfix(ok=len(converted_list), failed=failed_count, refresh=False)
|
||||
pbar.close()
|
||||
|
||||
if failed_count > 0:
|
||||
fail_rate = failed_count / total
|
||||
print(f"⚠️ Skipped {failed_count}/{total} ({fail_rate:.0%}) samples with broken/unreachable images")
|
||||
# For datasets that skipped the probe (small URL datasets), check fail rate now
|
||||
if has_urls and fail_rate >= MAX_FAIL_RATE:
|
||||
msg = (
|
||||
f"⚠️ {fail_rate:.0%} of images failed to download ({failed_count}/{total}). "
|
||||
"This dataset has too many broken or unreachable image URLs. "
|
||||
"Consider using a dataset with embedded images instead."
|
||||
)
|
||||
_notify(msg)
|
||||
raise ValueError(msg)
|
||||
|
||||
if len(converted_list) == 0:
|
||||
raise ValueError(
|
||||
f"All {total} samples failed during VLM conversion — no usable images found. "
|
||||
"This dataset may contain only image URLs that are no longer accessible."
|
||||
)
|
||||
|
||||
print(f"✅ Converted {len(converted_list)}/{total} samples")
|
||||
_notify(f"Converted {len(converted_list):,}/{total:,} images successfully")
|
||||
|
||||
# Return list, NOT Dataset
|
||||
return converted_list
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { Select as SelectPrimitive } from "radix-ui";
|
|||
import type * as React from "react";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDialogPortalContainer } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDialogPortalContainer } from "@/components/ui/dialog";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowUp01Icon,
|
||||
|
|
@ -92,22 +92,22 @@ function SelectTrigger({
|
|||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
container,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
|
||||
container?: HTMLElement | null;
|
||||
}) {
|
||||
const dialogContainer = useDialogPortalContainer();
|
||||
return (
|
||||
<SelectPrimitive.Portal container={container ?? dialogContainer ?? undefined}>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
container,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
|
||||
container?: HTMLElement | null;
|
||||
}) {
|
||||
const dialogContainer = useDialogPortalContainer();
|
||||
return (
|
||||
<SelectPrimitive.Portal container={container ?? dialogContainer ?? undefined}>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 shadow-border ring-1 ring-border min-w-36 rounded-xl p-1 corner-squircle duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto ",
|
||||
position === "popper" &&
|
||||
|
|
|
|||
|
|
@ -342,6 +342,13 @@ export function DatasetPreviewDialog({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{data.warning && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400 mb-4 flex items-start gap-2.5">
|
||||
<HugeiconsIcon icon={AlertCircleIcon} className="size-4 shrink-0 mt-0.5" />
|
||||
<span>{data.warning}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mappingEnabled && (
|
||||
<DatasetMappingCard
|
||||
mapping={manualMapping}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputGroupAddon } from "@/components/ui/input-group";
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -92,6 +93,10 @@ export function DatasetSection() {
|
|||
setUploadedFile,
|
||||
hfToken,
|
||||
modelType,
|
||||
datasetSliceStart,
|
||||
setDatasetSliceStart,
|
||||
datasetSliceEnd,
|
||||
setDatasetSliceEnd,
|
||||
} = useTrainingConfigStore(
|
||||
useShallow((s) => ({
|
||||
dataset: s.dataset,
|
||||
|
|
@ -110,6 +115,10 @@ export function DatasetSection() {
|
|||
setUploadedFile: s.setUploadedFile,
|
||||
hfToken: s.hfToken,
|
||||
modelType: s.modelType,
|
||||
datasetSliceStart: s.datasetSliceStart,
|
||||
setDatasetSliceStart: s.setDatasetSliceStart,
|
||||
datasetSliceEnd: s.datasetSliceEnd,
|
||||
setDatasetSliceEnd: s.setDatasetSliceEnd,
|
||||
})),
|
||||
);
|
||||
|
||||
|
|
@ -566,51 +575,118 @@ export function DatasetSection() {
|
|||
Advanced
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Target Format
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-foreground/70 hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={InformationCircleIcon}
|
||||
className="size-3"
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Format of your training data. Auto-detect works for most
|
||||
datasets.{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/datasets-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<Select
|
||||
value={datasetFormat}
|
||||
onValueChange={(v) =>
|
||||
setDatasetFormat(v as typeof datasetFormat)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="alpaca">Alpaca</SelectItem>
|
||||
<SelectItem value="chatml">ChatML</SelectItem>
|
||||
<SelectItem value="sharegpt">ShareGPT</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Target Format
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-foreground/70 hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={InformationCircleIcon}
|
||||
className="size-3"
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Format of your training data. Auto-detect works for most
|
||||
datasets.{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/datasets-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<Select
|
||||
value={datasetFormat}
|
||||
onValueChange={(v) =>
|
||||
setDatasetFormat(v as typeof datasetFormat)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="alpaca">Alpaca</SelectItem>
|
||||
<SelectItem value="chatml">ChatML</SelectItem>
|
||||
<SelectItem value="sharegpt">ShareGPT</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Train Split Start
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-foreground/70 hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={InformationCircleIcon}
|
||||
className="size-3"
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Only train on a subset of your training split by
|
||||
specifying a start row index (inclusive, 0-based).
|
||||
Leave empty to start from the first row.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
placeholder="0"
|
||||
value={datasetSliceStart ?? ""}
|
||||
onChange={(e) =>
|
||||
setDatasetSliceStart(e.target.value || null)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Train Split End
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-foreground/70 hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={InformationCircleIcon}
|
||||
className="size-3"
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Last row index to include from the training split
|
||||
(inclusive, 0-based). For example, set Start to 0 and
|
||||
End to 99 to train on the first 100 rows. Leave empty
|
||||
to use all remaining rows.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
placeholder="End"
|
||||
value={datasetSliceEnd ?? ""}
|
||||
onChange={(e) =>
|
||||
setDatasetSliceEnd(e.target.value || null)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type {
|
|||
CheckFormatResponse,
|
||||
LocalDatasetsResponse,
|
||||
} from "../types/datasets";
|
||||
import { authFetch } from "@/features/auth";
|
||||
|
||||
type CheckDatasetFormatArgs = {
|
||||
datasetName: string;
|
||||
|
|
@ -18,7 +19,7 @@ export async function checkDatasetFormat({
|
|||
split,
|
||||
isVlm,
|
||||
}: CheckDatasetFormatArgs): Promise<CheckFormatResponse> {
|
||||
const res = await fetch("/api/datasets/check-format", {
|
||||
const res = await authFetch("/api/datasets/check-format", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
|
|
@ -39,7 +40,7 @@ export async function checkDatasetFormat({
|
|||
}
|
||||
|
||||
export async function listLocalDatasets(): Promise<LocalDatasetsResponse> {
|
||||
const res = await fetch("/api/datasets/local");
|
||||
const res = await authFetch("/api/datasets/local");
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.detail || `Request failed (${res.status})`);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@ import type { TrainingStartRequest } from "../types/api";
|
|||
const BACKEND_LORA_TYPE = "LoRA/QLoRA";
|
||||
const BACKEND_FULL_TYPE = "Full Finetuning";
|
||||
|
||||
function parseSliceValue(value: string | null): number | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const num = Number(trimmed);
|
||||
if (!Number.isFinite(num) || !Number.isInteger(num)) return null;
|
||||
return num;
|
||||
}
|
||||
|
||||
export function toBackendTrainingType(trainingMethod: string): string {
|
||||
return trainingMethod === "full" ? BACKEND_FULL_TYPE : BACKEND_LORA_TYPE;
|
||||
}
|
||||
|
|
@ -31,6 +40,8 @@ export function buildTrainingStartPayload(
|
|||
subset: hfDataset ? config.datasetSubset : null,
|
||||
train_split: hfDataset ? config.datasetSplit : null,
|
||||
eval_split: hfDataset ? config.datasetEvalSplit : null,
|
||||
dataset_slice_start: parseSliceValue(config.datasetSliceStart),
|
||||
dataset_slice_end: parseSliceValue(config.datasetSliceEnd),
|
||||
local_datasets: localDatasets,
|
||||
format_type: config.datasetFormat,
|
||||
custom_format_mapping: customFormatMapping,
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export function HfDatasetSubsetSplitSelectors({
|
|||
: "rounded-lg border border-amber-200 bg-amber-50 px-3.5 py-2.5 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400"
|
||||
}
|
||||
>
|
||||
Could not fetch dataset splits: {error}
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,19 @@ const ROLE_REMAP: Record<string, Record<string, string>> = {
|
|||
sharegpt: { user: "human", assistant: "gpt", system: "system" },
|
||||
};
|
||||
|
||||
function normalizeTrainingStartError(message: string): string {
|
||||
const normalized = message.toLowerCase();
|
||||
const isLegacyDatasetScriptError =
|
||||
normalized.includes("failed to check dataset format") &&
|
||||
normalized.includes("dataset scripts are no longer supported");
|
||||
|
||||
if (isLegacyDatasetScriptError) {
|
||||
return "This Hub dataset relies on a legacy custom script and isn’t supported in this training flow.";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
export function useTrainingActions() {
|
||||
const isStarting = useTrainingRuntimeStore((state) => state.isStarting);
|
||||
const startError = useTrainingRuntimeStore((state) => state.startError);
|
||||
|
|
@ -79,7 +92,9 @@ export function useTrainingActions() {
|
|||
const response = await startTraining(payload);
|
||||
|
||||
if (response.status === "error") {
|
||||
runtimeStore.setStartError(response.error || response.message);
|
||||
const rawMessage = response.error || response.message;
|
||||
const safeMessage = normalizeTrainingStartError(rawMessage);
|
||||
runtimeStore.setStartError(safeMessage);
|
||||
runtimeStore.setStarting(false);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -88,9 +103,10 @@ export function useTrainingActions() {
|
|||
await syncTrainingRuntimeFromBackend();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message =
|
||||
const rawMessage =
|
||||
error instanceof Error ? error.message : "Failed to start training";
|
||||
runtimeStore.setStartError(message);
|
||||
const safeMessage = normalizeTrainingStartError(rawMessage);
|
||||
runtimeStore.setStartError(safeMessage);
|
||||
runtimeStore.setStarting(false);
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ const initialState: TrainingConfigState = {
|
|||
datasetSplit: null,
|
||||
datasetEvalSplit: null,
|
||||
datasetManualMapping: emptyManualMapping(),
|
||||
datasetSliceStart: null,
|
||||
datasetSliceEnd: null,
|
||||
uploadedFile: null,
|
||||
isCheckingVision: false,
|
||||
isVisionModel: false,
|
||||
|
|
@ -255,6 +257,8 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
|
|||
datasetSplit: null,
|
||||
datasetEvalSplit: null,
|
||||
datasetManualMapping: emptyManualMapping(),
|
||||
datasetSliceStart: null,
|
||||
datasetSliceEnd: null,
|
||||
isDatasetMultimodal: null,
|
||||
isCheckingDataset: false,
|
||||
});
|
||||
|
|
@ -311,6 +315,8 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
|
|||
},
|
||||
setDatasetManualMapping: (datasetManualMapping) =>
|
||||
set({ datasetManualMapping }),
|
||||
setDatasetSliceStart: (datasetSliceStart) => set({ datasetSliceStart }),
|
||||
setDatasetSliceEnd: (datasetSliceEnd) => set({ datasetSliceEnd }),
|
||||
setUploadedFile: (uploadedFile) => {
|
||||
_datasetCheckController?.abort();
|
||||
_datasetCheckController = null;
|
||||
|
|
@ -321,6 +327,8 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
|
|||
datasetSplit: null,
|
||||
datasetEvalSplit: null,
|
||||
datasetManualMapping: emptyManualMapping(),
|
||||
datasetSliceStart: null,
|
||||
datasetSliceEnd: null,
|
||||
isDatasetMultimodal: null,
|
||||
isCheckingDataset: false,
|
||||
});
|
||||
|
|
@ -381,7 +389,7 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
|
|||
},
|
||||
{
|
||||
name: "unsloth_training_config_v1",
|
||||
version: 6,
|
||||
version: 7,
|
||||
migrate: (persisted, version) => {
|
||||
const s = persisted as Record<string, unknown>;
|
||||
if (version < 2 && s.datasetSubset == null && s.datasetConfig != null) {
|
||||
|
|
@ -400,6 +408,10 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
|
|||
if (version < 6 && s.datasetEvalSplit == null) {
|
||||
s.datasetEvalSplit = null;
|
||||
}
|
||||
if (version < 7) {
|
||||
s.datasetSliceStart ??= null;
|
||||
s.datasetSliceEnd ??= null;
|
||||
}
|
||||
return s as unknown as TrainingConfigStore;
|
||||
},
|
||||
partialize: partializePersistedState,
|
||||
|
|
|
|||
|
|
@ -187,7 +187,6 @@ export const useTrainingRuntimeStore = create<TrainingRuntimeStore>()((set) => (
|
|||
evalEnabled: payload.eval_enabled ?? state.evalEnabled,
|
||||
message: payload.message,
|
||||
error: payload.error,
|
||||
startError: null,
|
||||
currentStep:
|
||||
typeof detailStep === "number" ? Math.max(detailStep, 0) : state.currentStep,
|
||||
totalSteps:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ export interface TrainingStartRequest {
|
|||
subset: string | null;
|
||||
train_split: string | null;
|
||||
eval_split: string | null;
|
||||
dataset_slice_start: number | null;
|
||||
dataset_slice_end: number | null;
|
||||
local_datasets: string[];
|
||||
format_type: string;
|
||||
custom_format_mapping?: Record<string, string> | null;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ export interface TrainingConfigState {
|
|||
datasetSplit: string | null;
|
||||
datasetEvalSplit: string | null;
|
||||
datasetManualMapping: DatasetManualMapping;
|
||||
datasetSliceStart: string | null;
|
||||
datasetSliceEnd: string | null;
|
||||
uploadedFile: string | null;
|
||||
epochs: number;
|
||||
contextLength: number;
|
||||
|
|
@ -84,6 +86,8 @@ export interface TrainingConfigActions {
|
|||
setDatasetSplit: (split: string | null) => void;
|
||||
setDatasetEvalSplit: (split: string | null) => void;
|
||||
setDatasetManualMapping: (mapping: DatasetManualMapping) => void;
|
||||
setDatasetSliceStart: (value: string | null) => void;
|
||||
setDatasetSliceEnd: (value: string | null) => void;
|
||||
setUploadedFile: (file: string | null) => void;
|
||||
setEpochs: (epochs: number) => void;
|
||||
setContextLength: (length: number) => void;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export type CheckFormatResponse = {
|
|||
total_rows?: number | null;
|
||||
is_multimodal?: boolean;
|
||||
multimodal_columns?: string[] | null;
|
||||
warning?: string | null;
|
||||
};
|
||||
|
||||
export type LocalDatasetInfo = {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,36 @@ export interface HfDatasetSplitsResult {
|
|||
|
||||
const HF_SPLITS_API = "https://datasets-server.huggingface.co/splits";
|
||||
|
||||
function normalizeDatasetSplitsError(message: string): string {
|
||||
const normalized = message.toLowerCase();
|
||||
|
||||
// datasets-server returns technical script/runtime details for legacy datasets.
|
||||
if (
|
||||
normalized.includes("dataset scripts are no longer supported") ||
|
||||
normalized.includes("runs arbitrary python code")
|
||||
) {
|
||||
return "We can’t load subset/split options for this Hub dataset because it relies on a legacy custom script.";
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes("unauthorized") ||
|
||||
normalized.includes("forbidden") ||
|
||||
normalized.includes("access token") ||
|
||||
normalized.includes("private") ||
|
||||
normalized.includes("gated") ||
|
||||
normalized.includes("401") ||
|
||||
normalized.includes("403")
|
||||
) {
|
||||
return "Unable to load dataset splits. This dataset may be private or gated. Add a Hugging Face token with access and try again.";
|
||||
}
|
||||
|
||||
if (normalized.includes("not found") || normalized.includes("404")) {
|
||||
return "Dataset not found. Check the dataset name and try again.";
|
||||
}
|
||||
|
||||
return "Unable to load dataset split options for this dataset.";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -101,7 +131,18 @@ export function useHfDatasetSplits(
|
|||
})
|
||||
.catch((err) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(err.message || "Failed to fetch dataset splits");
|
||||
const rawErrorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: typeof err === "string"
|
||||
? err
|
||||
: "Failed to fetch dataset splits";
|
||||
console.warn("[useHfDatasetSplits] Failed to fetch dataset splits", {
|
||||
datasetName,
|
||||
message: rawErrorMessage,
|
||||
error: err,
|
||||
});
|
||||
setError(normalizeDatasetSplitsError(rawErrorMessage));
|
||||
setEntries([]);
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -270,6 +270,9 @@
|
|||
@apply font-sans;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
body[data-scroll-locked] {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue