unsloth/studio/frontend/src/features/images/api.ts
Daniel Han 8ad8a58742 Add grad norm chart, clearer completion state, Windows caption keys, GGUF compute copy
- Trainers emit the pre-clip gradient norm; the service keeps a bounded
  grad_norm history and the Train tab renders a Grad Norm chart next to
  Loss and LR
- Completed runs show 'Training complete' with a celebratory marker in
  the success color instead of a plain status word
- metadata.jsonl caption keys now match on Windows (as_posix relative
  paths) in both the trainer discovery and the dataset image records
- RMSNorm eager patch skips installation on torch builds without
  F.rms_norm instead of failing at forward time
- GGUF compute description no longer says the GGUF is dequantised: the
  INT8/FP8/FP4 modes load the base model's bf16 transformer and quantise
  that directly; label no longer wraps in the Advanced panel
2026-07-04 04:31:04 +00:00

516 lines
17 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { authFetch } from "@/features/auth";
import { readFastApiError } from "@/lib/format-fastapi-error";
export interface DiffusionStatus {
loaded: boolean;
repo_id: string | null;
family: string | null;
base_repo: string | null;
device: string | null;
dtype: string | null;
// Resolved load kind: "gguf" | "single_file" | "pipeline". Gates GGUF-only controls
// (the dense transformer_quant fast path only engages on gguf). Null when not loaded.
model_kind?: string | null;
cpu_offload: boolean;
// Image workflows the loaded family supports (drives tab gating): txt2img, img2img,
// inpaint. Absent/empty when nothing is loaded or on the native sd.cpp engine.
workflows?: string[];
// Whether the loaded model + quantisation can apply LoRA adapters (drives the LoRA
// picker's enabled state). False on unsupported families/quant.
supports_lora?: boolean;
// Whether the loaded model can apply a ControlNet (drives the ControlNet picker's enabled
// state). Diffusers only, for families with a ControlNet pipeline; false otherwise.
supports_controlnet?: boolean;
}
export interface DiffusionGenerateProgress {
active: boolean;
step: number;
total_steps: number;
fraction: number;
eta_seconds: number | null;
}
export interface DiffusionLoadProgress {
phase: "downloading" | "finalizing" | "ready" | "error" | null;
bytes_downloaded: number;
bytes_total: number;
fraction: number;
error: string | null;
}
export interface DiffusionLoadRequest {
model_path: string;
// Optional now: required for the gguf / single_file kinds, omitted for a full
// pipeline (a diffusers repo loaded via from_pretrained).
gguf_filename?: string;
// How to load the model (omit to auto-detect from gguf_filename): "gguf" (single-file
// GGUF transformer), "single_file" (single-file safetensors transformer, e.g. fp8), or
// "pipeline" (a full diffusers repo). Non-GGUF kinds are restricted to unsloth/* repos.
model_kind?: "gguf" | "single_file" | "pipeline";
base_repo?: string;
family_override?: string;
hf_token?: string;
cpu_offload?: boolean;
// Advanced (load-time) tuning. All optional; omit for the backend's auto defaults.
speed_mode?: "off" | "eager" | "default" | "max";
transformer_quant?: "auto" | "int8" | "fp8" | "nvfp4" | "mxfp8";
attention_backend?:
| "auto"
| "native"
| "cudnn"
| "flash"
| "flash2"
| "flash3"
| "flash4"
| "sage"
| "xformers"
| "aiter";
memory_mode?: "auto" | "fast" | "balanced" | "low_vram";
transformer_cache?: "off" | "fbcache";
}
export interface DiffusionGenerateRequest {
prompt: string;
negative_prompt?: string;
width?: number;
height?: number;
steps?: number;
guidance?: number;
seed?: number;
batch_size?: number;
// Image-conditioned workflows. init_image alone = img2img; init_image + mask_image =
// inpaint. Base64 or data-URL. strength is the denoise amount (0 keeps source, 1 redraws).
init_image?: string;
mask_image?: string;
strength?: number;
// Upscale (hires fix): factor > 1 with an init_image enlarges the source and re-denoises
// it at low strength. Requires init_image; ignored for txt2img/inpaint/edit.
upscale?: number;
// Additional reference images for the FLUX.2 reference workflow, combined with init_image.
reference_images?: string[];
// LoRA adapters to apply for this generation (by discovery id + weight, 0..2). Omitted
// or empty applies none. Rejected (400) when the loaded model/quant can't apply LoRA.
loras?: LoraSpecInput[];
// ControlNet conditioning for this generation. Omitted applies none. Rejected (400) when
// the loaded model/quant can't apply ControlNet.
controlnet?: ControlNetSpecInput;
}
// One LoRA selection sent with a generation.
export interface LoraSpecInput {
id: string;
weight: number;
}
// A ControlNet selection sent with a generation.
export interface ControlNetSpecInput {
id: string;
// Base64/data-URL control image (a source image or an already-made control map).
image: string;
// "canny" preprocesses edges from a source image; any other type (passthrough, or a
// union type like depth/pose) is an already-made map the backend maps to a control mode.
control_type: string;
strength: number;
guidance_start?: number;
guidance_end?: number;
}
// A discoverable ControlNet model (from GET /api/models/diffusion-controlnets).
export interface DiffusionControlNetInfo {
id: string;
display_name: string;
source: "local" | "hub";
families: string[];
control_types: string[];
is_union: boolean;
}
// A discoverable diffusion LoRA adapter (from GET /api/models/diffusion-loras).
export interface DiffusionLoraInfo {
id: string;
display_name: string;
source: "local" | "hub";
format: "safetensors" | "gguf";
families: string[];
size_bytes: number;
weight_default: number;
}
// A persisted image's full generation recipe (also embedded in the PNG).
export interface GalleryImage {
id: string;
url: string;
prompt: string;
negative_prompt: string | null;
width: number;
height: number;
steps: number;
guidance: number;
seed: number;
batch_index: number;
batch_size: number;
model: string | null;
loras?: string[];
controlnet?: string | null;
created_at: number;
}
export interface DiffusionGenerateResponse {
images: GalleryImage[];
}
async function parseJson<T>(response: Response): Promise<T> {
if (!response.ok) {
throw new Error(await readFastApiError(response));
}
return (await response.json()) as T;
}
export async function getDiffusionStatus(): Promise<DiffusionStatus> {
return parseJson(await authFetch("/api/inference/images/status"));
}
export async function getDiffusionLoadProgress(): Promise<DiffusionLoadProgress> {
return parseJson(await authFetch("/api/inference/images/load-progress"));
}
export async function getGenerateProgress(): Promise<DiffusionGenerateProgress> {
return parseJson(await authFetch("/api/inference/images/generate-progress"));
}
export async function loadDiffusionModel(body: DiffusionLoadRequest): Promise<DiffusionStatus> {
return parseJson(
await authFetch("/api/inference/images/load", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}
export async function generateDiffusionImage(
body: DiffusionGenerateRequest,
): Promise<DiffusionGenerateResponse> {
return parseJson(
await authFetch("/api/inference/images/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}
export async function unloadDiffusionModel(): Promise<DiffusionStatus> {
return parseJson(await authFetch("/api/inference/images/unload", { method: "POST" }));
}
/** List diffusion LoRA adapters, optionally filtered to a model family. */
export async function listDiffusionLoras(family?: string): Promise<DiffusionLoraInfo[]> {
const qs = family ? `?family=${encodeURIComponent(family)}` : "";
const data = await parseJson<{ loras: DiffusionLoraInfo[] }>(
await authFetch(`/api/models/diffusion-loras${qs}`),
);
return data.loras ?? [];
}
/** List diffusion ControlNet models, optionally filtered to a model family. */
export async function listDiffusionControlNets(
family?: string,
): Promise<DiffusionControlNetInfo[]> {
const qs = family ? `?family=${encodeURIComponent(family)}` : "";
const data = await parseJson<{ controlnets: DiffusionControlNetInfo[] }>(
await authFetch(`/api/models/diffusion-controlnets${qs}`),
);
return data.controlnets ?? [];
}
export interface GalleryPage {
images: GalleryImage[];
has_more: boolean;
}
export async function getGallery(offset = 0, limit = 50): Promise<GalleryPage> {
return parseJson(
await authFetch(`/api/inference/images/gallery?offset=${offset}&limit=${limit}`),
);
}
export async function deleteGalleryImage(id: string): Promise<void> {
const res = await authFetch(`/api/inference/images/gallery/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error(await readFastApiError(res));
}
export async function clearGallery(): Promise<void> {
const res = await authFetch("/api/inference/images/gallery", { method: "DELETE" });
if (!res.ok) throw new Error(await readFastApiError(res));
}
/** Fetch a gallery PNG (auth-protected, so it can't be a plain <img src>) and
* wrap it in an object URL. Callers must revoke the URL when done. */
export async function fetchGalleryObjectUrl(url: string): Promise<string> {
const res = await authFetch(url);
if (!res.ok) throw new Error(await readFastApiError(res));
return URL.createObjectURL(await res.blob());
}
// ── Diffusion LoRA training ───────────────────────────────────────────────────
// Mirrors DiffusionTrainingStartRequest on the backend; only the paths are required.
export interface DiffusionTrainingStartRequest {
base_model: string;
// Explicit family (sdxl / flux.1 / qwen-image / z-image). Optional: the backend
// resolves it from base_model when omitted, but the Train tab always sends it so a
// custom base still trains under the intended family.
model_family?: string | null;
data_dir: string;
output_dir: string;
instance_prompt?: string | null;
resolution?: number;
train_steps?: number;
learning_rate?: number;
train_batch_size?: number;
gradient_accumulation_steps?: number;
lora_rank?: number;
lora_alpha?: number | null;
lora_target_modules?: string[];
max_grad_norm?: number;
seed?: number;
mixed_precision?: "bf16" | "fp16" | "no";
gradient_checkpointing?: boolean;
lr_scheduler?: string;
// Forwarded to the pipeline's from_pretrained for a gated/private base repo (e.g. FLUX).
hf_token?: string | null;
}
// Paired step-indexed history arrays for the live loss + LR charts. `lr` entries may be
// null so a sparse learning-rate series still aligns with `steps` by index.
export interface DiffusionMetricHistory {
steps: number[];
loss: number[];
lr: Array<number | null>;
grad_norm: Array<number | null>;
}
// A snapshot of the current diffusion training job (GET /api/train/diffusion/status).
export interface DiffusionTrainingStatus {
active: boolean;
job_id: string | null;
status: string;
message: string;
step: number;
total_steps: number;
loss: number | null;
avg_loss: number | null;
learning_rate: number | null;
num_images: number | null;
in_model_load: boolean;
output_dir: string | null;
lora_path: string | null;
started_at: number | null;
updated_at: number | null;
// Where the trained adapter was mirrored into the Studio LoRA catalog, and the family /
// base it was trained from -- lets the Train tab deploy the adapter onto the right base.
catalog_path?: string | null;
family?: string | null;
base_model?: string | null;
// Live throughput + peak VRAM (from the trainer's progress events).
samples_per_second?: number | null;
peak_memory_gb?: number | null;
// Bounded step/loss/lr history for the live charts.
metric_history?: DiffusionMetricHistory | null;
}
export async function startDiffusionTraining(
body: DiffusionTrainingStartRequest,
): Promise<{ job_id: string; status: string }> {
return parseJson(
await authFetch("/api/train/diffusion/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}
export async function stopDiffusionTraining(): Promise<{ status: string }> {
return parseJson(await authFetch("/api/train/diffusion/stop", { method: "POST" }));
}
export async function getDiffusionTrainingStatus(): Promise<DiffusionTrainingStatus> {
return parseJson(await authFetch("/api/train/diffusion/status"));
}
// One image-dataset folder under the Studio datasets root (GET /api/train/diffusion/info).
export interface DiffusionDatasetSummary {
name: string;
path: string;
image_count: number;
caption_count: number;
}
// Per-family training defaults (from GET /api/train/diffusion/info families[], added by
// the DiT-trainer backend). Absent on older backends; the Train tab falls back to a
// hardcoded family list when it is.
export interface DiffusionTrainableFamily {
name: string;
label: string;
default_base: string;
base_repos: string[];
defaults?: {
lora_rank?: number;
learning_rate?: number;
resolution?: number;
train_steps?: number;
train_batch_size?: number;
mixed_precision?: "bf16" | "fp16" | "no";
} | null;
vram_note?: string | null;
gated?: boolean | null;
}
// Where diffusion training reads/writes on this Studio, plus usable dataset folders.
export interface DiffusionTrainingInfo {
datasets_root: string;
outputs_root: string;
datasets: DiffusionDatasetSummary[];
// Added by the multi-family trainer backend; tolerate its absence.
families?: DiffusionTrainableFamily[];
}
export async function getDiffusionTrainingInfo(): Promise<DiffusionTrainingInfo> {
return parseJson(await authFetch("/api/train/diffusion/info"));
}
export interface DiffusionDatasetUploadResult extends DiffusionDatasetSummary {
uploaded: number;
}
/** Upload images (+ optional caption .txt / metadata.jsonl) into a named dataset folder.
* Repeat uploads into the same name accumulate; the returned name is a valid data_dir
* for startDiffusionTraining. */
export async function uploadDiffusionDataset(
name: string,
files: File[],
): Promise<DiffusionDatasetUploadResult> {
const form = new FormData();
form.append("name", name);
for (const f of files) form.append("files", f);
return parseJson(
await authFetch("/api/train/diffusion/dataset", { method: "POST", body: form }),
);
}
// ── Dataset labeling + example imports (GET/PUT/DELETE .../dataset/{name}/...) ──
// One image in a training dataset folder, with its resolved caption. `caption_source`
// records where the caption came from ("metadata" beats a per-image "sidecar"; "none"
// when uncaptioned) so the labeling grid can highlight images that still need one.
export interface DiffusionDatasetImageRecord {
filename: string;
caption: string | null;
caption_source: "sidecar" | "metadata" | "none";
width: number;
height: number;
size_bytes: number;
}
export interface DiffusionDatasetImages {
name: string;
path: string;
images: DiffusionDatasetImageRecord[];
}
/** List every image in a dataset folder (including uncaptioned ones) for the grid. */
export async function listDiffusionDatasetImages(
name: string,
): Promise<DiffusionDatasetImages> {
return parseJson(
await authFetch(`/api/train/diffusion/dataset/${encodeURIComponent(name)}/images`),
);
}
/** Build the auth-protected thumbnail URL for a dataset image. Fetch it via
* fetchGalleryObjectUrl (Bearer auth) into an object URL; it can't be a plain <img src>. */
export function diffusionDatasetImageUrl(
name: string,
filename: string,
thumb = 256,
): string {
const q = thumb > 0 ? `?thumb=${thumb}` : "";
return `/api/train/diffusion/dataset/${encodeURIComponent(name)}/image/${encodeURIComponent(filename)}${q}`;
}
/** Write (or, when blank, clear) a per-image caption sidecar. Returns the updated record. */
export async function setDiffusionDatasetCaption(
name: string,
filename: string,
caption: string,
): Promise<DiffusionDatasetImageRecord> {
return parseJson(
await authFetch(
`/api/train/diffusion/dataset/${encodeURIComponent(name)}/caption/${encodeURIComponent(filename)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ caption }),
},
),
);
}
/** Delete an image (and its caption + thumbnail) from a dataset folder. */
export async function deleteDiffusionDatasetImage(
name: string,
filename: string,
): Promise<void> {
const res = await authFetch(
`/api/train/diffusion/dataset/${encodeURIComponent(name)}/image/${encodeURIComponent(filename)}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error(await readFastApiError(res));
}
// A curated, one-click-importable example image dataset. `license` is shown verbatim so
// users see the terms before importing; `suggested_trigger` seeds the trigger prompt.
export interface DiffusionDatasetExample {
id: string;
label: string;
repo: string;
description: string;
license: string;
image_cap: number;
suggested_trigger?: string | null;
}
export async function listDiffusionDatasetExamples(): Promise<DiffusionDatasetExample[]> {
const data = await parseJson<{ examples: DiffusionDatasetExample[] }>(
await authFetch("/api/train/diffusion/dataset-examples"),
);
return data.examples;
}
export interface DiffusionDatasetImportResult {
name: string;
path: string;
image_count: number;
caption_count: number;
imported: number;
license: string;
source_repo: string;
}
/** Materialize a curated example dataset (by id) into a Studio dataset folder. */
export async function importDiffusionDatasetExample(
id: string,
name?: string,
): Promise<DiffusionDatasetImportResult> {
return parseJson(
await authFetch("/api/train/diffusion/dataset/import-example", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, name }),
}),
);
}