miscallenous studio (#4293)

* miscallenous studio

* chore: upload dataset misc

* chore: redudancy studio cleanup

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: adress the pr comments

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: adress comments about recipes

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Wasim Yousef Said 2026-03-15 11:42:11 +01:00 committed by GitHub
commit e280b0bebc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 3229 additions and 1105 deletions

View file

@ -0,0 +1,124 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
from __future__ import annotations
import json
from pathlib import Path
from utils.paths import recipe_datasets_root, resolve_dataset_path
_DATA_DESIGNER_FOOTER = (
'<sub style="white-space: nowrap;">Made with ❤️ using 🎨 '
'<a href="https://github.com/NVIDIA-NeMo/DataDesigner">NeMo Data Designer</a></sub>'
)
_UNSLOTH_STUDIO_FOOTER = (
'<sub style="white-space: nowrap;">Made with ❤️ using 🦥 ' "Unsloth Studio</sub>"
)
class RecipeDatasetPublishError(ValueError):
"""Raised when a recipe dataset cannot be published to Hugging Face."""
def _resolve_recipe_artifact_path(artifact_path: str) -> Path:
root = recipe_datasets_root().expanduser().resolve()
candidate = resolve_dataset_path(artifact_path).expanduser()
resolved = candidate.resolve(strict = False)
try:
resolved.relative_to(root)
except ValueError as exc:
raise RecipeDatasetPublishError(
"This execution artifact is outside the Recipe Studio dataset storage."
) from exc
if not resolved.exists():
raise RecipeDatasetPublishError("Execution artifacts are no longer available.")
if not resolved.is_dir():
raise RecipeDatasetPublishError(
"Execution artifact path is not a dataset folder."
)
return resolved
def publish_recipe_dataset(
*,
artifact_path: str,
repo_id: str,
description: str,
hf_token: str | None = None,
private: bool = False,
) -> str:
dataset_path = _resolve_recipe_artifact_path(artifact_path)
try:
from data_designer.engine.storage.artifact_storage import (
FINAL_DATASET_FOLDER_NAME,
METADATA_FILENAME,
PROCESSORS_OUTPUTS_FOLDER_NAME,
SDG_CONFIG_FILENAME,
)
from data_designer.integrations.huggingface.client import (
HuggingFaceHubClient,
HuggingFaceHubClientUploadError,
)
from data_designer.integrations.huggingface.dataset_card import (
DataDesignerDatasetCard,
)
except ImportError as exc:
raise RecipeDatasetPublishError(
"NeMo Data Designer Hugging Face integration is not installed."
) from exc
try:
client = HuggingFaceHubClient(token = hf_token)
client._validate_repo_id(repo_id = repo_id)
client._validate_dataset_path(base_dataset_path = dataset_path)
client._create_or_get_repo(repo_id = repo_id, private = private)
metadata_path = dataset_path / METADATA_FILENAME
builder_config_path = dataset_path / SDG_CONFIG_FILENAME
with metadata_path.open(encoding = "utf-8") as fh:
metadata = json.load(fh)
builder_config = None
if builder_config_path.exists():
with builder_config_path.open(encoding = "utf-8") as fh:
builder_config = json.load(fh)
card = DataDesignerDatasetCard.from_metadata(
metadata = metadata,
builder_config = builder_config,
repo_id = repo_id,
description = description,
tags = None,
)
card.text = card.text.replace(_DATA_DESIGNER_FOOTER, _UNSLOTH_STUDIO_FOOTER)
# Data Designer currently drops the explicit token when pushing the
# dataset card. Push it ourselves so auth stays request-local.
card.push_to_hub(repo_id, token = hf_token, repo_type = "dataset")
client._upload_main_dataset_files(
repo_id = repo_id,
parquet_folder = dataset_path / FINAL_DATASET_FOLDER_NAME,
)
client._upload_images_folder(
repo_id = repo_id,
images_folder = dataset_path / "images",
)
client._upload_processor_files(
repo_id = repo_id,
processors_folder = dataset_path / PROCESSORS_OUTPUTS_FOLDER_NAME,
)
client._upload_config_files(
repo_id = repo_id,
metadata_path = metadata_path,
builder_config_path = builder_config_path,
)
return f"https://huggingface.co/datasets/{repo_id}"
except HuggingFaceHubClientUploadError as exc:
raise RecipeDatasetPublishError(str(exc)) from exc

View file

@ -187,6 +187,7 @@ class JobManager:
"has_analysis": job.analysis is not None,
"dataset_rows": None if job.dataset is None else len(job.dataset),
"artifact_path": job.artifact_path,
"execution_type": job.execution_type,
"started_at": job.started_at,
"finished_at": job.finished_at,
}
@ -445,6 +446,7 @@ class JobManager:
self._job.finished_at = time.time()
self._job.analysis = event.get("analysis")
self._job.artifact_path = event.get("artifact_path")
self._job.execution_type = event.get("execution_type")
self._job.dataset = event.get("dataset")
self._job.processor_artifacts = event.get("processor_artifacts")
if self._job.progress.total and self._job.progress.total > 0:

View file

@ -65,6 +65,7 @@ class Job:
analysis: dict[str, Any] | None = None
artifact_path: str | None = None
execution_type: str | None = None
dataset: list[dict[str, Any]] | None = None
processor_artifacts: dict[str, Any] | None = None
model_usage: dict[str, ModelUsage] = field(default_factory = dict)

View file

@ -131,7 +131,6 @@ def _apply_data_designer_image_context_patch() -> None:
def build_model_providers(recipe: dict[str, Any]):
from data_designer.config.default_model_settings import get_default_providers
from data_designer.config.models import ModelProvider
providers: list[ModelProvider] = []
@ -151,9 +150,30 @@ def build_model_providers(recipe: dict[str, Any]):
)
)
# DataDesigner currently expects at least one provider even if they only use static samplers,
# but it's fine it gives a warning only.
return providers or get_default_providers()
return providers
def _recipe_has_llm_columns(recipe: dict[str, Any]) -> bool:
for column in recipe.get("columns", []):
if not isinstance(column, dict):
continue
column_type = column.get("column_type")
if isinstance(column_type, str) and column_type.startswith("llm-"):
return True
return False
def _validate_recipe_runtime_support(
recipe: dict[str, Any],
model_providers: list[Any],
) -> None:
if not _recipe_has_llm_columns(recipe):
raise ValueError(
"Recipe Studio currently requires at least one AI generation step."
)
if not model_providers:
raise ValueError("Add a Provider connection block before running this recipe.")
def build_mcp_providers(
@ -243,9 +263,12 @@ def create_data_designer(
_apply_data_designer_image_context_patch()
from data_designer.interface.data_designer import DataDesigner
model_providers = build_model_providers(recipe)
_validate_recipe_runtime_support(recipe, model_providers)
return DataDesigner(
artifact_path = artifact_path,
model_providers = build_model_providers(recipe),
model_providers = model_providers,
mcp_providers = build_mcp_providers(recipe),
)

View file

@ -40,6 +40,33 @@ class JobCreateResponse(BaseModel):
job_id: str
class PublishDatasetRequest(BaseModel):
repo_id: str = Field(min_length = 3, description = "Hugging Face dataset repo ID")
description: str = Field(
min_length = 1,
max_length = 4000,
description = "Short dataset description for the dataset card",
)
hf_token: str | None = Field(
default = None,
description = "Optional Hugging Face token for private or write-protected repos",
)
private: bool = Field(
default = False,
description = "Create or update the dataset repo as private",
)
artifact_path: str | None = Field(
default = None,
description = "Execution artifact path captured by the UI for completed runs",
)
class PublishDatasetResponse(BaseModel):
success: bool = True
url: str
message: str
class SeedInspectRequest(BaseModel):
dataset_name: str = Field(min_length = 1)
hf_token: str | None = None

View file

@ -11,8 +11,17 @@ from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import ValidationError
from core.data_recipe.huggingface import (
RecipeDatasetPublishError,
publish_recipe_dataset,
)
from core.data_recipe.jobs import get_job_manager
from models.data_recipe import JobCreateResponse, RecipePayload
from models.data_recipe import (
JobCreateResponse,
PublishDatasetRequest,
PublishDatasetResponse,
RecipePayload,
)
router = APIRouter()
@ -125,6 +134,67 @@ def job_dataset(
}
@router.post(
"/jobs/{job_id}/publish",
response_class = JSONResponse,
response_model = PublishDatasetResponse,
)
def publish_job_dataset(job_id: str, payload: PublishDatasetRequest):
repo_id = payload.repo_id.strip()
description = payload.description.strip()
hf_token = payload.hf_token.strip() if isinstance(payload.hf_token, str) else None
artifact_path = (
payload.artifact_path.strip()
if isinstance(payload.artifact_path, str)
else None
)
if not repo_id:
raise HTTPException(status_code = 400, detail = "repo_id is required")
if not description:
raise HTTPException(status_code = 400, detail = "description is required")
mgr = get_job_manager()
status = mgr.get_status(job_id)
if status is not None:
if (
status.get("status") != "completed"
or status.get("execution_type") != "full"
):
raise HTTPException(
status_code = 409,
detail = "Only completed full runs can be published.",
)
status_artifact = status.get("artifact_path")
if isinstance(status_artifact, str) and status_artifact.strip():
artifact_path = status_artifact.strip()
if not artifact_path:
raise HTTPException(
status_code = 400,
detail = "This execution does not have publishable dataset artifacts.",
)
try:
url = publish_recipe_dataset(
artifact_path = artifact_path,
repo_id = repo_id,
description = description,
hf_token = hf_token or None,
private = payload.private,
)
except RecipeDatasetPublishError as exc:
raise HTTPException(status_code = 400, detail = str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code = 500, detail = str(exc)) from exc
return {
"success": True,
"url": url,
"message": f"Published dataset to {repo_id}.",
}
@router.get("/jobs/{job_id}/events")
async def job_events(request: Request, job_id: str):
mgr = get_job_manager()

View file

@ -5,7 +5,7 @@
"": {
"name": "unsloth-theme",
"dependencies": {
"@assistant-ui/react": "^0.12.10",
"@assistant-ui/react": "^0.12.17",
"@assistant-ui/react-markdown": "^0.12.3",
"@assistant-ui/react-streamdown": "^0.1.2",
"@base-ui/react": "^1.2.0",
@ -23,10 +23,10 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@streamdown/cjk": "^1.0.2",
"@streamdown/code": "^1.0.2",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@streamdown/cjk": "1.0.2",
"@streamdown/code": "1.0.2",
"@streamdown/math": "1.0.2",
"@streamdown/mermaid": "1.0.2",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-router": "^1.159.10",
"@tanstack/react-table": "^8.21.3",
@ -57,7 +57,7 @@
"remark-gfm": "^4.0.1",
"shadcn": "^3.8.4",
"sonner": "^2.0.7",
"streamdown": "^2.2.0",
"streamdown": "2.3.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
@ -88,17 +88,17 @@
"@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="],
"@assistant-ui/core": ["@assistant-ui/core@0.1.0", "", { "dependencies": { "@assistant-ui/tap": "^0.5.0", "assistant-stream": "^0.3.3", "nanoid": "^5.1.6" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-8fIhNjX5Qvdvl5Zu3u0dypEm6/zFSJMKDAyl5icP6zW/2NGy+/CtFlNSdtvJ+tloKevJR7kXmyyTyTuhZRg25g=="],
"@assistant-ui/core": ["@assistant-ui/core@0.1.5", "", { "dependencies": { "assistant-stream": "^0.3.5", "nanoid": "^5.1.6" }, "peerDependencies": { "@assistant-ui/store": "^0.2.2", "@assistant-ui/tap": "^0.5.2", "@types/react": "*", "assistant-cloud": "^0.1.21", "react": "^18 || ^19", "zustand": "^5.0.11" }, "optionalPeers": ["@types/react", "assistant-cloud", "react", "zustand"] }, "sha512-kLqFbRULZvE+hIwxGz705BW3QYhfwiVaVWoolfTGYkg+4xwah1PGuH0zqjXP5AMADtz+L69Lp+LX0xU9MQZ0DA=="],
"@assistant-ui/react": ["@assistant-ui/react@0.12.11", "", { "dependencies": { "@assistant-ui/core": "^0.1.0", "@assistant-ui/store": "^0.2.0", "@assistant-ui/tap": "^0.5.0", "@radix-ui/primitive": "^1.1.3", "@radix-ui/react-compose-refs": "^1.1.2", "@radix-ui/react-context": "^1.1.3", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-use-callback-ref": "^1.1.1", "@radix-ui/react-use-escape-keydown": "^1.1.1", "assistant-cloud": "^0.1.18", "assistant-stream": "^0.3.3", "nanoid": "^5.1.6", "react-textarea-autosize": "^8.5.9", "zod": "^4.3.6", "zustand": "^5.0.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^18 || ^19", "react-dom": "^18 || ^19" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-OATx2u8JqYZCUSuR4JuhDFs64IlF+cvyq6DpIv4ZpkZ8HHMkYhS1hame7oxHeJSf9taWO+RcKrVgmG8txNO0Vg=="],
"@assistant-ui/react": ["@assistant-ui/react@0.12.17", "", { "dependencies": { "@assistant-ui/core": "^0.1.5", "@assistant-ui/store": "^0.2.2", "@assistant-ui/tap": "^0.5.2", "@radix-ui/primitive": "^1.1.3", "@radix-ui/react-compose-refs": "^1.1.2", "@radix-ui/react-context": "^1.1.3", "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-use-callback-ref": "^1.1.1", "@radix-ui/react-use-escape-keydown": "^1.1.1", "assistant-cloud": "^0.1.21", "assistant-stream": "^0.3.4", "nanoid": "^5.1.6", "radix-ui": "^1.4.3", "react-textarea-autosize": "^8.5.9", "zod": "^4.3.6", "zustand": "^5.0.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^18 || ^19", "react-dom": "^18 || ^19" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t4Z8LatD3LQrtURLaYPG47r4iG7UQgkdoi5YEv+EhzvYiG8I7kAyV4SbnFH6sXPrnleV4IpBHAd8Wc7ynkQtsw=="],
"@assistant-ui/react-markdown": ["@assistant-ui/react-markdown@0.12.4", "", { "dependencies": { "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-use-callback-ref": "^1.1.1", "classnames": "^2.5.1", "react-markdown": "^10.1.0" }, "peerDependencies": { "@assistant-ui/react": "^0.12.11", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-6TD9guiuLJxJoOwSjNHUYAVma2ctDCG9uypUqKHE0OUhDwTDD3NsMvTnQ0n0Lh8nnCEwVglOwKKlSEYpV7SnWA=="],
"@assistant-ui/react-streamdown": ["@assistant-ui/react-streamdown@0.1.3", "", { "dependencies": { "rehype-harden": "^1.1.7", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "streamdown": "^2.1.0" }, "peerDependencies": { "@assistant-ui/react": "^0.12.11", "@streamdown/cjk": "^1.0.0", "@streamdown/code": "^1.0.0", "@streamdown/math": "^1.0.0", "@streamdown/mermaid": "^1.0.0", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@streamdown/cjk", "@streamdown/code", "@streamdown/math", "@streamdown/mermaid", "@types/react"] }, "sha512-n1UCjXQ3svmDtJBMJj/vXqz/BqAQBuy7myrXeymz2tD9l+ENQgqu2JY5ir3J19juJTe5lsi/P3+tOJ2C1jc/nw=="],
"@assistant-ui/store": ["@assistant-ui/store@0.2.0", "", { "dependencies": { "@assistant-ui/core": "^0.1.0", "@assistant-ui/tap": "^0.5.0", "use-effect-event": "^2.0.3" }, "peerDependencies": { "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-+8Oq7knxhYh1UAGOolvJRlFB3SkLcxnz971oA/iVAxgN/jpp1MH4h6xQwiLoYrwOtcQDSJOSuivoxrDKZdhFrA=="],
"@assistant-ui/store": ["@assistant-ui/store@0.2.2", "", { "dependencies": { "use-effect-event": "^2.0.3" }, "peerDependencies": { "@assistant-ui/tap": "^0.5.2", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-JzQseWFp3UmbByBSWQmiGi/bz5jbfru04hIgb2DJBpnnTyns8Zl+8wDPnwiYGF/6SA+IzTg5M0V1wf77rwU0dA=="],
"@assistant-ui/tap": ["@assistant-ui/tap@0.5.0", "", { "peerDependencies": { "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-UUWXTLtD5/iIs1hSDDF0Ieew2kna0G6RzIVqxlfy5Ei0qPGxJr90ICkPwjaMzELxT/JlL0u2eo+78wFUUBCMcA=="],
"@assistant-ui/tap": ["@assistant-ui/tap@0.5.2", "", { "peerDependencies": { "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-w6gXhr+mF6cPG6ZCnkqV4kkOHzR+Fb+52S4T34PnrH0cs8l2Gqlwo/kB9BcB9fGmjwL7izdwubQ7t2VBhWpz/Q=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
@ -628,7 +628,7 @@
"@streamdown/cjk": ["@streamdown/cjk@1.0.2", "", { "dependencies": { "remark-cjk-friendly": "^1.2.3", "remark-cjk-friendly-gfm-strikethrough": "^1.2.3", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-5OOuZjj2Lnae92Zmg2gA5hloSbcKj25gv+QY4iKbYI+iRsiGWbgmYxmgxNUSO9SR6BKOCy783UHN1HM/QEUpdw=="],
"@streamdown/code": ["@streamdown/code@1.0.3", "", { "dependencies": { "shiki": "^3.19.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-3Ym5TCLcGhrHY2qBaUVWpqNRtxnZvqh4Y5Qm/pTIKA4AmEWwAAoYjZnxG7mOsvOpWVWiDwETjUtchNL1XzQEAw=="],
"@streamdown/code": ["@streamdown/code@1.0.2", "", { "dependencies": { "shiki": "^3.19.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-QKLS3sC8no5y0YvhGLA+ZjtNhznWU09IvFcjRKgSA35ulckMLw3b5T1ha+o1DaW8BS8l0zceLPFZa3/X9+agWQ=="],
"@streamdown/math": ["@streamdown/math@1.0.2", "", { "dependencies": { "katex": "^0.16.27", "rehype-katex": "^7.0.1", "remark-math": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-r8Ur9/lBuFnzZAFdEWrLUF2s/gRwRRRwruqltdZibyjbCBnuW7SJbFm26nXqvpJPW/gzpBUMrBVBzd88z05D5g=="],
@ -846,7 +846,7 @@
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"assistant-cloud": ["assistant-cloud@0.1.18", "", { "dependencies": { "assistant-stream": "^0.3.3" } }, "sha512-6tq2jPGIBjkjsLQ/Fd4r6PGj4hf05oM2jBl4hBs7YIkaJ3qBVUWiHary2+faNpsPOoY71brsVukl/qz5B1rQkA=="],
"assistant-cloud": ["assistant-cloud@0.1.21", "", { "dependencies": { "assistant-stream": "^0.3.4" } }, "sha512-KZ9ZsF1i1zMhozvD4m8TsmTdtufqULMaqgOoSLRyVtnhwvxkDufL87tSjv7epddZ4kbebe31biWSg7KIlgzvQA=="],
"assistant-stream": ["assistant-stream@0.3.3", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-Ne/uTseMIiZx740dTbr/SWxONM8nYj4Z5BRmUfqQN+TNgtOCgWOlC/oTUQ+A7LIUHtmGbcoyZwDf8yd2RASnDA=="],
@ -2082,6 +2082,10 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@assistant-ui/core/assistant-stream": ["assistant-stream@0.3.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-OGxVClfpEOoSsJDraPoe+GYTwh9TJX1wxK3hT5Qs7gIOyD/MZbqwyWwabRO2KTnNU4w7usvmC/vneUzxSk4bBg=="],
"@assistant-ui/react/assistant-stream": ["assistant-stream@0.3.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-OGxVClfpEOoSsJDraPoe+GYTwh9TJX1wxK3hT5Qs7gIOyD/MZbqwyWwabRO2KTnNU4w7usvmC/vneUzxSk4bBg=="],
"@assistant-ui/react/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
@ -2278,6 +2282,8 @@
"ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"assistant-cloud/assistant-stream": ["assistant-stream@0.3.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-OGxVClfpEOoSsJDraPoe+GYTwh9TJX1wxK3hT5Qs7gIOyD/MZbqwyWwabRO2KTnNU4w7usvmC/vneUzxSk4bBg=="],
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],

View file

@ -13,7 +13,7 @@
"biome:fix": "biome check . --write"
},
"dependencies": {
"@assistant-ui/react": "^0.12.10",
"@assistant-ui/react": "^0.12.17",
"@assistant-ui/react-markdown": "^0.12.3",
"@assistant-ui/react-streamdown": "^0.1.2",
"@base-ui/react": "^1.2.0",

View file

@ -0,0 +1,88 @@
"use client";
import { useMessageTiming } from "@assistant-ui/react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { FC } from "react";
const formatTimingMs = (ms: number | undefined): string => {
if (ms === undefined) return "—";
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
};
/**
* Shows streaming stats (TTFT, total time, chunks) as a badge with a
* hover/focus tooltip. Renders nothing until the stream completes.
*
* Place it inside `ActionBarPrimitive.Root` in your `thread.tsx` so it
* inherits the action bar's autohide behaviour:
*
* ```tsx
* import { MessageTiming } from "@/components/assistant-ui/message-timing";
*
* <ActionBarPrimitive.Root >
* <ActionBarPrimitive.Copy />
* <ActionBarPrimitive.Reload />
* <MessageTiming /> // <-- add this
* </ActionBarPrimitive.Root>
* ```
*
* @param side - Side of the tooltip relative to the badge trigger. Defaults to `"right"`.
*/
export const MessageTiming: FC<{
className?: string;
side?: "top" | "right" | "bottom" | "left";
}> = ({ className, side = "right" }) => {
const timing = useMessageTiming();
if (timing?.totalStreamTime === undefined) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-slot="message-timing-trigger"
aria-label="Message timing"
className={cn(
"flex items-center rounded-md p-1 font-mono text-muted-foreground text-xs tabular-nums transition-colors hover:bg-accent hover:text-accent-foreground",
className,
)}
>
{formatTimingMs(timing.totalStreamTime)}
</button>
</TooltipTrigger>
<TooltipContent
side={side}
sideOffset={8}
data-slot="message-timing-popover"
className="[&_span>svg]:hidden! rounded-lg border bg-popover px-3 py-2 text-popover-foreground shadow-md"
>
<div className="grid min-w-35 gap-1.5 text-xs">
{timing.firstTokenTime !== undefined && (
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">First token</span>
<span className="font-mono tabular-nums">
{formatTimingMs(timing.firstTokenTime)}
</span>
</div>
)}
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Total</span>
<span className="font-mono tabular-nums">
{formatTimingMs(timing.totalStreamTime)}
</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Chunks</span>
<span className="font-mono tabular-nums">{timing.totalChunks}</span>
</div>
</div>
</TooltipContent>
</Tooltip>
);
};

View file

@ -6,6 +6,7 @@ import {
ComposerAttachments,
UserMessageAttachments,
} from "@/components/assistant-ui/attachment";
import { MessageTiming } from "@/components/assistant-ui/message-timing";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
@ -401,6 +402,7 @@ const AssistantActionBar: FC = () => {
<RefreshCwIcon />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
<MessageTiming side="top" />
<ActionBarMorePrimitive.Root>
<ActionBarMorePrimitive.Trigger asChild={true}>
<TooltipIconButton

View file

@ -63,7 +63,10 @@ export function Navbar() {
<header className="relative top-0 z-40 h-16 w-full">
<div className="mx-auto grid h-full max-w-7xl grid-cols-[1fr_auto_1fr] items-center px-4 sm:px-6">
{/* Left: logo */}
<Link to="/studio" className="flex items-center justify-self-start select-none">
<Link
to="/studio"
className="flex items-center justify-self-start gap-2 select-none"
>
<img
src="/blacklogo.png"
alt="Unsloth"
@ -74,6 +77,9 @@ export function Navbar() {
alt="Unsloth"
className="hidden h-9 w-auto dark:block"
/>
<span className="text-[10px] font-extrabold tracking-[0.12em] text-primary">
BETA
</span>
</Link>
{/* Center: pill nav */}

View file

@ -6,6 +6,18 @@ import * as React from "react";
import { cn } from "@/lib/utils";
const THUMB_SIZE_PX = 16;
function getThumbInBoundsOffset(width: number, percent: number) {
const halfWidth = width / 2;
const halfPercent = 50;
if (percent <= 0) return halfWidth;
if (percent >= 100) return -halfWidth;
return halfWidth - (percent / halfPercent) * halfWidth;
}
function Slider({
className,
defaultValue,
@ -32,11 +44,6 @@ function Slider({
},
[isControlled, onValueChange],
);
// For single-thumb horizontal sliders, render the fill bar as a sibling of
// the track (outside its overflow-hidden container) so it can align flush
// with the thumb center without being clipped. The Range inside the track
// is hidden in this case to avoid double-painting.
const isSingleThumbHorizontal =
values.length === 1 && orientation === "horizontal";
const fillPercent = isSingleThumbHorizontal
@ -48,6 +55,12 @@ function Slider({
),
)
: null;
const fillWidth =
fillPercent === null
? undefined
: fillPercent <= 0
? "0%"
: `calc(${fillPercent}% + ${getThumbInBoundsOffset(THUMB_SIZE_PX, fillPercent)}px)`;
return (
<SliderPrimitive.Root
@ -75,19 +88,22 @@ function Slider({
isSingleThumbHorizontal && "opacity-0",
)}
/>
{isSingleThumbHorizontal && (
<div
aria-hidden={true}
className={cn(
"absolute inset-y-0 left-0 bg-primary pointer-events-none",
fillPercent === 100 ? "rounded-4xl" : "rounded-l-4xl",
)}
style={{ width: fillWidth }}
/>
)}
</SliderPrimitive.Track>
{isSingleThumbHorizontal && (
<div
aria-hidden={true}
className="absolute inset-y-0 left-0 my-auto h-3 rounded-4xl bg-primary pointer-events-none"
style={{ width: `${fillPercent}%` }}
/>
)}
{Array.from({ length: values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 size-4 rounded-4xl border bg-white shadow-sm block shrink-0 select-none cursor-pointer disabled:pointer-events-none disabled:opacity-50 transition-transform duration-100 ease-out hover:scale-110 hover:ring-4 active:scale-95 focus-visible:ring-4 focus-visible:outline-hidden"
className="border-primary ring-ring/50 relative z-10 size-4 rounded-4xl border bg-white shadow-sm block shrink-0 select-none cursor-pointer disabled:pointer-events-none disabled:opacity-50 transition-transform duration-100 ease-out hover:scale-110 hover:ring-4 active:scale-95 focus-visible:ring-4 focus-visible:outline-hidden"
/>
))}
</SliderPrimitive.Root>

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import type { ChatModelAdapter } from "@assistant-ui/react";
import type { MessageTiming } from "@assistant-ui/core";
import { toast } from "sonner";
import { generateAudio, streamChatCompletions } from "./chat-api";
import { db } from "../db";
@ -17,6 +18,37 @@ type RunMessage = RunMessages[number];
/** Tracks which user messages were sent with an audio file (messageId → filename). */
export const sentAudioNames = new Map<string, string>();
function estimateTokenCount(text: string): number | undefined {
const trimmed = text.trim();
if (!trimmed) {
return undefined;
}
return Math.max(1, Math.round(trimmed.length / 4));
}
function buildTiming(
streamStartTime: number,
totalChunks: number,
firstTokenTime?: number,
totalStreamTime?: number,
tokenCount?: number,
): MessageTiming {
return {
streamStartTime,
firstTokenTime,
totalStreamTime,
tokenCount,
tokensPerSecond:
typeof totalStreamTime === "number" &&
totalStreamTime > 0 &&
typeof tokenCount === "number"
? tokenCount / (totalStreamTime / 1000)
: undefined,
totalChunks,
toolCallCount: 0,
};
}
function collectTextParts(message: RunMessage): string[] {
const textParts = message.content
.filter((part) => part.type === "text")
@ -227,6 +259,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
const threadKey = unstable_threadId || "__default";
let waitingFirstChunk = true;
let firstTokenSettled = false;
const streamStartTime = Date.now();
let firstTokenTime: number | undefined;
let totalChunks = 0;
let resolveFirstToken: (() => void) | null = null;
let rejectFirstToken: ((err: unknown) => void) | null = null;
const firstTokenPromise = new Promise<void>((resolve, reject) => {
@ -288,12 +323,14 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
);
for await (const chunk of stream) {
totalChunks += 1;
const delta = chunk.choices?.[0]?.delta?.content;
if (!delta) {
continue;
}
if (waitingFirstChunk) {
waitingFirstChunk = false;
firstTokenTime = Date.now() - streamStartTime;
settleFirstTokenOk();
}
@ -310,11 +347,30 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
if (parts.length > 0) {
yield {
content: parts,
metadata: { custom: { reasoningDuration } },
metadata: {
timing: buildTiming(
streamStartTime,
totalChunks,
firstTokenTime,
),
custom: { reasoningDuration },
},
};
}
}
settleFirstTokenOk();
yield {
metadata: {
timing: buildTiming(
streamStartTime,
totalChunks,
firstTokenTime,
Date.now() - streamStartTime,
estimateTokenCount(cumulativeText),
),
custom: { reasoningDuration },
},
};
} catch (err) {
settleFirstTokenErr(err instanceof Error ? err : new Error("Generation failed"));
const isEarly = waitingFirstChunk;

View file

@ -15,6 +15,8 @@ db.version(1).stores({
recipes: "id, name, updatedAt, createdAt",
});
const recentRecipeCache = new Map<string, RecipeRecord>();
export function listRecipes(): Promise<RecipeRecord[]> {
return db.recipes.orderBy("updatedAt").reverse().toArray();
}
@ -23,6 +25,18 @@ export function getRecipe(id: string): Promise<RecipeRecord | undefined> {
return db.recipes.get(id);
}
function writeRecipeCache(record: RecipeRecord): void {
recentRecipeCache.set(record.id, record);
}
export function getCachedRecipe(id: string): RecipeRecord | null {
return recentRecipeCache.get(id) ?? null;
}
export function primeRecipeCache(record: RecipeRecord): void {
writeRecipeCache(record);
}
export async function saveRecipe(
input: SaveRecipeInput,
): Promise<RecipeRecord> {
@ -40,11 +54,13 @@ export async function saveRecipe(
input.learningRecipeTitle ?? existing?.learningRecipeTitle,
};
await db.recipes.put(record);
writeRecipeCache(record);
return record;
}
export async function deleteRecipe(id: string): Promise<void> {
await db.recipes.delete(id);
recentRecipeCache.delete(id);
}
export function createRecipeDraft(): Promise<RecipeRecord> {
@ -67,16 +83,29 @@ export function createRecipeFromLearningRecipe(input: {
});
}
export function useRecipes(): RecipeRecord[] {
export function useRecipes(): {
recipes: RecipeRecord[];
ready: boolean;
} {
const [recipes, setRecipes] = useState<RecipeRecord[]>([]);
const [ready, setReady] = useState(false);
useEffect(() => {
const sub = liveQuery(() => listRecipes()).subscribe({
next: (value) => setRecipes(value),
error: (error) => console.error("data-recipes liveQuery:", error),
next: (value) => {
for (const recipe of value) {
writeRecipeCache(recipe);
}
setRecipes(value);
setReady(true);
},
error: (error) => {
console.error("data-recipes liveQuery:", error);
setReady(true);
},
});
return () => sub.unsubscribe();
}, []);
return recipes;
return { recipes, ready };
}

View file

@ -41,15 +41,19 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { useNavigate } from "@tanstack/react-router";
import type { ReactElement } from "react";
import { useState } from "react";
import { useEffect, useState } from "react";
import {
createRecipeDraft,
createRecipeFromLearningRecipe,
deleteRecipe,
primeRecipeCache,
useRecipes,
} from "../data/recipes-db";
import { LEARNING_RECIPES } from "../learning-recipes";
const OPEN_LEARNING_RECIPES_ON_ARRIVAL_KEY =
"data-recipes:open-learning-recipes";
type TemplateCard = {
title: string;
description: string;
@ -293,13 +297,21 @@ function LearningRecipeCards({
export function DataRecipesPage(): ReactElement {
const navigate = useNavigate();
const recipes = useRecipes();
const { recipes, ready } = useRecipes();
const [creatingRecipe, setCreatingRecipe] = useState(false);
const [learningDialogOpen, setLearningDialogOpen] = useState(false);
const [loadingTemplateId, setLoadingTemplateId] = useState<string | null>(
null,
);
useEffect(() => {
if (sessionStorage.getItem(OPEN_LEARNING_RECIPES_ON_ARRIVAL_KEY) !== "1") {
return;
}
sessionStorage.removeItem(OPEN_LEARNING_RECIPES_ON_ARRIVAL_KEY);
setLearningDialogOpen(true);
}, []);
async function openNewRecipe(): Promise<void> {
if (creatingRecipe || loadingTemplateId) {
return;
@ -307,6 +319,7 @@ export function DataRecipesPage(): ReactElement {
setCreatingRecipe(true);
try {
const recipe = await createRecipeDraft();
primeRecipeCache(recipe);
await navigate({
to: "/data-recipes/$recipeId",
params: { recipeId: recipe.id },
@ -338,6 +351,7 @@ export function DataRecipesPage(): ReactElement {
templateTitle: recipeTemplate.title,
payload,
});
primeRecipeCache(recipe);
setLearningDialogOpen(false);
await navigate({
to: "/data-recipes/$recipeId",
@ -353,10 +367,11 @@ export function DataRecipesPage(): ReactElement {
}
}
function openRecipe(recipeId: string): void {
function openRecipe(recipe: (typeof recipes)[number]): void {
primeRecipeCache(recipe);
navigate({
to: "/data-recipes/$recipeId",
params: { recipeId },
params: { recipeId: recipe.id },
}).catch(() => undefined);
}
@ -407,7 +422,16 @@ export function DataRecipesPage(): ReactElement {
</DropdownMenu>
</div>
{recipes.length === 0 ? (
{!ready ? (
<div className="mt-8 rounded-2xl border border-border/70 bg-card px-6 py-10 text-center">
<p className="text-sm font-medium text-foreground">
Loading recipes
</p>
<p className="mt-1 text-xs text-muted-foreground">
Fetching your saved recipes and learning templates.
</p>
</div>
) : recipes.length === 0 ? (
<Empty className="mt-8 border border-dashed border-border/70">
<EmptyHeader>
<EmptyMedia variant="icon">
@ -448,7 +472,7 @@ export function DataRecipesPage(): ReactElement {
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 text-left"
onClick={() => openRecipe(recipe.id)}
onClick={() => openRecipe(recipe)}
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20">
<HugeiconsIcon
@ -490,7 +514,10 @@ export function DataRecipesPage(): ReactElement {
</main>
<Dialog open={learningDialogOpen} onOpenChange={setLearningDialogOpen}>
<DialogContent className="sm:max-w-5xl">
<DialogContent
className="sm:max-w-5xl"
overlayClassName="bg-background/45 supports-backdrop-filter:backdrop-blur-[1px]"
>
<DialogHeader>
<DialogTitle>Learning Recipes</DialogTitle>
<DialogDescription>

View file

@ -6,7 +6,7 @@ import { RecipeStudioPage, type RecipePayload } from "@/features/recipe-studio";
import { useNavigate } from "@tanstack/react-router";
import type { ReactElement } from "react";
import { useCallback, useEffect, useState } from "react";
import { getRecipe, saveRecipe } from "../data/recipes-db";
import { getCachedRecipe, getRecipe, primeRecipeCache, saveRecipe } from "../data/recipes-db";
import type { RecipeRecord } from "../types";
type EditRecipePageProps = {
@ -44,10 +44,23 @@ function RecipeLoadState({
export function EditRecipePage({ recipeId }: EditRecipePageProps): ReactElement {
const navigate = useNavigate();
const [loadState, setLoadState] = useState<LoadState>({ status: "loading" });
const [loadState, setLoadState] = useState<LoadState>(() => {
const cachedRecipe = getCachedRecipe(recipeId);
if (cachedRecipe) {
return { status: "ready", record: cachedRecipe };
}
return { status: "loading" };
});
useEffect(() => {
let active = true;
const cachedRecipe = getCachedRecipe(recipeId);
if (cachedRecipe) {
setLoadState({ status: "ready", record: cachedRecipe });
} else {
setLoadState({ status: "loading" });
}
void getRecipe(recipeId).then((record) => {
if (!active) {
return;
@ -56,6 +69,7 @@ export function EditRecipePage({ recipeId }: EditRecipePageProps): ReactElement
setLoadState({ status: "missing" });
return;
}
primeRecipeCache(record);
setLoadState({ status: "ready", record });
});
return () => {
@ -70,6 +84,7 @@ export function EditRecipePage({ recipeId }: EditRecipePageProps): ReactElement
name: input.name,
payload: input.payload,
});
primeRecipeCache(record);
return { id: record.id, updatedAt: record.updatedAt };
},
[recipeId],

View file

@ -13,6 +13,20 @@ export type JobCreateResponse = {
job_id: string;
};
export type PublishRecipeJobRequest = {
repo_id: string;
description: string;
hf_token?: string | null;
private?: boolean;
artifact_path?: string | null;
};
export type PublishRecipeJobResponse = {
success: boolean;
url: string;
message: string;
};
export type JobStatusResponse = {
// biome-ignore lint/style/useNamingConvention: api schema
job_id: string;
@ -271,6 +285,13 @@ export async function cancelRecipeJob(jobId: string): Promise<JobStatusResponse>
return postJson<JobStatusResponse>(`/jobs/${jobId}/cancel`, {});
}
export async function publishRecipeJob(
jobId: string,
payload: PublishRecipeJobRequest,
): Promise<PublishRecipeJobResponse> {
return postJson<PublishRecipeJobResponse>(`/jobs/${jobId}/publish`, payload);
}
export async function inspectSeedDataset(
payload: SeedInspectRequest,
): Promise<SeedInspectResponse> {

View file

@ -105,32 +105,32 @@ export type BlockDefinition = {
export const BLOCK_GROUPS: BlockGroup[] = [
{
kind: "sampler",
title: "Samplers",
description: "Fast deterministic columns from distributions and categories.",
title: "Generated fields",
description: "Create fields from lists, ranges, and reusable patterns.",
icon: DiceFaces03Icon,
},
{
kind: "seed",
title: "Seed",
description: "Bootstrap generation from an existing dataset.",
title: "Source data",
description: "Start from an existing dataset or file.",
icon: Plant01Icon,
},
{
kind: "llm",
title: "LLM + Models",
description: "Generation, model aliases, and shared tool profiles.",
title: "AI generation",
description: "Generate content, connect models, and manage tools.",
icon: PencilEdit02Icon,
},
{
kind: "validator",
title: "Validators",
description: "Validate generated code outputs with built-in engines.",
title: "Checks",
description: "Lint or filter generated code as it moves through the recipe.",
icon: Shield02Icon,
},
{
kind: "expression",
title: "Expression",
description: "Derive columns with Jinja templates.",
title: "Formulas",
description: "Build a field from other fields.",
icon: FunctionIcon,
},
{
@ -146,7 +146,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
kind: "seed",
type: "seed_hf",
title: "Hugging Face dataset",
description: "Load real rows from HF and use them as generation context.",
description: "Use rows from a Hugging Face dataset as source data.",
icon: Plant01Icon,
dialogKey: "seed",
createConfig: (id, existing) => makeSeedConfig(id, existing, "hf"),
@ -154,8 +154,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "seed",
type: "seed_local",
title: "Structured file",
description: "Upload CSV/JSON/JSONL and use rows as seed context.",
title: "CSV or JSON file",
description: "Upload CSV, JSON, or JSONL and use its rows as source data.",
icon: DocumentCodeIcon,
dialogKey: "seed",
createConfig: (id, existing) => makeSeedConfig(id, existing, "local"),
@ -163,8 +163,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "seed",
type: "seed_unstructured",
title: "Unstructured documents",
description: "Upload PDF/DOCX/TXT, chunk to text rows, then seed.",
title: "Document file",
description: "Upload PDF, DOCX, or TXT and turn it into source rows.",
icon: DocumentAttachmentIcon,
dialogKey: "seed",
createConfig: (id, existing) => makeSeedConfig(id, existing, "unstructured"),
@ -173,7 +173,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
kind: "sampler",
type: "category",
title: "Category",
description: "Define categorical values with optional weights and conditions.",
description: "Generate values from a list you define, with optional weights or rules.",
icon: Tag01Icon,
dialogKey: "category",
createConfig: (id, existing) => makeSamplerConfig(id, "category", existing),
@ -182,7 +182,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
kind: "sampler",
type: "subcategory",
title: "Subcategory",
description: "Define hierarchical values mapped to a parent category.",
description: "Generate values from groups you define for each category.",
icon: TagsIcon,
dialogKey: "subcategory",
createConfig: (id, existing) => makeSamplerConfig(id, "subcategory", existing),
@ -190,8 +190,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "sampler",
type: "uniform",
title: "Uniform",
description: "Sample evenly between low and high.",
title: "Random number",
description: "Generate a number anywhere between a minimum and maximum.",
icon: EqualSignIcon,
dialogKey: "uniform",
createConfig: (id, existing) => makeSamplerConfig(id, "uniform", existing),
@ -199,8 +199,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "sampler",
type: "gaussian",
title: "Gaussian",
description: "Sample from a normal distribution (mean/stddev).",
title: "Bell-curve number",
description: "Generate numbers around an average value.",
icon: Parabola02Icon,
dialogKey: "gaussian",
createConfig: (id, existing) => makeSamplerConfig(id, "gaussian", existing),
@ -208,8 +208,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "sampler",
type: "bernoulli",
title: "Bernoulli",
description: "Sample binary outcomes from probability p.",
title: "Yes/no value",
description: "Generate a binary result from a probability.",
icon: EqualSignIcon,
dialogKey: "bernoulli",
createConfig: (id, existing) => makeSamplerConfig(id, "bernoulli", existing),
@ -217,8 +217,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "sampler",
type: "datetime",
title: "Datetime",
description: "Sample timestamps within a start/end range.",
title: "Date and time",
description: "Generate timestamps inside a date range.",
icon: Clock01Icon,
dialogKey: "datetime",
createConfig: (id, existing) => makeSamplerConfig(id, "datetime", existing),
@ -226,8 +226,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "sampler",
type: "timedelta",
title: "Timedelta",
description: "Sample time offsets from a reference datetime column.",
title: "Time offset",
description: "Generate a time difference from another date field.",
icon: Clock01Icon,
dialogKey: "timedelta",
createConfig: (id, existing) => makeSamplerConfig(id, "timedelta", existing),
@ -235,8 +235,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "sampler",
type: "uuid",
title: "UUID",
description: "Generate unique identifiers with optional formatting.",
title: "Unique ID",
description: "Generate unique identifiers.",
icon: FingerPrintIcon,
dialogKey: "uuid",
createConfig: (id, existing) => makeSamplerConfig(id, "uuid", existing),
@ -244,8 +244,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "sampler",
type: "person",
title: "Person",
description: "Generate realistic synthetic people with faker attributes.",
title: "Synthetic person",
description: "Generate realistic person details.",
icon: UserAccountIcon,
dialogKey: "person",
createConfig: (id, existing) => makeSamplerConfig(id, "person", existing),
@ -253,8 +253,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "llm",
type: "text",
title: "LLM Text",
description: "Generate natural language text from prompt templates.",
title: "AI text",
description: "Generate text from your prompt.",
icon: PencilEdit02Icon,
dialogKey: "llm",
createConfig: (id, existing) => makeLlmConfig(id, "text", existing),
@ -262,8 +262,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "llm",
type: "structured",
title: "LLM Structured",
description: "Generate JSON constrained to a schema.",
title: "AI structured data",
description: "Generate JSON that follows a response format.",
icon: CodeIcon,
dialogKey: "llm",
createConfig: (id, existing) => makeLlmConfig(id, "structured", existing),
@ -271,8 +271,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "llm",
type: "code",
title: "LLM Code",
description: "Generate code in a chosen language with clean extraction.",
title: "AI code",
description: "Generate code in the language you choose.",
icon: CodeSimpleIcon,
dialogKey: "llm",
createConfig: (id, existing) => makeLlmConfig(id, "code", existing),
@ -280,8 +280,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "llm",
type: "judge",
title: "LLM Judge",
description: "Score generated outputs with rubric-based criteria.",
title: "AI scorer",
description: "Score outputs against your criteria.",
icon: BalanceScaleIcon,
dialogKey: "llm",
createConfig: (id, existing) => makeLlmConfig(id, "judge", existing),
@ -289,8 +289,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "llm",
type: "model_provider",
title: "Model Provider",
description: "Define endpoint and auth settings for model access.",
title: "Provider connection",
description: "Choose where model requests go and how to sign in.",
icon: Shield02Icon,
dialogKey: "model_provider",
createConfig: (id, existing) => makeModelProviderConfig(id, existing),
@ -298,8 +298,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "llm",
type: "model_config",
title: "Model Config",
description: "Bind alias to model, provider, and inference settings.",
title: "Model preset",
description: "Pick a model and save reusable generation settings.",
icon: Plant01Icon,
dialogKey: "model_config",
createConfig: (id, existing) => makeModelConfig(id, existing),
@ -307,8 +307,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "llm",
type: "tool_config",
title: "Tool Profile",
description: "Reusable MCP servers + allowed tools for one or more LLMs.",
title: "Tool access",
description: "Choose which tools an AI step can use.",
icon: Plug01Icon,
dialogKey: "tool_config",
createConfig: (id, existing) => makeToolProfileConfig(id, existing),
@ -316,8 +316,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "validator",
type: "validator_python",
title: "Python Validator",
description: "Validate Python code columns.",
title: "Python check",
description: "Lint generated Python and filter out rows that fail.",
icon: Shield02Icon,
dialogKey: "validator",
createConfig: (id, existing) =>
@ -326,8 +326,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "validator",
type: "validator_sql",
title: "SQL Validator",
description: "Validate SQL code columns.",
title: "SQL check",
description: "Lint generated SQL and filter out rows that fail.",
icon: Shield02Icon,
dialogKey: "validator",
createConfig: (id, existing) =>
@ -336,8 +336,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "validator",
type: "validator_oxc",
title: "OXC Validator",
description: "Validate JavaScript or TypeScript code columns.",
title: "JS/TS check",
description: "Lint generated JavaScript or TypeScript and filter out rows that fail.",
icon: Shield02Icon,
dialogKey: "validator",
createConfig: (id, existing) =>
@ -346,8 +346,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "expression",
type: "expression",
title: "Expression",
description: "Transform/combine columns using Jinja expressions.",
title: "Formula",
description: "Build or transform a field using other fields.",
icon: FunctionIcon,
dialogKey: "expression",
createConfig: (id, existing) => makeExpressionConfig(id, existing),
@ -355,8 +355,8 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "note",
type: "markdown_note",
title: "Markdown note",
description: "UI-only markdown notes on canvas, not sent to backend.",
title: "Note",
description: "Add a note to the canvas. Notes do not affect the run.",
icon: PencilEdit02Icon,
dialogKey: "markdown_note",
createConfig: (id, existing) => makeMarkdownNoteConfig(id, existing),

View file

@ -18,6 +18,7 @@ import {
Copy02Icon,
type Database02Icon,
DragDropVerticalIcon,
DocumentAttachmentIcon,
PlusSignIcon,
Search01Icon,
Tick02Icon,
@ -25,6 +26,7 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
useCallback,
type DragEvent as ReactDragEvent,
type ReactElement,
useMemo,
@ -38,6 +40,10 @@ import {
type BlockType,
type SeedBlockType,
} from "../blocks/registry";
import {
RECIPE_STUDIO_ONBOARDING_ICON_TONE,
RECIPE_STUDIO_ONBOARDING_SURFACE_TONE,
} from "../utils/ui-tones";
type SheetView =
| "root"
@ -94,27 +100,27 @@ export type RecipeBlockDragPayload = {
function getSheetTitle(sheetView: SheetView): string {
if (sheetView === "root") {
return "Add a block";
return "Add a step";
}
if (sheetView === "sampler") {
return "Sampler blocks";
return "Generated fields";
}
if (sheetView === "seed") {
return "Seed blocks";
return "Source data";
}
if (sheetView === "expression") {
return "Expression blocks";
return "Formulas";
}
if (sheetView === "validator") {
return "Validator blocks";
return "Checks";
}
if (sheetView === "note") {
return "Note blocks";
return "Notes";
}
if (sheetView === "processor") {
return "Processor blocks";
}
return "LLM blocks";
return "AI generation";
}
const VIEW_KIND: Record<SheetView, SheetKind | null> = {
@ -128,14 +134,10 @@ const VIEW_KIND: Record<SheetView, SheetKind | null> = {
processor: null,
};
const ROOT_GROUPS: RootGroup[] = [
...BLOCK_GROUPS,
{
kind: "processor",
title: "Processors",
description: "Output schema + post batch.",
icon: CodeIcon,
},
const ROOT_GROUPS: RootGroup[] = [...BLOCK_GROUPS];
const ROOT_GROUPS_WITH_SEED_FIRST: RootGroup[] = [
...ROOT_GROUPS.filter((group) => group.kind === "seed"),
...ROOT_GROUPS.filter((group) => group.kind !== "seed"),
];
const SEARCHABLE_KINDS: SheetKind[] = [
"sampler",
@ -145,8 +147,14 @@ const SEARCHABLE_KINDS: SheetKind[] = [
"expression",
"note",
];
const PROCESSOR_TITLE = "Schema Transform";
const PROCESSOR_DESCRIPTION = "Transform final dataset schema.";
const PROCESSOR_TITLE = "Final dataset shape";
const PROCESSOR_DESCRIPTION = "Rename, reorder, or reshape the final dataset.";
const SHOW_PROCESSOR_IN_BLOCK_SHEET = false;
const LLM_SETUP_TYPES = new Set<BlockType>([
"model_provider",
"model_config",
"tool_config",
]);
function BlockSheetButton({
icon,
@ -191,16 +199,20 @@ function BlockSheetButton({
<div className="flex size-9 items-center justify-center rounded-xl text-foreground/70">
<HugeiconsIcon icon={icon} className="size-5" />
</div>
<div className="flex-1">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-foreground">{title}</p>
<p className="break-words text-sm font-semibold text-foreground">
{title}
</p>
{badge ? (
<Badge variant="outline" className="rounded-full text-[10px]">
{badge}
</Badge>
) : null}
</div>
<p className="text-[11px] text-muted-foreground">{description}</p>
<p className="break-words text-[11px] text-muted-foreground">
{description}
</p>
</div>
{trailing === "chevron" ? (
<HugeiconsIcon
@ -258,9 +270,12 @@ export function BlockSheet({
}
onOpenChange?.(nextOpen);
};
const matchesSearch = (title: string, description: string) =>
title.toLowerCase().includes(normalizedSearch) ||
description.toLowerCase().includes(normalizedSearch);
const matchesSearch = useCallback(
(title: string, description: string) =>
title.toLowerCase().includes(normalizedSearch) ||
description.toLowerCase().includes(normalizedSearch),
[normalizedSearch],
);
const searchableBlocks = useMemo(
() => SEARCHABLE_KINDS.flatMap((kind) => getBlocksForKind(kind)),
@ -273,7 +288,7 @@ export function BlockSheet({
return searchableBlocks.filter((item) =>
matchesSearch(item.title, item.description),
);
}, [hasSearch, searchableBlocks, normalizedSearch]);
}, [hasSearch, matchesSearch, searchableBlocks]);
const scopedBlocks = useMemo(() => {
if (!isScopedBlockView) {
@ -284,11 +299,27 @@ export function BlockSheet({
return blocks;
}
return blocks.filter((item) => matchesSearch(item.title, item.description));
}, [hasSearch, isScopedBlockView, normalizedSearch, sheetView]);
}, [hasSearch, isScopedBlockView, matchesSearch, sheetView]);
const llmCreateBlocks =
sheetView === "llm"
? scopedBlocks.filter((item) => !LLM_SETUP_TYPES.has(item.type))
: [];
const llmSetupBlocks =
sheetView === "llm"
? scopedBlocks.filter((item) => LLM_SETUP_TYPES.has(item.type))
: [];
const featuredSeedBlock =
sheetView === "seed" && !hasSearch
? scopedBlocks.find((item) => item.type === "seed_unstructured") ?? null
: null;
const otherSeedBlocks =
sheetView === "seed" && !hasSearch
? scopedBlocks.filter((item) => item.type !== "seed_unstructured")
: scopedBlocks;
const rootGroups = useMemo(() => {
if (!hasSearch) {
return ROOT_GROUPS;
return ROOT_GROUPS_WITH_SEED_FIRST;
}
return ROOT_GROUPS.filter((group) => {
if (matchesSearch(group.title, group.description)) {
@ -301,7 +332,7 @@ export function BlockSheet({
matchesSearch(item.title, item.description),
);
});
}, [hasSearch, normalizedSearch]);
}, [hasSearch, matchesSearch]);
const showNoMatches =
(isRootView && hasSearch && rootSearchBlocks.length === 0) ||
(isScopedBlockView && scopedBlocks.length === 0) ||
@ -318,7 +349,7 @@ export function BlockSheet({
event.dataTransfer.setData("text/plain", serialized);
event.dataTransfer.effectAllowed = "copy";
};
const getTrailing = (_kind: SheetKind): "drag" => "drag";
const getTrailing = (): "drag" => "drag";
const onBlockClick = (kind: SheetKind, type: BlockType) => {
setSheetOpen(false);
if (kind === "sampler") {
@ -375,6 +406,8 @@ export function BlockSheet({
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
variant="ghost"
aria-label="Add a step"
title="Add a step"
>
<HugeiconsIcon
icon={PlusSignIcon}
@ -398,6 +431,8 @@ export function BlockSheet({
variant="ghost"
size="icon-sm"
onClick={() => onViewChange("root")}
aria-label="Back to step groups"
title="Back to step groups"
>
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
</Button>
@ -412,37 +447,68 @@ export function BlockSheet({
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search blocks..."
placeholder="Search steps..."
className="corner-squircle h-9 pl-8"
aria-label="Search steps"
/>
</div>
</SheetHeader>
<div className=" py-4">
<div className="flex-1 min-h-0 overflow-y-auto py-4">
<div className="mt-4 flex flex-col gap-2">
{isRootView && !hasSearch && (
<div className={`mx-3 mb-2 rounded-2xl border px-4 py-4 ${RECIPE_STUDIO_ONBOARDING_SURFACE_TONE}`}>
<div className="flex items-start gap-3">
<div className={`mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-xl ${RECIPE_STUDIO_ONBOARDING_ICON_TONE}`}>
<HugeiconsIcon
icon={DocumentAttachmentIcon}
className="size-4"
/>
</div>
<div className="min-w-0 flex-1 space-y-2">
<div>
<p className="text-sm font-semibold text-foreground">
Need a place to start?
</p>
<p className="text-xs text-muted-foreground">
Open Source data first, then add generation and checks
on top of it.
</p>
</div>
<Button
type="button"
size="sm"
variant="ghost"
className="corner-squircle justify-start px-0 text-primary hover:bg-transparent hover:text-primary/80"
onClick={() => onViewChange("seed")}
>
Start with source data
</Button>
</div>
</div>
</div>
)}
{isRootView &&
hasSearch &&
rootSearchBlocks.map((item, index) => (
rootSearchBlocks.map((item) => (
<BlockSheetButton
key={`${item.kind}:${item.type}`}
icon={item.icon}
title={item.title}
description={item.description}
isActive={index === 0}
draggable={true}
onDragStart={buildDragStart(item.kind, item.type)}
trailing={getTrailing(item.kind)}
trailing={getTrailing()}
onClick={() => onBlockClick(item.kind, item.type)}
/>
))}
{isRootView &&
!hasSearch &&
rootGroups.map((item, index) => (
rootGroups.map((item) => (
<BlockSheetButton
key={item.kind}
icon={item.icon}
title={item.title}
description={item.description}
isActive={index === 0}
draggable={item.kind === "expression" || item.kind === "note"}
onDragStart={
item.kind === "expression" && expressionBlocks[0]
@ -454,18 +520,9 @@ export function BlockSheet({
trailing={
item.kind === "expression" || item.kind === "note"
? "drag"
: item.kind === "processor"
? "none"
: "chevron"
: "chevron"
}
disabled={item.kind === "processor"}
badge={item.kind === "processor" ? "Work in progress" : undefined}
onClick={() => {
if (item.kind === "processor") {
setSheetOpen(false);
onOpenProcessors();
return;
}
if (item.kind === "seed" && seedBlocks.length === 1) {
setSheetOpen(false);
onAddSeed(seedBlocks[0].type as SeedBlockType);
@ -485,37 +542,175 @@ export function BlockSheet({
}}
/>
))}
{isProcessorView && (
{SHOW_PROCESSOR_IN_BLOCK_SHEET && isProcessorView && (
(!hasSearch ||
matchesSearch(PROCESSOR_TITLE, PROCESSOR_DESCRIPTION)) && (
<BlockSheetButton
icon={CodeIcon}
title={PROCESSOR_TITLE}
description={PROCESSOR_DESCRIPTION}
isActive={true}
onClick={onOpenProcessors}
/>
)
)}
{isScopedBlockView &&
sheetView === "seed" &&
featuredSeedBlock && (
<div className="pb-2">
<div className="px-3 pb-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Recommended first step
</p>
<p className="text-xs text-muted-foreground">
Best when you want to turn PDFs, DOCX files, or text
files into source rows.
</p>
</div>
<BlockSheetButton
icon={featuredSeedBlock.icon}
title={featuredSeedBlock.title}
description={featuredSeedBlock.description}
draggable={true}
onDragStart={buildDragStart(
featuredSeedBlock.kind,
featuredSeedBlock.type,
)}
trailing={getTrailing()}
badge="Start here"
onClick={() =>
onBlockClick(
featuredSeedBlock.kind,
featuredSeedBlock.type,
)
}
/>
</div>
)}
{isScopedBlockView &&
sheetView === "seed" &&
!hasSearch &&
otherSeedBlocks.length > 0 && (
<div className="px-3 pt-2 pb-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Other source options
</p>
<p className="text-xs text-muted-foreground">
Use a dataset or structured file when your source is
already tabular.
</p>
</div>
)}
{isScopedBlockView &&
sheetView === "llm" &&
llmCreateBlocks.length > 0 && (
<div className="px-3 pb-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Create
</p>
<p className="text-xs text-muted-foreground">
Start with the kind of output you want to generate.
</p>
</div>
)}
{isScopedBlockView &&
sheetView === "llm" &&
llmCreateBlocks.map((item) => (
<BlockSheetButton
key={item.type}
icon={item.icon}
title={item.title}
description={item.description}
draggable={true}
onDragStart={buildDragStart(item.kind, item.type)}
trailing={getTrailing()}
onClick={() => onBlockClick(item.kind, item.type)}
/>
))}
{isScopedBlockView &&
sheetView === "llm" &&
llmSetupBlocks.length > 0 && (
<div className="px-3 pt-4 pb-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Setup
</p>
<p className="text-xs text-muted-foreground">
Add these only when you need a new model or tool setup.
</p>
</div>
)}
{isScopedBlockView &&
sheetView === "llm" &&
llmSetupBlocks.map((item) => (
<BlockSheetButton
key={item.type}
icon={item.icon}
title={item.title}
description={item.description}
draggable={true}
onDragStart={buildDragStart(item.kind, item.type)}
trailing={getTrailing()}
onClick={() => onBlockClick(item.kind, item.type)}
/>
))}
{isScopedBlockView &&
sheetView === "seed" &&
otherSeedBlocks.map((item) => (
<BlockSheetButton
key={item.type}
icon={item.icon}
title={item.title}
description={item.description}
draggable={true}
onDragStart={buildDragStart(item.kind, item.type)}
trailing={getTrailing()}
onClick={() => onBlockClick(item.kind, item.type)}
/>
))}
{isScopedBlockView &&
sheetView !== "llm" &&
sheetView !== "seed" &&
scopedBlocks.map(
(item, index) => (
(item) => (
<BlockSheetButton
key={item.type}
icon={item.icon}
title={item.title}
description={item.description}
isActive={index === 0}
draggable={true}
onDragStart={buildDragStart(item.kind, item.type)}
trailing={getTrailing(item.kind)}
trailing={getTrailing()}
onClick={() => onBlockClick(item.kind, item.type)}
/>
),
)}
{SHOW_PROCESSOR_IN_BLOCK_SHEET && isRootView && !hasSearch && (
<div className="px-3 pt-3">
<button
type="button"
onClick={() => {
setSheetOpen(false);
onOpenProcessors();
}}
className="flex w-full items-center justify-between gap-3 rounded-xl border border-border/60 px-3 py-3 text-left transition hover:bg-muted/25"
>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">
Edit final dataset shape
</p>
<p className="break-words text-xs text-muted-foreground">
Rename, reorder, or reshape your final output.
</p>
</div>
<HugeiconsIcon
icon={CodeIcon}
className="size-4 text-muted-foreground"
/>
</button>
</div>
)}
{showNoMatches && (
<p className="px-3 py-2 text-xs text-muted-foreground">
No blocks match.
No matching steps.
</p>
)}
</div>
@ -528,6 +723,8 @@ export function BlockSheet({
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={onImport}
aria-label="Paste recipe JSON"
title="Paste recipe JSON"
>
<HugeiconsIcon
icon={Upload01Icon}
@ -540,6 +737,8 @@ export function BlockSheet({
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={onCopy}
aria-label={copied ? "Recipe JSON copied" : "Copy recipe JSON"}
title={copied ? "Recipe JSON copied" : "Copy recipe JSON"}
>
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy02Icon}

View file

@ -44,7 +44,7 @@ export function RunValidateFloatingControls({
disabled={validateLoading || executionLocked}
>
<HugeiconsIcon icon={TestTube01Icon} className="size-4" />
{validateLoading ? "Validating..." : "Validate"}
{validateLoading ? "Checking..." : "Check"}
</Button>
</div>
</div>

View file

@ -9,6 +9,7 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
@ -37,6 +38,8 @@ type ExecutionOverviewTabProps = {
terminalLines: string[];
terminalRef: RefObject<HTMLDivElement | null>;
onTerminalScroll: (event: UIEvent<HTMLDivElement>) => void;
canPublish: boolean;
onOpenPublish: () => void;
};
export function ExecutionOverviewTab({
@ -54,11 +57,26 @@ export function ExecutionOverviewTab({
terminalLines,
terminalRef,
onTerminalScroll,
canPublish,
onOpenPublish,
}: ExecutionOverviewTabProps): ReactElement {
return (
<div className="mt-3 space-y-3">
{showSummaryCards && (
<div className="space-y-3">
{canPublish && (
<div className="flex flex-col gap-3 rounded-xl border border-border/60 bg-card/55 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">Next step</p>
<p className="text-xs text-muted-foreground">
This run is complete. Publish the generated dataset to Hugging Face.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={onOpenPublish}>
Publish to Hugging Face
</Button>
</div>
)}
<div className="grid gap-3 md:grid-cols-2">
<div className="h-full rounded-xl border border-border/60 bg-card/55 p-3">
<div className="mb-2 flex items-center justify-between">

View file

@ -32,13 +32,13 @@ export function ExecutionSidebar({
<aside className="w-72 shrink-0 border-r border-border/60 bg-card/20">
<div className="flex items-center justify-between border-border/60 px-3 py-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Executions
Runs
</p>
</div>
<div className="h-[calc(100%-45px)] space-y-2 overflow-auto p-2">
{executions.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/60 p-3 text-xs text-muted-foreground">
No executions yet.
No runs yet.
</div>
) : (
executions.map((execution) => {

View file

@ -6,8 +6,10 @@ import type { ColumnDef } from "@tanstack/react-table";
import {
CheckmarkCircle02Icon,
Flag02Icon,
Share08Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { publishRecipeJob } from "../../api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
@ -23,6 +25,7 @@ import { ExecutionDataTab } from "./execution-data-tab";
import { ExecutionOverviewTab } from "./execution-overview-tab";
import { ExecutionRawTab } from "./execution-raw-tab";
import { ExecutionSidebar } from "./execution-sidebar";
import { PublishExecutionDialog } from "./publish-execution-dialog";
import {
PREVIEW_DATASET_PAGE_SIZE,
TERMINAL_STICKY_BOTTOM_THRESHOLD_PX,
@ -66,6 +69,7 @@ export function ExecutionsView({
const [previewDatasetPageByExecution, setPreviewDatasetPageByExecution] = useState<
Record<string, number>
>({});
const [publishDialogOpen, setPublishDialogOpen] = useState(false);
const terminalRef = useRef<HTMLDivElement | null>(null);
const shouldStickTerminalToBottomRef = useRef(true);
const selectedExecution = useMemo(
@ -183,6 +187,13 @@ export function ExecutionsView({
const canCancel = Boolean(
selectedExecution?.jobId && isExecutionInProgress(selectedExecution.status),
);
const canPublish = Boolean(
selectedExecution &&
selectedExecution.kind === "full" &&
selectedExecution.status === "completed" &&
selectedExecution.jobId &&
selectedExecution.artifact_path,
);
const datasetPage = selectedExecution?.datasetPage ?? 1;
const datasetPageSize = selectedExecution?.datasetPageSize ?? 20;
const datasetTotal = selectedExecution?.datasetTotal ?? 0;
@ -434,16 +445,29 @@ export function ExecutionsView({
<TabsTrigger value="data">Data</TabsTrigger>
<TabsTrigger value="raw">Raw</TabsTrigger>
</TabsList>
{canCancel && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => onCancelExecution(selectedExecution.id)}
>
Cancel
</Button>
)}
<div className="flex items-center gap-2">
{canPublish && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setPublishDialogOpen(true)}
>
<HugeiconsIcon icon={Share08Icon} className="mr-2 size-4" />
Publish to Hugging Face
</Button>
)}
{canCancel && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => onCancelExecution(selectedExecution.id)}
>
Cancel
</Button>
)}
</div>
</div>
<TabsContent value="overview">
<ExecutionOverviewTab
@ -460,6 +484,8 @@ export function ExecutionsView({
modelUsageRows={modelUsageRows}
terminalLines={terminalLines}
terminalRef={terminalRef}
canPublish={canPublish}
onOpenPublish={() => setPublishDialogOpen(true)}
onTerminalScroll={(event) => {
const element = event.currentTarget;
const distanceFromBottom =
@ -538,6 +564,18 @@ export function ExecutionsView({
</div>
)}
</section>
<PublishExecutionDialog
open={publishDialogOpen}
onOpenChange={setPublishDialogOpen}
execution={canPublish ? selectedExecution : null}
onPublish={async (payload) => {
if (!selectedExecution?.jobId) {
throw new Error("This run is missing a job id.");
}
const response = await publishRecipeJob(selectedExecution.jobId, payload);
return { url: response.url };
}}
/>
</div>
);
}

View file

@ -0,0 +1,345 @@
// 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 { useEffect, useMemo, useState, type ReactElement } from "react";
import { ArrowRight01Icon, CheckmarkCircle02Icon, Copy01Icon, Key01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { toastError, toastSuccess } from "@/shared/toast";
import type { RecipeExecutionRecord } from "../../execution-types";
import { copyTextToClipboard } from "../../executions/execution-helpers";
type PublishExecutionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
execution: RecipeExecutionRecord | null;
onPublish: (payload: {
repo_id: string;
description: string;
hf_token?: string | null;
private: boolean;
artifact_path?: string | null;
}) => Promise<{ url: string }>;
};
function getExecutionRecordCount(execution: RecipeExecutionRecord | null): number | null {
if (!execution) {
return null;
}
if (typeof execution.analysis?.num_records === "number") {
return execution.analysis.num_records;
}
if (execution.datasetTotal > 0) {
return execution.datasetTotal;
}
if (execution.rows > 0) {
return execution.rows;
}
return null;
}
function buildDefaultDescription(execution: RecipeExecutionRecord | null): string {
if (!execution) {
return "";
}
const runName = execution.run_name?.trim() || "This dataset";
const records = getExecutionRecordCount(execution);
const recordPart =
typeof records === "number" && records > 0
? ` It contains ${records.toLocaleString()} generated records.`
: "";
return `${runName} was generated with Unsloth Recipe Studio.${recordPart}`;
}
export function PublishExecutionDialog({
open,
onOpenChange,
execution,
onPublish,
}: PublishExecutionDialogProps): ReactElement {
const [repoId, setRepoId] = useState("");
const [description, setDescription] = useState("");
const [hfToken, setHfToken] = useState("");
const [privateRepo, setPrivateRepo] = useState(false);
const [publishing, setPublishing] = useState(false);
const [publishError, setPublishError] = useState<string | null>(null);
const [publishedUrl, setPublishedUrl] = useState<string | null>(null);
const defaultDescription = useMemo(
() => buildDefaultDescription(execution),
[execution],
);
const runLabel = execution?.run_name?.trim() || "Completed run";
const recordCount = getExecutionRecordCount(execution);
const recordLabel =
typeof recordCount === "number" ? recordCount.toLocaleString() : "--";
useEffect(() => {
if (!open) {
setPublishing(false);
setPublishError(null);
setPublishedUrl(null);
setRepoId("");
setDescription("");
setHfToken("");
setPrivateRepo(false);
return;
}
setPublishError(null);
setPublishedUrl(null);
setDescription(buildDefaultDescription(execution));
}, [execution, open]);
const canSubmit =
!publishing &&
Boolean(execution?.jobId) &&
Boolean(execution?.artifact_path) &&
repoId.trim().length > 0 &&
description.trim().length > 0;
const handleCopyUrl = async (): Promise<void> => {
if (!publishedUrl) {
return;
}
const ok = await copyTextToClipboard(publishedUrl);
if (ok) {
toastSuccess("Dataset link copied");
return;
}
toastError("Copy failed", "Could not copy the dataset link.");
};
const handlePublish = async (): Promise<void> => {
if (!execution?.jobId) {
setPublishError("This run is missing a job id, so it cannot be published.");
return;
}
setPublishing(true);
setPublishError(null);
try {
const result = await onPublish({
repo_id: repoId.trim(),
description: description.trim(),
hf_token: hfToken.trim() || null,
private: privateRepo,
artifact_path: execution.artifact_path,
});
setPublishedUrl(result.url);
toastSuccess("Dataset published");
} catch (error) {
const message =
error instanceof Error ? error.message : "Could not publish this dataset.";
setPublishError(message);
toastError("Publish failed", message);
} finally {
setPublishing(false);
}
};
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (publishing) {
return;
}
onOpenChange(nextOpen);
}}
>
<DialogContent
className="sm:max-w-xl"
overlayClassName="bg-black/55"
onInteractOutside={(event) => {
if (publishing) {
event.preventDefault();
}
}}
>
{publishedUrl ? (
<>
<div className="flex flex-col items-center gap-3 py-4">
<div className="flex size-12 items-center justify-center rounded-full bg-emerald-500/10">
<HugeiconsIcon
icon={CheckmarkCircle02Icon}
className="size-6 text-emerald-600 dark:text-emerald-400"
/>
</div>
<div className="space-y-1 text-center">
<DialogTitle>Published</DialogTitle>
<DialogDescription>
Your dataset is live on Hugging Face.
</DialogDescription>
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-card/55 p-3 text-xs">
<p className="mb-1 text-muted-foreground">Dataset URL</p>
<p className="break-all font-medium text-foreground">{publishedUrl}</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCopyUrl}>
<HugeiconsIcon icon={Copy01Icon} className="mr-2 size-4" />
Copy link
</Button>
<Button asChild={true}>
<a href={publishedUrl} target="_blank" rel="noreferrer">
Open repo
<HugeiconsIcon icon={ArrowRight01Icon} className="ml-2 size-4" />
</a>
</Button>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Done
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>Publish to Hugging Face</DialogTitle>
<DialogDescription>
Create or update a dataset repo from this completed run.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-2xl border border-border/60 bg-card/55 p-3 text-xs">
<p className="font-medium text-foreground">From this run</p>
<div className="mt-2 grid gap-1.5 text-muted-foreground sm:grid-cols-2">
<p>
Run: <span className="text-foreground">{runLabel}</span>
</p>
<p>
Records: <span className="text-foreground">{recordLabel}</span>
</p>
</div>
<p className="mt-2 text-muted-foreground">
Well upload the generated dataset, dataset card, images, and any processor
outputs from this execution.
</p>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground" htmlFor="publish-repo-id">
Repository
</label>
<Input
id="publish-repo-id"
placeholder="your-name/customer-support-synth"
value={repoId}
onChange={(event) => setRepoId(event.target.value)}
disabled={publishing}
/>
<p className="text-xs text-muted-foreground">
Use the format <span className="font-mono">username-or-org/dataset-name</span>.
</p>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground" htmlFor="publish-description">
About this dataset
</label>
<Textarea
id="publish-description"
className="corner-squircle"
value={description}
onChange={(event) => setDescription(event.target.value)}
disabled={publishing}
rows={4}
placeholder={defaultDescription || "What is this dataset for?"}
/>
<p className="text-xs text-muted-foreground">
This short summary is used in the dataset card on Hugging Face.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<label className="text-sm font-medium text-foreground" htmlFor="publish-hf-token">
HF write token
</label>
<a
href="https://huggingface.co/settings/tokens"
target="_blank"
rel="noreferrer"
className="text-xs text-muted-foreground underline underline-offset-3 hover:text-foreground"
>
Manage tokens
</a>
</div>
<div className="relative">
<HugeiconsIcon
icon={Key01Icon}
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
id="publish-hf-token"
type="password"
autoComplete="new-password"
className="pl-9"
placeholder="hf_..."
value={hfToken}
onChange={(event) => setHfToken(event.target.value)}
disabled={publishing}
/>
</div>
<p className="text-xs text-muted-foreground">
Leave empty if you're already logged in via CLI.
</p>
</div>
<div className="corner-squircle flex items-start gap-3 rounded-2xl border border-border/60 bg-card/35 p-3">
<Switch
id="publish-private"
size="sm"
checked={privateRepo}
onCheckedChange={setPrivateRepo}
disabled={publishing}
/>
<div className="space-y-1">
<label
htmlFor="publish-private"
className="text-sm font-medium text-foreground"
>
Private dataset
</label>
<p className="text-xs text-muted-foreground">
Only people with access can view or download the repo.
</p>
</div>
</div>
{publishError ? (
<div className="rounded-2xl border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
{publishError}
</div>
) : null}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={publishing}
>
Cancel
</Button>
<Button onClick={() => void handlePublish()} disabled={!canSubmit}>
{publishing ? "Publishing..." : "Publish to Hugging Face"}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -1,9 +1,9 @@
// 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 { MarkdownPreview } from "@/components/markdown/markdown-preview";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { MarkdownPreview } from "@/components/markdown/markdown-preview";
import { cn } from "@/lib/utils";
import {
BalanceScaleIcon,
@ -14,10 +14,10 @@ import {
EqualSignIcon,
FingerPrintIcon,
FunctionIcon,
Plug01Icon,
Parabola02Icon,
PencilEdit02Icon,
Plant01Icon,
Plug01Icon,
Shield02Icon,
Tag01Icon,
TagsIcon,
@ -25,22 +25,31 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type NodeProps,
NodeResizer,
Position,
useUpdateNodeInternals,
type NodeProps,
} from "@xyflow/react";
import { memo, type ReactElement, useEffect } from "react";
import { MAX_NODE_WIDTH, MAX_NOTE_NODE_WIDTH, MIN_NODE_WIDTH } from "../constants";
import { type ReactElement, memo, useEffect } from "react";
import {
MAX_NODE_WIDTH,
MAX_NOTE_NODE_WIDTH,
MIN_NODE_WIDTH,
} from "../constants";
import { useNodeConnectionStatus } from "../hooks/use-node-connection-status";
import { useRecipeStudioStore } from "../stores/recipe-studio";
import type {
RecipeNode as RecipeGraphNodeType,
LlmType,
NodeConfig,
RecipeNode as RecipeGraphNodeType,
SamplerType,
} from "../types";
import { NODE_HANDLE_CLASS } from "../utils/handle-layout";
import { HANDLE_IDS } from "../utils/handles";
import {
RECIPE_STUDIO_NODE_TONES,
RECIPE_STUDIO_USER_NODE_TONE,
} from "../utils/ui-tones";
import { InlineCategoryBadges } from "./inline/inline-category-badges";
import { InlineExpression } from "./inline/inline-expression";
import { InlineLlm } from "./inline/inline-llm";
@ -81,36 +90,33 @@ function parseNoteOpacity(value: string | undefined): number {
const NODE_META = {
sampler: {
tone: "bg-emerald-50 text-emerald-600 border-emerald-100",
tone: RECIPE_STUDIO_NODE_TONES.sampler,
},
llm: {
tone: "bg-sky-50 text-sky-600 border-sky-100",
tone: RECIPE_STUDIO_NODE_TONES.llm,
},
validator: {
tone: "bg-rose-50 text-rose-600 border-rose-100",
tone: RECIPE_STUDIO_NODE_TONES.validator,
},
expression: {
tone: "bg-indigo-50 text-indigo-600 border-indigo-100",
tone: RECIPE_STUDIO_NODE_TONES.expression,
},
note: {
tone: "bg-violet-50 text-violet-700 border-violet-100",
tone: RECIPE_STUDIO_NODE_TONES.note,
},
seed: {
tone: "bg-lime-50 text-lime-700 border-lime-100",
tone: RECIPE_STUDIO_NODE_TONES.seed,
},
model_provider: {
tone: "bg-amber-50 text-amber-600 border-amber-100",
tone: RECIPE_STUDIO_NODE_TONES.model_provider,
},
model_config: {
tone: "bg-orange-50 text-orange-600 border-orange-100",
tone: RECIPE_STUDIO_NODE_TONES.model_config,
},
tool_config: {
tone: "bg-cyan-50 text-cyan-700 border-cyan-100",
tone: RECIPE_STUDIO_NODE_TONES.tool_config,
},
} as const;
const USER_NODE_TONE =
"bg-amber-50 text-amber-700 border-amber-100 dark:bg-amber-950/30 dark:text-amber-300 dark:border-amber-900/60";
const SAMPLER_ICONS: Record<SamplerType, IconType> = {
category: Tag01Icon,
subcategory: TagsIcon,
@ -167,19 +173,19 @@ function resolveNodeIcon(
function getConfigSummary(config: NodeConfig | undefined): string {
if (!config) {
return "Open details for config";
return "Open settings";
}
if (config.kind === "sampler") {
if (config.sampler_type === "category") {
const count = config.values?.length ?? 0;
return `${count} values`;
return `${count} options`;
}
if (config.sampler_type === "subcategory") {
if (config.subcategory_parent?.trim()) {
return `Parent: ${config.subcategory_parent}`;
return `Based on ${config.subcategory_parent}`;
}
return "Select parent category";
return "Choose the main field";
}
if (config.sampler_type === "datetime") {
const start = config.datetime_start?.trim() || "?";
@ -188,9 +194,9 @@ function getConfigSummary(config: NodeConfig | undefined): string {
}
if (config.sampler_type === "timedelta") {
if (config.reference_column_name?.trim()) {
return `Ref: ${config.reference_column_name}`;
return `From ${config.reference_column_name}`;
}
return "Pick datetime reference";
return "Choose a date field";
}
if (
config.sampler_type === "person" ||
@ -203,40 +209,41 @@ function getConfigSummary(config: NodeConfig | undefined): string {
}
return locale;
}
return "Open details for config";
return "Open settings";
}
if (config.kind === "llm") {
if (config.llm_type === "structured") {
return "Structured output schema in details";
return "Set the response format in settings";
}
if (config.llm_type === "judge") {
const scoreCount = config.scores?.length ?? 0;
return `${scoreCount} scorers`;
return `${scoreCount} criteria`;
}
if (config.tool_alias?.trim()) {
return `Tool profile: ${config.tool_alias.trim()}`;
return `Tools: ${config.tool_alias.trim()}`;
}
return "Prompt/system via linked input nodes";
return "Add your prompt in settings";
}
if (config.kind === "tool_config") {
const providerCount = config.mcp_providers.length;
const allowCount = config.allow_tools?.filter((value) => value.trim()).length ?? 0;
const allowCount =
config.allow_tools?.filter((value) => value.trim()).length ?? 0;
const providerLabel =
providerCount === 1 ? "1 MCP server" : `${providerCount} MCP servers`;
providerCount === 1 ? "1 server" : `${providerCount} servers`;
if (allowCount === 0) {
return `${providerLabel} · all tools allowed`;
}
return `${providerLabel} · ${allowCount} allowed tools`;
return `${providerLabel} · ${allowCount} selected tools`;
}
if (config.kind === "validator") {
const target = config.target_columns[0]?.trim();
if (target) {
return `Target: ${target}`;
return `Checks ${target}`;
}
return "Pick LLM code target";
return "Choose code to check";
}
if (config.kind === "seed") {
@ -257,22 +264,22 @@ function getConfigSummary(config: NodeConfig | undefined): string {
return config.hf_path.trim();
}
if (seedSourceType === "hf") {
return "Set HF dataset repo";
return "Choose a dataset";
}
if (seedSourceType === "local") {
return "Upload structured file";
return "Upload a table file";
}
return "Upload PDF/DOCX/TXT file";
return "Upload a document";
}
if (config.kind === "markdown_note") {
if (config.markdown.trim()) {
return "Markdown preview";
return "Note preview";
}
return "Add markdown content";
return "Add note text";
}
return "Open details for config";
return "Open settings";
}
function renderNodeBody(
@ -285,7 +292,8 @@ function renderNodeBody(
}
if (config && isInlineConfig(config)) {
const onUpdate = (patch: Partial<NodeConfig>) => updateConfig(config.id, patch);
const onUpdate = (patch: Partial<NodeConfig>) =>
updateConfig(config.id, patch);
if (config.kind === "sampler") {
return <InlineSampler config={config} onUpdate={onUpdate} />;
@ -355,6 +363,7 @@ function RecipeGraphNodeBase({
const updateNodeInternals = useUpdateNodeInternals();
const executionLocked = Boolean(data.executionLocked);
const runtimeState = data.runtimeState ?? "idle";
const connectionStatus = useNodeConnectionStatus(id, config);
useEffect(() => {
updateNodeInternals(id);
@ -400,7 +409,8 @@ function RecipeGraphNodeBase({
data.kind === "expression" ||
data.kind === "sampler" ||
data.kind === "seed";
const showSemanticIn = data.kind === "model_config" || data.kind === "validator";
const showSemanticIn =
data.kind === "model_config" || data.kind === "validator";
const showSemanticOut =
data.kind === "model_config" ||
data.kind === "model_provider" ||
@ -417,7 +427,7 @@ function RecipeGraphNodeBase({
config?.kind === "sampler" &&
(config.sampler_type === "person" ||
config.sampler_type === "person_from_faker")
? USER_NODE_TONE
? RECIPE_STUDIO_USER_NODE_TONE
: meta.tone;
const runtimeNodeTone =
runtimeState === "running"
@ -425,12 +435,18 @@ function RecipeGraphNodeBase({
: runtimeState === "done"
? "border-emerald-500/60 ring-1 ring-emerald-500/20"
: "";
const hasConnectionIssue =
connectionStatus.isDisconnected ||
connectionStatus.missingDataInput;
return (
<BaseNode
className={cn(
"corner-squircle relative w-full min-w-0 overflow-visible rounded-lg border-border/60 shadow-sm",
runtimeNodeTone,
hasConnectionIssue &&
runtimeState === "idle" &&
"opacity-80 border-dashed border-amber-400/70",
)}
>
{runtimeState === "running" && config?.kind === "llm" && (

View file

@ -1,17 +1,29 @@
// 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 { type KeyboardEvent, type ReactElement, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Alert02Icon,
AlertDiamondIcon,
CookBookIcon,
FloppyDiskIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { type KeyboardEvent, type ReactElement, useState } from "react";
import type { RecipeStudioView } from "../execution-types";
import type { GraphWarning } from "../utils/graph-warnings";
import {
RECIPE_STUDIO_WARNING_BADGE_TONE,
RECIPE_STUDIO_WARNING_ICON_TONE,
} from "../utils/ui-tones";
type StatusTone = "success" | "error";
@ -21,6 +33,7 @@ type RecipeStudioHeaderProps = {
saveTone: StatusTone;
savedAtLabel: string;
workflowName: string;
warnings?: GraphWarning[];
onWorkflowNameChange: (value: string) => void;
onViewChange: (view: RecipeStudioView) => void;
onSaveRecipe: () => void;
@ -28,7 +41,7 @@ type RecipeStudioHeaderProps = {
const STATUS_MESSAGE_CLASS: Record<StatusTone, string> = {
success: "Saved",
error: "Unsaved changes",
error: "Needs saving",
};
export function RecipeStudioHeader({
@ -37,6 +50,7 @@ export function RecipeStudioHeader({
saveTone,
savedAtLabel,
workflowName,
warnings = [],
onWorkflowNameChange,
onViewChange,
onSaveRecipe,
@ -51,12 +65,14 @@ export function RecipeStudioHeader({
function closeWorkflowNameEditor(): void {
if (workflowName.trim().length === 0) {
onWorkflowNameChange("Unnamed");
onWorkflowNameChange("Untitled recipe");
}
setEditingWorkflowName(false);
}
function handleWorkflowNameKeyDown(event: KeyboardEvent<HTMLInputElement>): void {
function handleWorkflowNameKeyDown(
event: KeyboardEvent<HTMLInputElement>,
): void {
if (event.key === "Enter") {
closeWorkflowNameEditor();
return;
@ -69,13 +85,15 @@ export function RecipeStudioHeader({
return (
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-4 border-b px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
className="flex size-8 items-center justify-center rounded-lg corner-squircle border border-border/70 bg-muted/20"
aria-label="Recipe icon"
<div
className="flex size-8 shrink-0 items-center justify-center rounded-lg corner-squircle border border-border/70 bg-muted/20"
aria-hidden={true}
>
<HugeiconsIcon icon={CookBookIcon} className="size-4 text-muted-foreground" />
</button>
<HugeiconsIcon
icon={CookBookIcon}
className="size-4 text-muted-foreground"
/>
</div>
<div className="flex min-w-0 items-center gap-2">
{editingWorkflowName ? (
<Input
@ -84,32 +102,83 @@ export function RecipeStudioHeader({
onBlur={closeWorkflowNameEditor}
onKeyDown={handleWorkflowNameKeyDown}
autoFocus={true}
className="h-7 w-[180px]"
className="h-7 w-full max-w-[min(22rem,50vw)]"
aria-label="Recipe name"
/>
) : (
<button
type="button"
onClick={() => setEditingWorkflowName(true)}
className="truncate text-sm font-semibold text-foreground hover:text-primary"
className="max-w-[min(22rem,50vw)] truncate text-sm font-semibold text-foreground hover:text-primary"
title={workflowName}
aria-label={`Edit recipe name: ${workflowName}`}
>
{workflowName}
</button>
)}
<Badge variant="secondary" className="h-6 text-[10px]">
<Badge variant="secondary" className="h-6 shrink-0 text-[10px]">
{STATUS_MESSAGE_CLASS[saveTone]}
</Badge>
<span className="text-xs text-muted-foreground">{savedAtLabel}</span>
<span
className="hidden max-w-[12rem] truncate text-xs text-muted-foreground sm:inline"
title={savedAtLabel}
>
{savedAtLabel}
</span>
</div>
</div>
<div className="justify-self-center">
<Tabs value={activeView} onValueChange={handleViewValueChange}>
<TabsList>
<TabsTrigger value="editor">Editor</TabsTrigger>
<TabsTrigger value="executions">Executions</TabsTrigger>
<TabsTrigger value="executions">Runs</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex items-center justify-self-end gap-2">
{warnings.length > 0 && (
<Popover>
<PopoverTrigger asChild={true}>
<button
type="button"
className={`inline-flex h-6 shrink-0 items-center gap-1 rounded-md border px-2 text-[10px] font-medium ${RECIPE_STUDIO_WARNING_BADGE_TONE}`}
>
<HugeiconsIcon icon={Alert02Icon} className="size-3" />
{warnings.length}
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="border-b px-3 py-2">
<p className="text-xs font-semibold text-foreground">
Graph warnings ({warnings.length})
</p>
</div>
<ul className="max-h-60 overflow-y-auto py-1">
{warnings.map((w) => (
<li
key={`${w.nodeId ?? "global"}-${w.message}`}
className="flex items-start gap-2 px-3 py-1.5"
>
<HugeiconsIcon
icon={
w.severity === "error" ? AlertDiamondIcon : Alert02Icon
}
className={`mt-0.5 size-3 shrink-0 ${w.severity === "error" ? "text-destructive" : RECIPE_STUDIO_WARNING_ICON_TONE}`}
/>
<span className="text-xs text-muted-foreground">
{(w.nodeName || w.nodeId) && (
<span className="font-medium text-foreground">
{w.nodeName || w.nodeId}:{" "}
</span>
)}
{w.message}
</span>
</li>
))}
</ul>
</PopoverContent>
</Popover>
)}
<Button
type="button"
size="sm"
@ -118,7 +187,7 @@ export function RecipeStudioHeader({
disabled={saveLoading}
>
<HugeiconsIcon icon={FloppyDiskIcon} className="size-3.5" />
{saveLoading ? "Saving..." : "Save Recipe"}
{saveLoading ? "Saving..." : "Save"}
</Button>
</div>
</div>

View file

@ -74,9 +74,10 @@ export function ExecutionProgressIsland({
return (
<div
className={cn(
"w-[clamp(15rem,26vw,20rem)] rounded-b-xl border-x border-b bg-card/96 shadow-sm backdrop-blur-sm transition-all",
"w-[clamp(15rem,26vw,20rem)] max-w-[calc(100vw-1rem)] rounded-b-xl border-x border-b bg-card/96 shadow-sm backdrop-blur-sm transition-all",
minimized ? "min-h-[3rem]" : "min-h-[8.5rem]",
)}
aria-live="polite"
>
<div className="flex items-center justify-between gap-2 px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
@ -97,15 +98,20 @@ export function ExecutionProgressIsland({
{showLoadingSpinner && (
<Spinner className="size-3.5 text-muted-foreground" />
)}
<span className="text-[11px] text-muted-foreground">{formatPercent(progressPercent)}</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{formatPercent(progressPercent)}
</span>
<button
type="button"
onClick={() => onMinimizedChange(!minimized)}
className="inline-flex h-5 w-5 items-center justify-center rounded border border-border/70 text-muted-foreground transition hover:bg-muted/50"
className="inline-flex size-8 shrink-0 items-center justify-center rounded border border-border/70 text-muted-foreground transition hover:bg-muted/50"
aria-label={minimized ? "Expand progress" : "Minimize progress"}
title={minimized ? "Expand" : "Minimize"}
>
<HugeiconsIcon icon={minimized ? ArrowDown01Icon : ArrowUp01Icon} className="size-3" />
<HugeiconsIcon
icon={minimized ? ArrowDown01Icon : ArrowUp01Icon}
className="size-3.5"
/>
</button>
</div>
</div>
@ -116,18 +122,37 @@ export function ExecutionProgressIsland({
{!minimized && (
<>
<div className="grid grid-cols-4 gap-2 px-3 pt-2 text-[11px] text-muted-foreground">
<p>Done: {formatMetricValue(execution.progress?.done)}</p>
<p>Total: {formatMetricValue(execution.progress?.total)}</p>
<p>Rate: {formatMetricValue(execution.progress?.rate)}</p>
<p>ETA: {formatEta(execution.progress?.eta_sec)}</p>
<div className="grid grid-cols-2 gap-2 px-3 pt-2 text-[11px] text-muted-foreground sm:grid-cols-4">
<p className="truncate" title={`Done: ${formatMetricValue(execution.progress?.done)}`}>
Done: {formatMetricValue(execution.progress?.done)}
</p>
<p className="truncate" title={`Total: ${formatMetricValue(execution.progress?.total)}`}>
Total: {formatMetricValue(execution.progress?.total)}
</p>
<p className="truncate" title={`Rate: ${formatMetricValue(execution.progress?.rate)}`}>
Rate: {formatMetricValue(execution.progress?.rate)}
</p>
<p className="truncate" title={`ETA: ${formatEta(execution.progress?.eta_sec)}`}>
ETA: {formatEta(execution.progress?.eta_sec)}
</p>
</div>
<div className="mt-1 flex items-center gap-1.5 px-3 text-[11px] text-muted-foreground">
<HugeiconsIcon icon={currentColumnIcon} className="size-3.5" />
<p className="truncate">Column: {execution.current_column ?? "--"}</p>
<HugeiconsIcon
icon={currentColumnIcon}
className="size-3.5 shrink-0"
/>
<p
className="truncate"
title={execution.current_column ?? "--"}
>
Column: {execution.current_column ?? "--"}
</p>
</div>
{showBatch && (
<div className="mt-1 px-3 text-[11px] text-muted-foreground">
<div
className="mt-1 truncate px-3 text-[11px] text-muted-foreground"
title={`Batch: ${execution.batch?.idx ?? "--"}/${execution.batch?.total ?? "--"}`}
>
Batch: {execution.batch?.idx ?? "--"}/{execution.batch?.total ?? "--"}
</div>
)}
@ -139,7 +164,7 @@ export function ExecutionProgressIsland({
className="h-7 w-full text-[11px]"
onClick={onViewExecutions}
>
View more in executions view
View run details
</Button>
</div>
</>

View file

@ -57,23 +57,23 @@ export function ConfigDialog({
className="corner-squircle max-h-[650px] overflow-y-auto overflow-x-hidden sm:max-w-2xl shadow-border"
>
<DialogShell
title={blockDefinition ? `${blockDefinition.title} block` : undefined}
title={blockDefinition ? blockDefinition.title : undefined}
description={
blockDefinition
? blockDefinition.description
: "Adjust block params before running the flow."
: "Choose a step to edit."
}
/>
{!config && (
<div className="text-sm text-muted-foreground">
Select a node to edit.
Select a step to edit.
</div>
)}
{config && (
<div className="min-w-0 space-y-4">
{readOnly && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
Recipe locked while execution is active.
This recipe is locked while a run is in progress.
</div>
)}
<ValidationBanner config={config} />
@ -82,10 +82,10 @@ export function ConfigDialog({
>
{showDropToggle && (
<div className="mb-2 flex items-center corner-squircle justify-between gap-3 rounded-2xl border border-border/60 px-3 pt-2 pb-4">
<div>
<p className="text-sm font-semibold">Drop from final dataset</p>
<p className="text-xs text-muted-foreground">
Keep for generation but omit from exported rows.
<div className="min-w-0">
<p className="text-sm font-semibold">Keep out of final dataset</p>
<p className="break-words text-xs text-muted-foreground">
Use this step while generating, but leave it out of exported rows.
</p>
</div>
<Switch

View file

@ -62,7 +62,7 @@ export function ExpressionDialog({
<FieldLabel
label="Output type"
htmlFor={dtypeId}
hint="Cast expression output type in final dataset."
hint="Choose how this formula should be stored in the final dataset."
/>
<Select
value={config.dtype}
@ -84,9 +84,9 @@ export function ExpressionDialog({
</div>
<div className="grid gap-2">
<FieldLabel
label="Expression (Jinja2)"
label="Formula"
htmlFor={exprId}
hint="Use Jinja to combine or transform existing columns."
hint="Build this field from other fields."
/>
<Textarea
id={exprId}
@ -98,14 +98,14 @@ export function ExpressionDialog({
/>
{invalidExprRefs.length > 0 && (
<p className="text-xs text-destructive">
Unknown reference: {invalidExprText}
Unknown field: {invalidExprText}
{invalidExprRefs.length > 3
? ` +${invalidExprRefs.length - 3} more`
: ""}
</p>
)}
<p className="text-xs text-muted-foreground">
Use Jinja2. Reference columns like {"{{ column_name }}"}.
Insert other fields like {"{{ field_name }}"}.
</p>
</div>
</div>

View file

@ -60,9 +60,9 @@ export function ImportDialog({
</DialogHeader>
<div className="grid gap-2">
<FieldLabel
label="JSON payload"
label="Recipe JSON"
htmlFor={payloadId}
hint="Paste exported recipe payload JSON."
hint="Paste JSON exported from Recipe Studio."
/>
<Textarea
id={payloadId}
@ -79,7 +79,7 @@ export function ImportDialog({
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleImport}>
Import
Import recipe
</Button>
</DialogFooter>
</DialogContent>

View file

@ -23,12 +23,15 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { ArrowRight01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactElement, type RefObject, useMemo, useRef } from "react";
import { useRecipeStudioStore } from "../../stores/recipe-studio";
import type { LlmConfig } from "../../types";
import { isLikelyImageValue } from "../../utils/image-preview";
import { findInvalidJinjaReferences } from "../../utils/refs";
import { getAvailableVariables } from "../../utils/variables";
import { CollapsibleSectionTriggerButton } from "../shared/collapsible-section-trigger";
import { AvailableVariables } from "../shared/available-variables";
import { FieldLabel } from "../shared/field-label";
import { NameField } from "../shared/name-field";
@ -115,8 +118,14 @@ export function LlmGeneralTab({
const hasHfSeed = Boolean(
seedConfig && (seedConfig.seed_source_type ?? "hf") === "hf",
);
const seedColumns = seedConfig?.seed_columns ?? [];
const seedPreviewRows = seedConfig?.seed_preview_rows ?? [];
const seedColumns = useMemo(
() => seedConfig?.seed_columns ?? [],
[seedConfig],
);
const seedPreviewRows = useMemo(
() => seedConfig?.seed_preview_rows ?? [],
[seedConfig],
);
const imageColumnOptions = useMemo(() => {
if (seedColumns.length === 0) {
return [];
@ -160,29 +169,57 @@ export function LlmGeneralTab({
const reasoningToggleId = `${config.id}-reasoning-content`;
const advancedOpen = config.advancedOpen === true;
const toolAliasAnchorRef = useRef<HTMLDivElement>(null);
const needsSetupHelp = !hasModelConfigs || !hasModelProviders;
const needsModelChoice = !config.model_alias?.trim();
return (
<div className="space-y-4">
<AvailableVariables configId={config.id} />
<NameField
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
{(!hasModelConfigs || !hasModelProviders) && (
<div className="rounded-2xl border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<p className="font-semibold text-foreground">Setup hint</p>
<p>
{!hasModelProviders && "Add a Model Provider block. "}
{!hasModelConfigs &&
"Add a Model Config block and pick its alias here."}
{needsSetupHelp ? (
<div className="rounded-2xl border border-border/60 bg-muted/10 px-4 py-3 text-xs text-muted-foreground">
<p className="text-sm font-semibold text-foreground">
Set up the model once, then come back here
</p>
<div className="mt-2 space-y-1.5">
{!hasModelProviders && (
<p className="flex items-start gap-2">
<HugeiconsIcon
icon={ArrowRight01Icon}
className="mt-0.5 size-3.5 shrink-0 text-primary"
/>
<span>Add a Provider connection step in AI generation Setup.</span>
</p>
)}
{!hasModelConfigs && (
<p className="flex items-start gap-2">
<HugeiconsIcon
icon={ArrowRight01Icon}
className="mt-0.5 size-3.5 shrink-0 text-primary"
/>
<span>Add a Model preset step, connect it, then choose it below.</span>
</p>
)}
</div>
</div>
) : needsModelChoice ? (
<div className="rounded-2xl border border-border/60 bg-muted/10 px-4 py-3 text-xs text-muted-foreground">
<p className="text-sm font-semibold text-foreground">
Start by choosing a model preset
</p>
<p className="mt-1">
Once that is in place, write the prompt and add optional tool access
if this step needs tools.
</p>
</div>
)}
) : null}
<div className="grid gap-2">
<FieldLabel
label="Model alias"
label="Model preset"
htmlFor={modelAliasId}
hint="Alias must match a Model Config block."
hint="Choose the reusable model setup for this step."
/>
<div ref={modelAliasAnchorRef}>
<Combobox
@ -197,7 +234,7 @@ export function LlmGeneralTab({
<ComboboxInput
id={modelAliasId}
className="nodrag w-full"
placeholder="Pick model alias or type"
placeholder="Choose a model preset"
onBlur={(event) => {
const inputValue = event.target.value;
if (inputValue !== config.model_alias) {
@ -218,59 +255,60 @@ export function LlmGeneralTab({
</Combobox>
</div>
</div>
<div className="grid gap-2">
<FieldLabel
label="Tool profile (optional)"
htmlFor={toolAliasId}
hint="Pick a shared Tool Profile block. Leave empty for no tools."
/>
<div ref={toolAliasAnchorRef}>
<Combobox
items={toolProfileAliases}
filteredItems={toolProfileAliases}
filter={null}
value={config.tool_alias || null}
onValueChange={(value) => onUpdate({ tool_alias: value ?? "" })}
itemToStringValue={(value) => value}
autoHighlight={true}
>
<ComboboxInput
id={toolAliasId}
className="nodrag w-full"
placeholder={
hasToolProfiles ? "Pick tool profile or type" : "No tool profiles yet"
}
onBlur={(event) => {
const inputValue = event.target.value;
if (inputValue !== (config.tool_alias ?? "")) {
onUpdate({ tool_alias: inputValue });
}
}}
/>
<ComboboxContent anchor={toolAliasAnchorRef}>
<ComboboxEmpty>No tool profiles found</ComboboxEmpty>
<ComboboxList>
{(alias: string) => (
<ComboboxItem key={alias} value={alias}>
{alias}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
{!hasToolProfiles && (
<p className="text-xs text-muted-foreground">
Need tools for this step? Add a Tool access step in AI generation
Setup.
</p>
)}
{(hasToolProfiles || Boolean(config.tool_alias?.trim())) && (
<div className="grid gap-2">
<FieldLabel
label="Tool access (optional)"
htmlFor={toolAliasId}
hint="Choose saved tool access for this step. Leave empty if this step should not use tools."
/>
<div ref={toolAliasAnchorRef}>
<Combobox
items={toolProfileAliases}
filteredItems={toolProfileAliases}
filter={null}
value={config.tool_alias || null}
onValueChange={(value) => onUpdate({ tool_alias: value ?? "" })}
itemToStringValue={(value) => value}
autoHighlight={true}
>
<ComboboxInput
id={toolAliasId}
className="nodrag w-full"
placeholder="Choose tool access"
onBlur={(event) => {
const inputValue = event.target.value;
if (inputValue !== (config.tool_alias ?? "")) {
onUpdate({ tool_alias: inputValue });
}
}}
/>
<ComboboxContent anchor={toolAliasAnchorRef}>
<ComboboxEmpty>No tool access found</ComboboxEmpty>
<ComboboxList>
{(alias: string) => (
<ComboboxItem key={alias} value={alias}>
{alias}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</div>
{!hasToolProfiles && (
<p className="text-xs text-muted-foreground">
Add a Tool Profile block to configure MCP servers and allowed tools.
</p>
)}
</div>
)}
{config.llm_type === "code" && (
<div className="grid gap-2">
<FieldLabel
label="Code language"
htmlFor={codeLangId}
hint="Target language for LLM code generation."
hint="Choose the language this AI step should generate."
/>
<Select
value={config.code_lang ?? "python"}
@ -293,7 +331,7 @@ export function LlmGeneralTab({
<FieldLabel
label="Prompt"
htmlFor={promptId}
hint="Jinja template. references other columns via {{ variable }}."
hint="Write the prompt for this step. Insert other fields with {{ field_name }}."
/>
<Textarea
id={promptId}
@ -304,20 +342,21 @@ export function LlmGeneralTab({
/>
{invalidPromptRefs.length > 0 && (
<p className="text-xs text-destructive">
Unknown reference: {invalidPromptText}
Unknown field: {invalidPromptText}
{invalidPromptRefs.length > 3
? ` +${invalidPromptRefs.length - 3} more`
: ""}
</p>
)}
</div>
<AvailableVariables configId={config.id} />
{hasHfSeed && (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<FieldLabel
label="Use image context"
htmlFor={imageContextToggleId}
hint="Attach one seed image column to this LLM call."
hint="Attach one image field from your source data to this AI step."
/>
<Switch
id={imageContextToggleId}
@ -340,9 +379,9 @@ export function LlmGeneralTab({
{imageContext.enabled && (
<div className="grid gap-2">
<FieldLabel
label="Image column"
label="Image field"
htmlFor={imageContextColumnId}
hint="Pick the seed column that contains image data."
hint="Choose the source-data field that contains the image."
/>
<Select
value={imageContext.column_name || undefined}
@ -377,9 +416,9 @@ export function LlmGeneralTab({
{config.llm_type === "structured" && (
<div className="grid gap-2">
<FieldLabel
label="Output format (JSON schema)"
label="Response format"
htmlFor={outputFormatId}
hint="Schema used to constrain structured JSON output."
hint="Describe the JSON shape you want back."
/>
<Textarea
id={outputFormatId}
@ -391,47 +430,46 @@ export function LlmGeneralTab({
/>
</div>
)}
<div className="grid gap-2">
<FieldLabel
label="System prompt (optional)"
htmlFor={systemPromptId}
hint="Global behavior instructions prepended before prompt."
/>
<Textarea
id={systemPromptId}
className="corner-squircle nodrag max-h-[450px] overflow-auto"
aria-invalid={invalidSystemRefs.length > 0}
value={config.system_prompt}
onChange={(event) => onUpdate({ system_prompt: event.target.value })}
/>
{invalidSystemRefs.length > 0 && (
<p className="text-xs text-destructive">
Unknown reference: {invalidSystemText}
{invalidSystemRefs.length > 3
? ` +${invalidSystemRefs.length - 3} more`
: ""}
</p>
)}
</div>
<Collapsible
open={advancedOpen}
onOpenChange={(open) => onUpdate({ advancedOpen: open })}
>
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex w-full items-center justify-between text-left text-xs text-muted-foreground"
>
<span className="font-semibold uppercase">Advanced</span>
<span>{advancedOpen ? "Hide" : "Show"}</span>
</button>
<CollapsibleSectionTriggerButton
label="Trace and extra controls"
open={advancedOpen}
/>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-4">
<div className="grid gap-2">
<FieldLabel
label="Trace capture"
label="Instructions (optional)"
htmlFor={systemPromptId}
hint="Add extra guidance that should apply before the prompt."
/>
<Textarea
id={systemPromptId}
className="corner-squircle nodrag max-h-[450px] overflow-auto"
aria-invalid={invalidSystemRefs.length > 0}
value={config.system_prompt}
onChange={(event) =>
onUpdate({ system_prompt: event.target.value })
}
/>
{invalidSystemRefs.length > 0 && (
<p className="text-xs text-destructive">
Unknown field: {invalidSystemText}
{invalidSystemRefs.length > 3
? ` +${invalidSystemRefs.length - 3} more`
: ""}
</p>
)}
</div>
<div className="grid gap-2">
<FieldLabel
label="Save trace details"
htmlFor={traceModeId}
hint="Adds {column}__trace for debugging/replay."
hint="Adds a trace field you can inspect later."
/>
<Select
value={config.with_trace ?? "none"}
@ -456,9 +494,9 @@ export function LlmGeneralTab({
</div>
<div className="flex items-center justify-between gap-3">
<FieldLabel
label="Extract reasoning content"
label="Save reasoning text"
htmlFor={reasoningToggleId}
hint="Adds {column}__reasoning_content when model provides it."
hint="Adds a reasoning field when the model returns one."
/>
<Switch
id={reasoningToggleId}

View file

@ -19,6 +19,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { type ReactElement, useRef, useState } from "react";
import type { ModelConfig } from "../../types";
import { CollapsibleSectionTriggerButton } from "../shared/collapsible-section-trigger";
import { FieldLabel } from "../shared/field-label";
import { NameField } from "../shared/name-field";
@ -58,29 +59,24 @@ export function ModelConfigDialog({
return (
<div className="space-y-4">
<NameField
label="Model alias"
label="Model preset name"
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
<div className="grid gap-2">
<FieldLabel
label="Model"
htmlFor={modelId}
hint="Exact model id string sent to provider."
/>
<Input
id={modelId}
className="nodrag"
placeholder="gpt-4o-mini"
value={config.model}
onChange={(event) => updateField("model", event.target.value)}
/>
<div className="rounded-2xl border border-border/60 bg-muted/10 px-4 py-3">
<p className="text-sm font-semibold text-foreground">
Set up one reusable model choice for your AI steps
</p>
<p className="mt-1 text-xs text-muted-foreground">
Choose the provider connection, enter the exact model ID, then save any
generation defaults you want to reuse.
</p>
</div>
<div className="grid gap-2">
<FieldLabel
label="Provider name"
label="Provider connection"
htmlFor={providerId}
hint="Must match a Model Provider block name."
hint="Choose where this model should run."
/>
<div ref={providerAnchorRef}>
<Combobox
@ -98,7 +94,7 @@ export function ModelConfigDialog({
<ComboboxInput
id={providerId}
className="nodrag w-full"
placeholder="Pick provider or type name"
placeholder="Choose a provider connection"
onBlur={() => {
const next = providerInputRef.current;
if (next !== config.provider) {
@ -119,69 +115,110 @@ export function ModelConfigDialog({
</Combobox>
</div>
<p className="text-xs text-muted-foreground">
Pick provider name from list. Matching node link becomes semantic.
{providerOptions.length === 0
? "Add a Provider connection step first, then come back here."
: "Matching blocks are linked automatically on the canvas."}
</p>
</div>
<div className="grid gap-2">
<FieldLabel
label="Inference"
hint="Runtime generation params for this model alias."
label="Model ID"
htmlFor={modelId}
hint="The exact model name sent to the connection."
/>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<Input
id={tempId}
className="nodrag"
placeholder="Temp"
value={config.inference_temperature ?? ""}
onChange={(event) =>
updateField("inference_temperature", event.target.value)
}
/>
<Input
id={topPId}
className="nodrag"
placeholder="Top_p"
value={config.inference_top_p ?? ""}
onChange={(event) =>
updateField("inference_top_p", event.target.value)
}
/>
<Input
id={maxTokensId}
className="nodrag"
placeholder="Max tokens"
value={config.inference_max_tokens ?? ""}
onChange={(event) =>
updateField("inference_max_tokens", event.target.value)
}
/>
<Input
id={timeoutId}
className="nodrag"
placeholder="Timeout (sec)"
value={config.inference_timeout ?? ""}
onChange={(event) =>
updateField("inference_timeout", event.target.value)
}
/>
<Input
id={modelId}
className="nodrag"
placeholder="gpt-4o-mini"
value={config.model}
onChange={(event) => updateField("model", event.target.value)}
/>
</div>
<div className="grid gap-3">
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground">
Default generation settings
</p>
<p className="text-xs text-muted-foreground">
These defaults are reused anywhere you choose this model preset.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<FieldLabel
label="Temperature"
htmlFor={tempId}
hint="Higher values make responses more varied."
/>
<Input
id={tempId}
className="nodrag"
value={config.inference_temperature ?? ""}
onChange={(event) =>
updateField("inference_temperature", event.target.value)
}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Top-p"
htmlFor={topPId}
hint="Use this to limit how broad token selection can be."
/>
<Input
id={topPId}
className="nodrag"
value={config.inference_top_p ?? ""}
onChange={(event) =>
updateField("inference_top_p", event.target.value)
}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Max tokens"
htmlFor={maxTokensId}
hint="Maximum length of the model response."
/>
<Input
id={maxTokensId}
className="nodrag"
value={config.inference_max_tokens ?? ""}
onChange={(event) =>
updateField("inference_max_tokens", event.target.value)
}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Timeout (seconds)"
htmlFor={timeoutId}
hint="How long to wait before a request is treated as failed."
/>
<Input
id={timeoutId}
className="nodrag"
value={config.inference_timeout ?? ""}
onChange={(event) =>
updateField("inference_timeout", event.target.value)
}
/>
</div>
</div>
</div>
<Collapsible open={optionalOpen} onOpenChange={setOptionalOpen}>
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex w-full items-center justify-between text-left text-xs text-muted-foreground"
>
<span className="font-semibold uppercase">Optional</span>
<span>{optionalOpen ? "Hide" : "Show"}</span>
</button>
<CollapsibleSectionTriggerButton
label="Advanced request fields"
open={optionalOpen}
/>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-4">
<div className="grid gap-2">
<FieldLabel
label="Inference extra body (JSON)"
label="Advanced request fields (JSON)"
htmlFor={extraBodyId}
hint="Optional request fields merged into inference parameters."
hint="Extra request fields to send with every call."
/>
<Textarea
id={extraBodyId}
@ -200,7 +237,7 @@ export function ModelConfigDialog({
updateField("skip_health_check", Boolean(value))
}
/>
Skip health check
Skip connection check
</label>
</CollapsibleContent>
</Collapsible>

View file

@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { type ReactElement, useState } from "react";
import type { ModelProviderConfig } from "../../types";
import { CollapsibleSectionTriggerButton } from "../shared/collapsible-section-trigger";
import { FieldLabel } from "../shared/field-label";
import { NameField } from "../shared/name-field";
@ -38,15 +39,24 @@ export function ModelProviderDialog({
return (
<div className="space-y-4">
<NameField
label="Provider name"
label="Connection name"
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
<div className="rounded-2xl border border-border/60 bg-muted/10 px-4 py-3">
<p className="text-sm font-semibold text-foreground">
Start with the endpoint you want this model to use
</p>
<p className="mt-1 text-xs text-muted-foreground">
Most connections only need an endpoint. Add an API key if that
service requires one.
</p>
</div>
<div className="grid gap-2">
<FieldLabel
label="Endpoint"
htmlFor={endpointId}
hint="Base API URL used for model requests."
hint="Base URL for the model service or gateway."
/>
<Input
id={endpointId}
@ -60,7 +70,7 @@ export function ModelProviderDialog({
<FieldLabel
label="API key (optional)"
htmlFor={apiKeyId}
hint="Inline key. prefer env var for safer configs."
hint="Paste a key here, or use an environment variable below."
/>
<Input
id={apiKeyId}
@ -71,20 +81,17 @@ export function ModelProviderDialog({
</div>
<Collapsible open={optionalOpen} onOpenChange={setOptionalOpen}>
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex w-full items-center justify-between text-left text-xs text-muted-foreground"
>
<span className="font-semibold uppercase">Optional</span>
<span>{optionalOpen ? "Hide" : "Show"}</span>
</button>
<CollapsibleSectionTriggerButton
label="Advanced request overrides"
open={optionalOpen}
/>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-4">
<div className="grid gap-2">
<FieldLabel
label="API key env (optional)"
label="API key environment variable"
htmlFor={apiKeyEnvId}
hint="Env var name to read secret key from runtime."
hint="Name of the environment variable that stores the key."
/>
<Input
id={apiKeyEnvId}
@ -98,7 +105,7 @@ export function ModelProviderDialog({
<FieldLabel
label="Extra headers (JSON)"
htmlFor={extraHeadersId}
hint="Optional request headers merged into every call."
hint="Optional headers to send with every request."
/>
<Textarea
id={extraHeadersId}
@ -112,7 +119,7 @@ export function ModelProviderDialog({
<FieldLabel
label="Extra body (JSON)"
htmlFor={extraBodyId}
hint="Optional payload fields merged into requests."
hint="Optional request fields to send every time."
/>
<Textarea
id={extraBodyId}

View file

@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Collapsible,
@ -16,17 +17,16 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import {
AlertCircleIcon,
ArrowDown01Icon,
CheckmarkCircle02Icon,
CookBookIcon,
SparklesIcon,
TestTube01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactElement, useEffect, useState } from "react";
import { type ReactElement, type ReactNode, useState } from "react";
import type { RecipeExecutionKind } from "../execution-types";
import type { RecipeRunSettings } from "../stores/recipe-executions";
import { FieldLabel } from "./shared/field-label";
@ -172,6 +172,26 @@ function DraftInputField({
);
}
function AdvancedSettingsSection({
title,
description,
children,
}: {
title: string;
description: string;
children: ReactNode;
}): ReactElement {
return (
<div className="space-y-3 rounded-2xl border border-border/70 bg-card/60 p-4">
<div className="space-y-0.5">
<p className="text-sm font-semibold text-foreground">{title}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
{children}
</div>
);
}
function ValidationResultPanel({
validateResult,
}: {
@ -200,7 +220,9 @@ function ValidationResultPanel({
)}
>
<HugeiconsIcon
icon={validateResult.valid ? CheckmarkCircle02Icon : AlertCircleIcon}
icon={
validateResult.valid ? CheckmarkCircle02Icon : AlertCircleIcon
}
className="size-4"
/>
</div>
@ -208,37 +230,46 @@ function ValidationResultPanel({
<p
className={cn(
"text-sm font-semibold",
validateResult.valid ? "text-emerald-700 dark:text-emerald-300" : "text-destructive",
validateResult.valid
? "text-emerald-700 dark:text-emerald-300"
: "text-destructive",
)}
>
{validateResult.valid ? "Recipe looks good" : "Recipe needs attention"}
{validateResult.valid ? "Ready to run" : "Fix these issues first"}
</p>
<p className="text-xs text-muted-foreground">
{validateResult.valid
? "Validation passed. You can start the run when ready."
: "Fix the issues below, then validate again."}
? "Everything checks out. Start the run when you're ready."
: "Update the recipe, then check it again."}
</p>
</div>
</div>
{!validateResult.valid && validateResult.errors.length > 0 && (
<div className="space-y-1">
{validateResult.errors.map((error) => (
<p key={error} className="text-xs text-destructive">
<p key={error} className="break-words text-xs text-destructive">
{error}
</p>
))}
</div>
)}
{!validateResult.valid && validateResult.rawDetail && (
<p className="text-xs text-destructive">{validateResult.rawDetail}</p>
<p className="break-words text-xs text-destructive">
{validateResult.rawDetail}
</p>
)}
</div>
);
}
export function RunDialog({
open,
onOpenChange,
type RunDialogBodyProps = Omit<
RunDialogProps,
"open" | "onOpenChange" | "container"
> & {
onClose: () => void;
};
function RunDialogBody({
kind,
onKindChange,
rows,
@ -253,12 +284,13 @@ export function RunDialog({
errors,
onRun,
onValidate,
container,
}: RunDialogProps): ReactElement {
onClose,
}: RunDialogBodyProps): ReactElement {
const [advancedOpen, setAdvancedOpen] = useState(false);
const kindLabel = kind === "preview" ? "Preview" : "Full run";
const kindLabel = kind === "preview" ? "Test run" : "Full run";
const normalizedFullRunName = fullRunName.trim();
const isFullRunNameMissing = kind === "full" && normalizedFullRunName.length === 0;
const isFullRunNameMissing =
kind === "full" && normalizedFullRunName.length === 0;
const rowHint =
kind === "preview"
? "How many sample rows to generate for a quick check."
@ -288,238 +320,170 @@ export function RunDialog({
const [shutdownRateDraft, setShutdownRateDraft] = useState(
String(settings.shutdownErrorRate),
);
const showBatchingHint =
kind === "full" && rows >= 1000 && !settings.batchEnabled;
useEffect(() => {
if (!open) {
return;
}
setRowsDraft(String(rows));
setBatchSizeDraft(String(settings.batchSize));
setLlmParallelDraft(
settings.llmParallelRequests === null
? ""
: String(settings.llmParallelRequests),
);
setWorkersDraft(String(settings.nonInferenceWorkers));
setWindowDraft(String(settings.shutdownErrorWindow));
setRestartsDraft(String(settings.maxConversationRestarts));
setCorrectionsDraft(String(settings.maxConversationCorrectionSteps));
setShutdownRateDraft(String(settings.shutdownErrorRate));
}, [
rows,
settings.batchSize,
settings.llmParallelRequests,
settings.nonInferenceWorkers,
settings.shutdownErrorWindow,
settings.maxConversationRestarts,
settings.maxConversationCorrectionSteps,
settings.shutdownErrorRate,
open,
]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
container={container}
position="absolute"
overlayPosition="absolute"
overlayClassName="bg-transparent"
className="corner-squircle border-border/70 bg-background/95 sm:max-w-2xl shadow-border backdrop-blur-xl"
>
<DialogHeader className="space-y-2">
<DialogTitle>{kindLabel} settings</DialogTitle>
<p className="text-sm text-muted-foreground">
Configure run size and performance knobs for this execution.
</p>
</DialogHeader>
<>
<DialogHeader className="space-y-2">
<DialogTitle>{kindLabel}</DialogTitle>
<p className="text-sm text-muted-foreground">
Choose a quick test or a full run. Advanced settings are optional.
</p>
</DialogHeader>
{showBatchingHint && (
<div className="flex items-start gap-3 rounded-2xl border border-amber-300/70 bg-amber-50/80 p-4 shadow-border dark:border-amber-900/60 dark:bg-amber-950/30">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full border border-amber-300/70 bg-amber-500/10 text-amber-700 dark:border-amber-900/60 dark:text-amber-300">
<HugeiconsIcon icon={SparklesIcon} className="size-4" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold text-amber-800 dark:text-amber-200">
Bigger runs usually feel smoother with batching on
</p>
<p className="text-xs leading-relaxed text-amber-900/80 dark:text-amber-100/80">
You&apos;re generating {rows.toLocaleString()} records. Turning on batching
usually makes larger runs easier to manage and more resilient if
something goes wrong mid-run.
</p>
</div>
</div>
)}
<div className="grid gap-2">
<FieldLabel
label="Run type"
hint="Start with a quick check or generate the full dataset."
/>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant={kind === "preview" ? "default" : "outline"}
className="corner-squircle min-h-10 justify-center whitespace-normal px-3 text-center"
aria-pressed={kind === "preview"}
onClick={() => onKindChange("preview")}
>
Test run
</Button>
<Button
type="button"
variant={kind === "full" ? "default" : "outline"}
className="corner-squircle min-h-10 justify-center whitespace-normal px-3 text-center"
aria-pressed={kind === "full"}
onClick={() => onKindChange("full")}
>
Full run
</Button>
</div>
</div>
<div className="flex items-center justify-between rounded-2xl border border-border/70 bg-card/60 px-4 py-3 text-sm shadow-border">
<div className="space-y-0.5">
<span className="font-medium text-foreground">Preview mode</span>
<p className="text-xs text-muted-foreground">
Turn this off for a full dataset run.
</p>
</div>
<Switch
checked={kind === "preview"}
onCheckedChange={(checked) =>
onKindChange(checked ? "preview" : "full")
}
{kind === "full" && (
<div className="grid gap-2">
<FieldLabel
label="Run name"
htmlFor="run-name"
hint="Name shown in your run history."
/>
<Input
id="run-name"
type="text"
value={fullRunName}
onChange={(event) => onFullRunNameChange(event.target.value)}
placeholder="Sprint dataset v2"
aria-invalid={isFullRunNameMissing}
/>
{isFullRunNameMissing ? (
<p className="text-xs text-destructive">
Give this full run a name before you start.
</p>
) : null}
</div>
)}
{kind === "full" && (
<div className="grid gap-2">
<FieldLabel
label="Run name"
htmlFor="run-name"
hint="Optional label shown in executions list."
/>
<Input
id="run-name"
type="text"
value={fullRunName}
onChange={(event) => onFullRunNameChange(event.target.value)}
placeholder="Sprint dataset v2"
aria-invalid={isFullRunNameMissing}
/>
{isFullRunNameMissing ? (
<p className="text-xs text-destructive">
Run name is required before starting a full run.
</p>
) : null}
</div>
)}
<div className="grid gap-2">
<FieldLabel label="Records" htmlFor="run-rows" hint={rowHint} />
<Input
id="run-rows"
type="text"
inputMode="numeric"
value={rowsDraft}
onChange={(event) => setRowsDraft(event.target.value)}
onBlur={() =>
commitInt(
rowsDraft,
rows,
1,
MAX_RECORDS,
onRowsChange,
setRowsDraft,
)
}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<FieldLabel label="Records" htmlFor="run-rows" hint={rowHint} />
<Input
id="run-rows"
type="text"
inputMode="numeric"
value={rowsDraft}
onChange={(event) => setRowsDraft(event.target.value)}
onBlur={() =>
commitInt(
rowsDraft,
rows,
1,
MAX_RECORDS,
onRowsChange,
setRowsDraft,
)
}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
>
<HugeiconsIcon
icon={ArrowDown01Icon}
className={cn(
"size-3.5 transition-transform",
advancedOpen && "rotate-180",
)}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="LLM parallel"
htmlFor="run-llm-parallel"
hint="How many LLM calls run at once. Leave empty to keep each model's own setting."
/>
<Input
id="run-llm-parallel"
type="text"
inputMode="numeric"
placeholder="Use model config"
value={llmParallelDraft}
onChange={(event) => setLlmParallelDraft(event.target.value)}
onBlur={() => {
const trimmed = llmParallelDraft.trim();
if (!trimmed) {
onSettingsChange({ llmParallelRequests: null });
setLlmParallelDraft("");
return;
}
const parsed = Number(trimmed);
if (!Number.isFinite(parsed)) {
setLlmParallelDraft(
settings.llmParallelRequests === null
? ""
: String(settings.llmParallelRequests),
);
return;
}
const next = clampInt(parsed, 1, MAX_WORKERS);
onSettingsChange({ llmParallelRequests: next });
setLlmParallelDraft(String(next));
}}
/>
</div>
</div>
{kind === "full" && (
<div className="space-y-3 rounded-2xl border border-border/70 bg-card/60 p-4 shadow-border">
<div className="flex items-center justify-between gap-3 text-sm">
<div className="space-y-0.5">
<span className="font-medium">Enable batching</span>
<p className="text-xs text-muted-foreground">
Split big runs into manageable chunks.
</p>
</div>
<Switch
checked={settings.batchEnabled}
onCheckedChange={(checked) =>
onSettingsChange({ batchEnabled: Boolean(checked) })
}
/>
</div>
{settings.batchEnabled && (
<div className="space-y-3">
<DraftInputField
id="run-batch-size"
label="Batch size"
hint="Rows handled per batch during generation."
inputMode="numeric"
value={batchSizeDraft}
onChange={setBatchSizeDraft}
onBlur={() =>
commitInt(
batchSizeDraft,
settings.batchSize,
1,
MAX_RECORDS,
(value) => onSettingsChange({ batchSize: value }),
setBatchSizeDraft,
)
{advancedOpen
? "Hide advanced run settings"
: "Show advanced run settings"}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-4">
{kind === "full" && (
<AdvancedSettingsSection
title="Batching"
description="Use batches when you want to split a larger run into smaller pieces."
>
<div className="flex items-center justify-between gap-3 text-sm">
<div className="space-y-0.5">
<span className="font-medium">Enable batching</span>
<p className="text-xs text-muted-foreground">
Split a larger run into smaller chunks.
</p>
</div>
<Switch
checked={settings.batchEnabled}
onCheckedChange={(checked) =>
onSettingsChange({ batchEnabled: Boolean(checked) })
}
/>
<div className="flex items-center justify-between gap-3 text-sm">
<div className="space-y-0.5">
<span className="font-medium">Merge batches to one parquet</span>
<p className="text-xs text-muted-foreground">
Combine chunk outputs into one final file when done.
</p>
</div>
<Switch
checked={settings.mergeBatches}
onCheckedChange={(checked) =>
onSettingsChange({ mergeBatches: Boolean(checked) })
}
/>
</div>
</div>
)}
</div>
)}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
>
{advancedOpen ? "Hide advanced" : "Show advanced"}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-4">
<div className="grid gap-4 rounded-2xl border border-border/70 bg-card/60 p-4 shadow-border md:grid-cols-2">
{rows >= 1000 && !settings.batchEnabled ? (
<p className="text-xs text-muted-foreground">
Larger runs are usually easier to manage in batches.
</p>
) : null}
</AdvancedSettingsSection>
)}
<AdvancedSettingsSection
title="Throughput"
description="Control how much work runs at the same time."
>
<div className="grid gap-4 md:grid-cols-2">
<DraftInputField
id="run-llm-parallel"
label="AI requests at once"
hint="Leave empty to use each saved model's own setting."
inputMode="numeric"
value={llmParallelDraft}
onChange={setLlmParallelDraft}
onBlur={() => {
const trimmed = llmParallelDraft.trim();
if (!trimmed) {
onSettingsChange({ llmParallelRequests: null });
setLlmParallelDraft("");
return;
}
const parsed = Number(trimmed);
if (!Number.isFinite(parsed)) {
setLlmParallelDraft(
settings.llmParallelRequests === null
? ""
: String(settings.llmParallelRequests),
);
return;
}
const next = clampInt(parsed, 1, MAX_WORKERS);
onSettingsChange({ llmParallelRequests: next });
setLlmParallelDraft(String(next));
}}
placeholder="Use saved model setting"
/>
<DraftInputField
id="run-non-inference-workers"
label="CPU workers"
hint="Worker threads for non-LLM steps like samplers and expressions."
hint="Used for steps like source data, generated fields, and formulas."
inputMode="numeric"
value={workersDraft}
onChange={setWorkersDraft}
@ -534,10 +498,53 @@ export function RunDialog({
)
}
/>
{kind === "full" && settings.batchEnabled && (
<>
<DraftInputField
id="run-batch-size"
label="Batch size"
hint="How many rows to generate in each batch."
inputMode="numeric"
value={batchSizeDraft}
onChange={setBatchSizeDraft}
onBlur={() =>
commitInt(
batchSizeDraft,
settings.batchSize,
1,
MAX_RECORDS,
(value) => onSettingsChange({ batchSize: value }),
setBatchSizeDraft,
)
}
/>
<div className="flex items-center justify-between gap-3 rounded-xl border border-border/60 bg-background/60 px-3 py-2 text-sm text-foreground">
<div className="space-y-0.5">
<p className="font-medium">Merge batches into one file</p>
<p className="text-xs text-muted-foreground">
Combine every batch output into one final file.
</p>
</div>
<Switch
checked={settings.mergeBatches}
onCheckedChange={(checked) =>
onSettingsChange({ mergeBatches: Boolean(checked) })
}
/>
</div>
</>
)}
</div>
</AdvancedSettingsSection>
<AdvancedSettingsSection
title="Retries and recovery"
description="Choose how hard the run should try before it gives up."
>
<div className="grid gap-4 md:grid-cols-2">
<DraftInputField
id="run-shutdown-window"
label="Error window"
hint="How many attempts to observe before early-stop checks kick in."
label="Failure check window"
hint="How many recent attempts to inspect before stopping early."
inputMode="numeric"
value={windowDraft}
onChange={setWindowDraft}
@ -552,10 +559,28 @@ export function RunDialog({
)
}
/>
<DraftInputField
id="run-shutdown-rate"
label="Stop after too many failures"
hint="Example: 0.5 stops when about half of recent attempts fail."
inputMode="decimal"
value={shutdownRateDraft}
onChange={setShutdownRateDraft}
onBlur={() =>
commitFloat(
shutdownRateDraft,
settings.shutdownErrorRate,
0,
1,
(value) => onSettingsChange({ shutdownErrorRate: value }),
setShutdownRateDraft,
)
}
/>
<DraftInputField
id="run-max-restarts"
label="Conversation restarts"
hint="How many full retries to do if model output fails validation."
label="Full retries"
hint="How many times to retry when a model answer fails checks."
inputMode="numeric"
value={restartsDraft}
onChange={setRestartsDraft}
@ -573,8 +598,8 @@ export function RunDialog({
/>
<DraftInputField
id="run-correction-steps"
label="Correction steps"
hint="Extra in-chat fix attempts before a full retry."
label="Correction attempts"
hint="How many follow-up fixes to try before starting over."
inputMode="numeric"
value={correctionsDraft}
onChange={setCorrectionsDraft}
@ -585,36 +610,16 @@ export function RunDialog({
0,
MAX_RETRY_STEPS,
(value) =>
onSettingsChange({
maxConversationCorrectionSteps: value,
}),
onSettingsChange({ maxConversationCorrectionSteps: value }),
setCorrectionsDraft,
)
}
/>
<DraftInputField
id="run-shutdown-rate"
label="Shutdown error rate"
hint="Stop early if failure rate passes this value. Example: 0.5 = 50%."
inputMode="decimal"
value={shutdownRateDraft}
onChange={setShutdownRateDraft}
onBlur={() =>
commitFloat(
shutdownRateDraft,
settings.shutdownErrorRate,
0,
1,
(value) => onSettingsChange({ shutdownErrorRate: value }),
setShutdownRateDraft,
)
}
/>
<div className="flex items-center justify-between gap-3 rounded-xl border border-border/60 bg-background/60 px-3 py-2 text-sm text-foreground md:col-span-2">
<div className="space-y-0.5">
<p className="font-medium">Keep running through failures</p>
<p className="text-xs text-muted-foreground">
Recommended for longer runs when you want maximum output.
Useful for longer runs when you want as many rows as possible.
</p>
</div>
<Switch
@ -627,57 +632,90 @@ export function RunDialog({
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</AdvancedSettingsSection>
</CollapsibleContent>
</Collapsible>
{errors.length > 0 && (
<div className="max-h-44 space-y-2 overflow-y-auto rounded-2xl border border-destructive/30 bg-destructive/5 p-4 shadow-border">
<div className="flex items-center gap-2">
<HugeiconsIcon icon={AlertCircleIcon} className="size-4 text-destructive" />
<Badge variant="outline" className="rounded-full text-[10px] text-destructive">
Run checks
</Badge>
</div>
{errors.map((error) => (
<p key={error} className="text-xs text-destructive">
{error}
</p>
))}
{errors.length > 0 && (
<div className="max-h-44 space-y-2 overflow-y-auto rounded-2xl border border-destructive/30 bg-destructive/5 p-4 shadow-border">
<div className="flex items-center gap-2">
<HugeiconsIcon
icon={AlertCircleIcon}
className="size-4 text-destructive"
/>
<Badge
variant="outline"
className="rounded-full text-[10px] text-destructive"
>
Before you run
</Badge>
</div>
)}
{errors.map((error) => (
<p key={error} className="break-words text-xs text-destructive">
{error}
</p>
))}
</div>
)}
<ValidationResultPanel validateResult={validateResult} />
<ValidationResultPanel validateResult={validateResult} />
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
className="corner-squircle border-border/70 bg-card/70"
>
Cancel
</Button>
<Button
type="button"
variant="outline"
onClick={onValidate}
disabled={loading || validateLoading}
className="corner-squircle border-border/70 bg-card/70"
>
<HugeiconsIcon icon={TestTube01Icon} className="size-3.5" />
{validateLoading ? "Validating..." : "Validate recipe"}
</Button>
<Button
type="button"
onClick={onRun}
disabled={loading || isFullRunNameMissing}
className="corner-squircle"
>
<HugeiconsIcon icon={CookBookIcon} className="size-3.5" />
{loading ? "Starting..." : `Start ${kindLabel.toLowerCase()}`}
</Button>
</DialogFooter>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={loading}
className="corner-squircle border-border/70 bg-card/70"
>
Cancel
</Button>
<Button
type="button"
variant="outline"
onClick={onValidate}
disabled={loading || validateLoading}
className="corner-squircle border-border/70 bg-card/70"
>
<HugeiconsIcon icon={TestTube01Icon} className="size-3.5" />
{validateLoading ? "Checking..." : "Check recipe"}
</Button>
<Button
type="button"
onClick={onRun}
disabled={loading || isFullRunNameMissing}
className="corner-squircle"
>
<HugeiconsIcon icon={CookBookIcon} className="size-3.5" />
{loading ? "Starting..." : `Start ${kindLabel.toLowerCase()}`}
</Button>
</DialogFooter>
</>
);
}
export function RunDialog({
open,
onOpenChange,
container,
...contentProps
}: RunDialogProps): ReactElement {
const draftKey = [open ? "open" : "closed", contentProps.kind].join("|");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
container={container}
position="absolute"
overlayPosition="absolute"
overlayClassName="bg-transparent"
className="corner-squircle max-h-[650px] overflow-y-auto overflow-x-hidden border-border/70 bg-background/95 sm:max-w-2xl shadow-border backdrop-blur-xl"
>
<RunDialogBody
key={draftKey}
{...contentProps}
onClose={() => onOpenChange(false)}
/>
</DialogContent>
</Dialog>
);

View file

@ -11,6 +11,7 @@ import { Input } from "@/components/ui/input";
import { type ReactElement, useState } from "react";
import type { SamplerConfig } from "../../types";
import { ChipInput } from "../../components/chip-input";
import { CollapsibleSectionTriggerButton } from "../shared/collapsible-section-trigger";
import { FieldLabel } from "../shared/field-label";
import { NameField } from "../shared/name-field";
@ -120,13 +121,10 @@ export function CategoryDialog({
onOpenChange={(open) => onUpdate({ advancedOpen: open })}
>
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex w-full items-center justify-between text-left text-xs text-muted-foreground"
>
<span className="font-semibold uppercase">Advanced</span>
<span>{advancedOpen ? "Hide" : "Show"}</span>
</button>
<CollapsibleSectionTriggerButton
label="Advanced list settings"
open={advancedOpen}
/>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-3">
<div className="grid gap-2">

View file

@ -48,6 +48,7 @@ import type {
SeedSamplingStrategy,
SeedSelectionType,
} from "../../types";
import { CollapsibleSectionTriggerButton } from "../shared/collapsible-section-trigger";
import { HfDatasetCombobox } from "../../components/shared/hf-dataset-combobox";
import { FieldLabel } from "../shared/field-label";
@ -626,13 +627,10 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl
onOpenChange={(openState) => onUpdate({ advancedOpen: openState })}
>
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex w-full items-center justify-between text-left text-xs text-muted-foreground"
>
<span className="font-semibold uppercase">Advanced</span>
<span>{advancedOpen ? "Hide" : "Show"}</span>
</button>
<CollapsibleSectionTriggerButton
label="Advanced source options"
open={advancedOpen}
/>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-3">
<div className="grid gap-2">

View file

@ -2,9 +2,12 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Badge } from "@/components/ui/badge";
import { ArrowDown01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactElement, useMemo, useState } from "react";
import { useRecipeStudioStore } from "../../stores/recipe-studio";
import { getAvailableVariableEntries } from "../../utils/variables";
import { RECIPE_STUDIO_REFERENCE_BADGE_TONES } from "../../utils/ui-tones";
type AvailableVariablesProps = {
configId: string;
@ -18,9 +21,6 @@ const USER_EXPANDED_FIELDS = [
"state",
"age",
] as const;
const USER_BADGE_CLASS =
"corner-squircle border-amber-500/25 bg-amber-500/10 font-mono text-[11px] text-amber-700 dark:text-amber-300";
export function AvailableVariables({
configId,
}: AvailableVariablesProps): ReactElement | null {
@ -49,10 +49,10 @@ export function AvailableVariables({
{vars.map((v) => {
const className =
v.name === "user" || v.name.startsWith("user.")
? USER_BADGE_CLASS
? RECIPE_STUDIO_REFERENCE_BADGE_TONES.user
: v.source === "seed"
? "corner-squircle border-blue-500/25 bg-blue-500/10 font-mono text-[11px] text-blue-700 dark:text-blue-300"
: "corner-squircle font-mono text-[11px]";
? RECIPE_STUDIO_REFERENCE_BADGE_TONES.seed
: RECIPE_STUDIO_REFERENCE_BADGE_TONES.default;
if (v.name !== "user") {
return (
<Badge
@ -71,9 +71,14 @@ export function AvailableVariables({
onClick={() => setShowUserFields((prev) => !prev)}
className="cursor-pointer"
aria-expanded={showUserFields}
aria-label={showUserFields ? "Hide user fields" : "Show user fields"}
>
<Badge variant="secondary" className={className}>
{`{{ ${v.name} }}`}
<span>{`{{ ${v.name} }}`}</span>
<HugeiconsIcon
icon={ArrowDown01Icon}
className={`size-3 transition-transform ${showUserFields ? "rotate-180" : ""}`}
/>
</Badge>
</button>
);
@ -83,7 +88,7 @@ export function AvailableVariables({
<Badge
key={`user-expanded:${entry.name}`}
variant="secondary"
className={USER_BADGE_CLASS}
className={RECIPE_STUDIO_REFERENCE_BADGE_TONES.user}
>
{`{{ ${entry.name} }}`}
</Badge>

View file

@ -0,0 +1,56 @@
// 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 { cn } from "@/lib/utils";
import { ArrowDown01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
forwardRef,
type ButtonHTMLAttributes,
type ReactElement,
} from "react";
type CollapsibleSectionTriggerProps = {
label: string;
open: boolean;
summary?: string;
} & ButtonHTMLAttributes<HTMLButtonElement>;
export const CollapsibleSectionTriggerButton = forwardRef<
HTMLButtonElement,
CollapsibleSectionTriggerProps
>(function CollapsibleSectionTriggerButton(
{
label,
open,
summary,
className,
type = "button",
...props
}: CollapsibleSectionTriggerProps,
ref,
): ReactElement {
return (
<button
ref={ref}
type={type}
className={cn(
"flex w-full items-center justify-between gap-3 text-left text-xs text-muted-foreground transition hover:text-foreground",
className,
)}
{...props}
>
<span className="flex min-w-0 items-center gap-2">
<HugeiconsIcon
icon={ArrowDown01Icon}
className={cn(
"size-3.5 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
<span className="font-semibold uppercase">{label}</span>
</span>
<span className="shrink-0">{summary ?? (open ? "Hide" : "Show")}</span>
</button>
);
});

View file

@ -14,8 +14,8 @@ type DialogShellProps = {
};
export function DialogShell({
title = "Configure block",
description = "Adjust block params before running the flow.",
title = "Edit step",
description = "Update this step before you run the recipe.",
}: DialogShellProps): ReactElement {
return (
<DialogHeader>

View file

@ -18,26 +18,31 @@ export function FieldLabel({
hint,
}: FieldLabelProps): ReactElement {
return (
<label
className="flex items-center gap-1.5 text-xs font-semibold uppercase text-muted-foreground"
htmlFor={htmlFor}
>
<span>{label}</span>
<div className="flex min-w-0 items-start gap-1.5 text-xs font-semibold uppercase text-muted-foreground">
{htmlFor ? (
<label className="min-w-0 cursor-pointer" htmlFor={htmlFor}>
<span className="break-words">{label}</span>
</label>
) : (
<span className="min-w-0 break-words">{label}</span>
)}
{hint && (
<Tooltip>
<TooltipTrigger asChild={true}>
<button
type="button"
className="inline-flex size-3.5 items-center justify-center rounded-full text-muted-foreground/80 hover:text-foreground"
className="inline-flex size-6 shrink-0 items-center justify-center rounded-full text-muted-foreground/80 transition hover:text-foreground"
aria-label={`More info: ${label}`}
title={`More info about ${label}`}
>
<HugeiconsIcon icon={InformationCircleIcon} className="size-3.5" />
<HugeiconsIcon icon={InformationCircleIcon} className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>{hint}</TooltipContent>
<TooltipContent className="max-w-64 break-words text-xs leading-relaxed">
{hint}
</TooltipContent>
</Tooltip>
)}
</label>
</div>
);
}

View file

@ -25,11 +25,11 @@ export function NameField({
return (
<div className="grid gap-2">
<FieldLabel
label={label ?? "Column name"}
label={label ?? "Field name"}
htmlFor={inputId}
hint={
hint ??
"Unique field name used in templates and final dataset output."
"This name is used in prompts and in the final dataset."
}
/>
<Input

View file

@ -16,7 +16,7 @@ export function ValidationBanner({
}
return (
<p className="text-xs text-amber-600">
<span className="font-semibold">Fix before run: </span>
<span className="font-semibold">Needs attention: </span>
{errors.join(". ")}.
</p>
);

View file

@ -20,6 +20,7 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactElement, useEffect, useMemo, useRef, useState } from "react";
import { listMcpTools } from "../../api";
import { ChipInput } from "../../components/chip-input";
import { CollapsibleSectionTriggerButton } from "../shared/collapsible-section-trigger";
import type { LlmMcpProviderConfig, McpEnvVar, ToolProfileConfig } from "../../types";
import { FieldLabel } from "../shared/field-label";
import { NameField } from "../shared/name-field";
@ -105,14 +106,14 @@ function McpServerCard({
provider.env && provider.env.length > 0
? provider.env
: [{ key: "", value: "" }];
const summaryTitle = provider.name.trim() || `MCP server ${index + 1}`;
const summaryTitle = provider.name.trim() || `Tool server ${index + 1}`;
const transportLabel =
provider.provider_type === "stdio" ? "STDIO" : "Streamable HTTP";
provider.provider_type === "stdio" ? "Local command" : "HTTP";
const toolsLabel = typeof toolsCount === "number" ? `${toolsCount} tools` : null;
const description =
provider.provider_type === "stdio"
? "Launches a local MCP process over stdio."
: "Calls a remote MCP endpoint from the backend.";
? "Runs a local tool server."
: "Calls a remote tool server.";
return (
<Collapsible open={open} onOpenChange={onOpenChange}>
@ -165,7 +166,7 @@ function McpServerCard({
)}
<div className="grid gap-2">
<FieldLabel label="Server name" hint="Unique name inside this tool profile." />
<FieldLabel label="Server name" hint="Name shown in this tool access setup." />
<Input
className="nodrag"
value={provider.name}
@ -185,16 +186,16 @@ function McpServerCard({
})
}
>
<TabsList className="w-full">
<TabsTrigger value="stdio">STDIO</TabsTrigger>
<TabsTrigger value="streamable_http">Streamable HTTP</TabsTrigger>
</TabsList>
<TabsList className="w-full">
<TabsTrigger value="stdio">Local command</TabsTrigger>
<TabsTrigger value="streamable_http">HTTP endpoint</TabsTrigger>
</TabsList>
</Tabs>
{provider.provider_type === "stdio" ? (
<div className="space-y-4">
<div className="grid gap-2">
<FieldLabel label="Command" hint="Executable used to start the MCP server." />
<FieldLabel label="Command" hint="Command used to start the tool server." />
<Input
className="nodrag"
value={provider.command ?? ""}
@ -207,7 +208,7 @@ function McpServerCard({
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<FieldLabel label="Args" hint="Optional CLI args." />
<FieldLabel label="Arguments" hint="Optional command arguments." />
<Button
type="button"
size="xs"
@ -242,7 +243,7 @@ function McpServerCard({
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<FieldLabel label="Env vars" hint="Optional process env." />
<FieldLabel label="Environment variables" hint="Optional values passed to the tool server." />
<Button
type="button"
size="xs"
@ -293,7 +294,7 @@ function McpServerCard({
) : (
<div className="space-y-4">
<div className="grid gap-2">
<FieldLabel label="Endpoint" hint="Backend calls this MCP URL." />
<FieldLabel label="Endpoint" hint="URL for the tool server." />
<Input
className="nodrag"
value={provider.endpoint ?? ""}
@ -306,13 +307,13 @@ function McpServerCard({
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid gap-2">
<FieldLabel
label="API key env"
hint="Optional env var used on the backend."
label="API key environment variable"
hint="Optional environment variable that stores the API key."
/>
<Input
className="nodrag"
value={provider.api_key_env ?? ""}
placeholder="MCP_API_KEY"
placeholder="TOOL_SERVER_API_KEY"
onChange={(event) =>
onUpdateProviderAt(index, {
// biome-ignore lint/style/useNamingConvention: api schema
@ -324,7 +325,7 @@ function McpServerCard({
<div className="grid gap-2">
<FieldLabel
label="API key"
hint="Optional inline token."
hint="Optional API key."
/>
<Input
className="nodrag"
@ -352,6 +353,10 @@ export function ToolProfileDialog({
onUpdate,
}: ToolProfileDialogProps): ReactElement {
const providers = config.mcp_providers;
const [activeTab, setActiveTab] = useState<"profile" | "servers">(
providers.length > 0 ? "profile" : "servers",
);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [loadingTools, setLoadingTools] = useState(false);
const [toolsByProvider, setToolsByProvider] = useState<Record<string, string[]>>(
config.fetched_tools_by_provider ?? {},
@ -528,8 +533,8 @@ export function ToolProfileDialog({
const readyProviders = providers.filter(isProviderReadyForToolFetch);
if (readyProviders.length === 0) {
toastError(
"No MCP servers ready",
"Add a server name plus command or endpoint first.",
"No tool servers are ready",
"Add a server name plus a command or endpoint first.",
);
return;
}
@ -567,8 +572,8 @@ export function ToolProfileDialog({
setDuplicateTools(response.duplicate_tools ?? {});
} catch (error) {
toastError(
"Failed to load tools",
error instanceof Error ? error.message : "Could not load MCP tools.",
"Couldn't load tools",
error instanceof Error ? error.message : "We couldn't load the tools for these servers.",
);
} finally {
setLoadingTools(false);
@ -588,49 +593,65 @@ export function ToolProfileDialog({
);
const hasProviders = providers.length > 0;
useEffect(() => {
if (!hasProviders && activeTab === "profile") {
setActiveTab("servers");
}
}, [activeTab, hasProviders]);
return (
<Tabs defaultValue="profile" className="w-full">
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(value === "servers" ? "servers" : "profile")
}
className="w-full"
>
<TabsList className="w-full">
<TabsTrigger value="profile">Profile</TabsTrigger>
<TabsTrigger value="servers">MCP servers</TabsTrigger>
<TabsTrigger value="servers">1. Add servers</TabsTrigger>
<TabsTrigger value="profile">2. Choose tools</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="space-y-4 pt-3">
<NameField
label="Tool profile name"
label="Tool access name"
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
{!hasProviders ? (
<EmptyState
title="Add MCP server to configure tools"
description="This profile becomes useful after at least one MCP server is configured in the MCP servers tab."
/>
<div className="space-y-3">
<EmptyState
title="Add a server to start choosing tools"
description="Set up a server first, then come back here to choose which tools this step can use."
/>
<Button
type="button"
variant="outline"
onClick={() => setActiveTab("servers")}
>
Add servers first
</Button>
</div>
) : (
<>
<div className="space-y-2">
<FieldLabel
label="Configured servers"
hint="All servers in this profile are available to any LLM using this tool profile."
/>
<div className="flex flex-wrap gap-2">
{providerNames.map((providerName) => (
<Badge key={providerName} variant="secondary" className="rounded-full">
{providerName}
</Badge>
))}
</div>
<div className="rounded-2xl border border-border/60 bg-muted/10 px-4 py-3">
<p className="text-sm font-semibold text-foreground">
Pick which tools this setup may use
</p>
<p className="mt-1 text-xs text-muted-foreground">
1. Load tool names from your servers. 2. Leave the list empty to
allow all tools, or add only the ones this step should use.
</p>
</div>
<div className="space-y-3 rounded-2xl border border-border/60 bg-muted/10 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-foreground">
Available tool refs
Available tools
</p>
<p className="text-xs text-muted-foreground">
Load tools from backend so users pick tool names instead of guessing.
Load tool names so you can pick from a list instead of guessing.
</p>
</div>
<Button
@ -649,7 +670,7 @@ export function ToolProfileDialog({
{Object.keys(toolsByProvider).length === 0 &&
Object.keys(providerErrors).length === 0 && (
<p className="text-xs text-muted-foreground">
No tools loaded yet.
Load tools to browse what's available.
</p>
)}
@ -675,7 +696,7 @@ export function ToolProfileDialog({
{Object.entries(duplicateTools).length > 0 && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
Duplicate tool names across servers:
Some tool names appear on more than one server:
{" "}
{Object.entries(duplicateTools)
.map(([toolName, providerList]) => `${toolName} (${providerList.join(", ")})`)
@ -686,8 +707,8 @@ export function ToolProfileDialog({
<div className="grid gap-2">
<FieldLabel
label="Allow tools (optional)"
hint="Leave empty to allow all tools from configured MCP servers."
label="Tools this setup may use"
hint="Leave this empty to allow every tool from these servers."
/>
<ChipInput
values={config.allow_tools ?? []}
@ -710,60 +731,79 @@ export function ToolProfileDialog({
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<FieldLabel
label="Max tool call turns"
hint="Required. Data Designer defaults to 5."
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild={true}>
<CollapsibleSectionTriggerButton
label="Tool-call limits"
open={advancedOpen}
/>
<Input
className="nodrag"
value={config.max_tool_call_turns ?? ""}
onChange={(event) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
max_tool_call_turns: event.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Timeout sec"
hint="Optional. Applies to MCP tool loading and calls."
/>
<Input
className="nodrag"
value={config.timeout_sec ?? ""}
onChange={(event) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
timeout_sec: event.target.value,
})
}
/>
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<FieldLabel
label="Max tool-use turns"
hint="How many back-and-forth tool calls an AI step can make."
/>
<Input
className="nodrag"
value={config.max_tool_call_turns ?? ""}
onChange={(event) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
max_tool_call_turns: event.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Timeout (seconds)"
hint="How long to wait when loading or calling tools."
/>
<Input
className="nodrag"
value={config.timeout_sec ?? ""}
onChange={(event) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
timeout_sec: event.target.value,
})
}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</>
)}
</TabsContent>
<TabsContent value="servers" className="space-y-4 pt-3">
<div className="rounded-2xl border border-border/60 bg-muted/10 px-4 py-3">
<p className="text-sm font-semibold text-foreground">
Add one or more tool servers
</p>
<p className="mt-1 text-xs text-muted-foreground">
After your servers are ready, switch to Choose tools to load names
and decide which ones this setup should allow.
</p>
</div>
<div className="flex items-center justify-between gap-3">
<FieldLabel
label="MCP servers"
hint="These server defs are owned by this tool profile and reused by linked LLMs."
label="Tool servers"
hint="These servers belong to this tool access setup and can be reused by linked AI steps."
/>
<Button type="button" size="xs" variant="outline" onClick={addProvider}>
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
Add MCP server
Add server
</Button>
</div>
{!hasProviders ? (
<EmptyState
title="No MCP servers yet"
description="Add one or more servers here. Then go back to Profile to load and pick tools."
title="No tool servers yet"
description="Add one or more servers here, then go back to Access to load and choose tools."
/>
) : (
<div className="space-y-3">

View file

@ -38,6 +38,7 @@ import {
OXC_VALIDATION_MODES,
normalizeOxcValidationMode,
} from "../../utils/validators/oxc-mode";
import { CollapsibleSectionTriggerButton } from "../shared/collapsible-section-trigger";
import { FieldLabel } from "../shared/field-label";
import { NameField } from "../shared/name-field";
@ -101,14 +102,16 @@ export function ValidatorDialog({
return (
<div className="space-y-4">
<NameField
label="Check name"
hint="Name used for this check in the canvas and run results."
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
<div className="grid gap-2">
<FieldLabel
label="Target code column"
label="Code to check"
htmlFor={targetColumnId}
hint="Must reference an LLM Code block."
hint="Choose the AI code step this check should review."
/>
<Select
value={currentTarget || NONE_VALUE}
@ -146,20 +149,20 @@ export function ValidatorDialog({
</SelectContent>
</Select>
{codeOptions.length === 0 && (
<p className="text-xs text-muted-foreground">
{config.validator_type === "oxc"
? "Add an LLM Code block with javascript/typescript first."
: "Add an LLM Code block first."}
</p>
<p className="text-xs text-muted-foreground">
{config.validator_type === "oxc"
? "Add an AI code step that generates JavaScript or TypeScript first."
: "Add an AI code step first."}
</p>
)}
</div>
{config.validator_type === "oxc" && (
<div className="grid gap-3">
<div className="grid gap-2">
<FieldLabel
label="Validation mode"
label="Check mode"
htmlFor={oxcModeId}
hint="syntax: parser only. lint: oxlint only. syntax+lint: both."
hint="Choose whether to check syntax, lint rules, or both."
/>
<div ref={oxcModeAnchorRef}>
<Combobox
@ -198,7 +201,7 @@ export function ValidatorDialog({
<FieldLabel
label="Code shape"
htmlFor={oxcCodeShapeId}
hint="auto: detect module/snippet. module: strict file. snippet: wrapped fragment."
hint="Choose whether the code should be treated like a full file or a smaller snippet."
/>
<div ref={oxcCodeShapeAnchorRef}>
<Combobox
@ -240,20 +243,17 @@ export function ValidatorDialog({
onOpenChange={(open) => onUpdate({ advancedOpen: open })}
>
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex w-full items-center justify-between text-left text-xs text-muted-foreground"
>
<span className="font-semibold uppercase">Advanced</span>
<span>{advancedOpen ? "Hide" : "Show"}</span>
</button>
<CollapsibleSectionTriggerButton
label="Advanced check settings"
open={advancedOpen}
/>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
<div className="grid gap-2">
<FieldLabel
label="Batch size"
htmlFor={batchSizeId}
hint="Records per validation batch."
hint="How many records to check at a time."
/>
<Input
id={batchSizeId}

View file

@ -0,0 +1,52 @@
// 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 { useMemo } from "react";
import { useRecipeStudioStore } from "../stores/recipe-studio";
import { INFRA_NODE_KINDS, type NodeConfig } from "../types";
type ConnectionStatus = {
/** True when the node has zero edges at all. */
isDisconnected: boolean;
/** True when an LLM node has no incoming data edge (only infra). */
missingDataInput: boolean;
};
export function useNodeConnectionStatus(
nodeId: string,
config: NodeConfig | undefined,
): ConnectionStatus {
const edges = useRecipeStudioStore((state) => state.edges);
const configs = useRecipeStudioStore((state) => state.configs);
return useMemo(() => {
const empty: ConnectionStatus = {
isDisconnected: false,
missingDataInput: false,
};
if (!config || config.kind === "markdown_note") {
return empty;
}
const nodeEdges = edges.filter(
(e) => e.source === nodeId || e.target === nodeId,
);
const isDisconnected = nodeEdges.length === 0;
let missingDataInput = false;
if (config.kind === "llm" && !isDisconnected) {
const hasDataEdge = nodeEdges.some((e) => {
const otherId = e.source === nodeId ? e.target : e.source;
const otherConfig = configs[otherId];
return otherConfig && !INFRA_NODE_KINDS.has(otherConfig.kind);
});
missingDataInput = !hasDataEdge;
}
return {
isDisconnected,
missingDataInput,
};
}, [nodeId, config, edges, configs]);
}

View file

@ -36,6 +36,7 @@ type UseRecipePersistenceParams = {
};
type UseRecipePersistenceResult = {
initialRecipeReady: boolean;
workflowName: string;
setWorkflowName: (value: string) => void;
saveLoading: boolean;
@ -174,6 +175,7 @@ export function useRecipePersistence({
loadRecipe,
getCurrentPayloadFromStore,
}: UseRecipePersistenceParams): UseRecipePersistenceResult {
const [initialRecipeReady, setInitialRecipeReady] = useState(false);
const [workflowName, setWorkflowName] = useState("Unnamed");
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
const [savedSignature, setSavedSignature] = useState("");
@ -195,6 +197,7 @@ export function useRecipePersistence({
const savedAtLabel = formatSavedLabel(lastSavedAt);
useEffect(() => {
setInitialRecipeReady(false);
const nextName = normalizeNonEmptyName(initialRecipeName, "Unnamed");
resetRecipe();
setWorkflowName(nextName);
@ -210,6 +213,7 @@ export function useRecipePersistence({
const payload = getCurrentPayloadFromStore();
setSavedSignature(buildSignature(nextName, payload));
setInitialRecipeReady(true);
}, [
getCurrentPayloadFromStore,
initialPayload,
@ -287,6 +291,7 @@ export function useRecipePersistence({
);
return {
initialRecipeReady,
workflowName,
setWorkflowName,
saveLoading,

View file

@ -37,6 +37,7 @@ type UseRecipeStudioActionsParams = {
};
type UseRecipeStudioActionsResult = {
initialRecipeReady: boolean;
workflowName: string;
setWorkflowName: (value: string) => void;
saveLoading: boolean;
@ -116,6 +117,7 @@ export function useRecipeStudioActions({
});
return {
initialRecipeReady: persistence.initialRecipeReady,
workflowName: persistence.workflowName,
setWorkflowName: persistence.setWorkflowName,
saveLoading: persistence.saveLoading,

View file

@ -1,6 +1,11 @@
// 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 {
DocumentAttachmentIcon,
PlusSignIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Background,
BackgroundVariant,
@ -12,8 +17,6 @@ import {
ReactFlow,
type ReactFlowInstance,
} from "@xyflow/react";
import { PlusSignIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type ReactElement,
useCallback,
@ -24,39 +27,48 @@ import {
} from "react";
import { useShallow } from "zustand/react/shallow";
import "@xyflow/react/dist/style.css";
import { RecipeGraphAuxNode, type RecipeGraphAuxNodeData } from "./components/recipe-graph-aux-node";
import {
BlockSheet,
} from "./components/block-sheet";
import { Button } from "@/components/ui/button";
import { BlockSheet } from "./components/block-sheet";
import { LayoutControls } from "./components/controls/layout-controls";
import { RunValidateFloatingControls } from "./components/controls/run-validate-floating-controls";
import { ViewportControls } from "./components/controls/viewport-controls";
import { ExecutionsView } from "./components/executions/executions-view";
import { InternalsSync } from "./components/graph/internals-sync";
import { ExecutionProgressIsland } from "./components/runtime/execution-progress-island";
import { RecipeStudioHeader } from "./components/recipe-studio-header";
import {
RecipeGraphAuxNode,
type RecipeGraphAuxNodeData,
} from "./components/recipe-graph-aux-node";
import { RecipeNode } from "./components/recipe-graph-node";
import { RecipeGraphSemanticEdge } from "./components/recipe-graph-semantic-edge";
import { RecipeStudioHeader } from "./components/recipe-studio-header";
import { DataEdge } from "./components/rf-ui/data-edge";
import { ExecutionProgressIsland } from "./components/runtime/execution-progress-island";
import { ConfigDialog } from "./dialogs/config-dialog";
import { ImportDialog } from "./dialogs/import-dialog";
import { RunDialog } from "./dialogs/preview-dialog";
import { ProcessorsDialog } from "./dialogs/processors-dialog";
import type {
RecipeExecutionRecord,
RecipeStudioView,
} from "./execution-types";
import { isExecutionInProgress } from "./executions/execution-helpers";
import { useRecipeEditorGraph } from "./hooks/use-recipe-editor-graph";
import { useRecipeRuntimeVisuals } from "./hooks/use-recipe-runtime-visuals";
import { useRecipeStudioActions } from "./hooks/use-recipe-studio-actions";
import { useRecipeStudioStore } from "./stores/recipe-studio";
import { isExecutionInProgress } from "./executions/execution-helpers";
import type { RecipeNodeData } from "./types";
import { getGraphWarnings } from "./utils/graph-warnings";
import { getFitNodeIdsIgnoringNotes } from "./utils/graph/fit-view";
import { buildRecipePayload } from "./utils/payload";
import type { RecipePayload } from "./utils/payload/types";
import { buildDefaultSchemaTransform } from "./utils/processors";
import { buildDialogOptions } from "./utils/recipe-studio-view";
import type { RecipeExecutionRecord, RecipeStudioView } from "./execution-types";
const NODE_TYPES: NodeTypes = { builder: RecipeNode, aux: RecipeGraphAuxNode };
const EDGE_TYPES: EdgeTypes = { canvas: DataEdge, semantic: RecipeGraphSemanticEdge };
const EDGE_TYPES: EdgeTypes = {
canvas: DataEdge,
semantic: RecipeGraphSemanticEdge,
};
const COMPLETE_ISLAND_VISIBLE_MS = 7_000;
const TAB_SWITCH_FIT_DELAY_MS = 110;
const FIT_ANIMATION_MS = 340;
@ -94,6 +106,7 @@ export function RecipeStudioPage({
llmAuxVisibility,
configs,
processors,
sheetOpen,
sheetView,
activeConfigId,
dialogOpen,
@ -115,6 +128,7 @@ export function RecipeStudioPage({
openConfig,
updateConfig,
isValidConnection,
setSheetOpen,
setSheetView,
setProcessors,
setDialogOpen,
@ -132,6 +146,7 @@ export function RecipeStudioPage({
llmAuxVisibility: state.llmAuxVisibility,
configs: state.configs,
processors: state.processors,
sheetOpen: state.sheetOpen,
sheetView: state.sheetView,
activeConfigId: state.activeConfigId,
dialogOpen: state.dialogOpen,
@ -153,6 +168,7 @@ export function RecipeStudioPage({
openConfig: state.openConfig,
updateConfig: state.updateConfig,
isValidConnection: state.isValidConnection,
setSheetOpen: state.setSheetOpen,
setSheetView: state.setSheetView,
setProcessors: state.setProcessors,
setDialogOpen: state.setDialogOpen,
@ -168,16 +184,16 @@ export function RecipeStudioPage({
null,
);
const flowContainerRef = useRef<HTMLDivElement | null>(null);
const [blockSheetOpen, setBlockSheetOpen] = useState(false);
const [activeView, setActiveView] = useState<RecipeStudioView>("editor");
const [processorsOpen, setProcessorsOpen] = useState(false);
const [interactive, setInteractive] = useState(true);
const [runtimeIslandMinimized, setRuntimeIslandMinimized] = useState(false);
const [recentCompletedExecution, setRecentCompletedExecution] =
useState<RecipeExecutionRecord | null>(null);
const [reactFlowInstance, setReactFlowInstance] = useState<
ReactFlowInstance<Node<RecipeNodeData | RecipeGraphAuxNodeData>, Edge> | null
>(null);
const [reactFlowInstance, setReactFlowInstance] = useState<ReactFlowInstance<
Node<RecipeNodeData | RecipeGraphAuxNodeData>,
Edge
> | null>(null);
const lastProcessedFitTickRef = useRef(0);
const previousActiveViewRef = useRef<RecipeStudioView>("editor");
const previousActiveExecutionIdRef = useRef<string | null>(null);
@ -257,6 +273,7 @@ export function RecipeStudioPage({
).payload;
}, []);
const {
initialRecipeReady,
workflowName,
setWorkflowName,
saveLoading,
@ -354,7 +371,8 @@ export function RecipeStudioPage({
}
const latestCompleted = executions.find(
(execution) =>
execution.status === "completed" && typeof execution.finishedAt === "number",
execution.status === "completed" &&
typeof execution.finishedAt === "number",
);
if (!latestCompleted || typeof latestCompleted.finishedAt !== "number") {
setRecentCompletedExecution(null);
@ -390,8 +408,12 @@ export function RecipeStudioPage({
const openRootBlockSheet = useCallback(() => {
setSheetView("root");
setBlockSheetOpen(true);
}, [setSheetView]);
setSheetOpen(true);
}, [setSheetOpen, setSheetView]);
const openSourceBlockSheet = useCallback(() => {
setSheetView("seed");
setSheetOpen(true);
}, [setSheetOpen, setSheetView]);
const runDialogRows = runDialogKind === "preview" ? previewRows : fullRows;
const runDialogLoading =
runDialogKind === "preview" ? previewLoading : fullLoading;
@ -407,7 +429,9 @@ export function RecipeStudioPage({
let retryFrameId = 0;
const fitWithCurrentNodes = () => {
const targetNodes = getFitNodeIdsIgnoringNotes(reactFlowInstance.getNodes());
const targetNodes = getFitNodeIdsIgnoringNotes(
reactFlowInstance.getNodes(),
);
if (targetNodes.length === 0) {
return false;
}
@ -455,9 +479,13 @@ export function RecipeStudioPage({
);
useEffect(() => {
if (previousActiveViewRef.current !== activeView && activeView === "editor") {
if (
previousActiveViewRef.current !== activeView &&
activeView === "editor"
) {
pendingEditorTabFitRef.current = true;
forceEditorTabFitRef.current = previousActiveViewRef.current === "executions";
forceEditorTabFitRef.current =
previousActiveViewRef.current === "executions";
}
previousActiveViewRef.current = activeView;
}, [activeView]);
@ -479,7 +507,7 @@ export function RecipeStudioPage({
pendingEditorTabFitRef.current = false;
const forceFit = forceEditorTabFitRef.current;
forceEditorTabFitRef.current = false;
if (!forceFit && !viewportMovedSinceAutoFitRef.current) {
if (!(forceFit || viewportMovedSinceAutoFitRef.current)) {
return;
}
return scheduleFitView({ delayMs: TAB_SWITCH_FIT_DELAY_MS });
@ -496,6 +524,163 @@ export function RecipeStudioPage({
return scheduleFitView();
}, [activeView, fitViewTick, reactFlowInstance, scheduleFitView]);
let editorContent: ReactElement;
if (initialRecipeReady) {
editorContent = (
<ReactFlow<Node<RecipeNodeData | RecipeGraphAuxNodeData>, Edge>
onInit={setReactFlowInstance}
onDragOver={handleDragOver}
onDrop={handleDrop}
nodes={displayGraph.nodes}
edges={displayGraph.edges}
proOptions={{ hideAttribution: true }}
nodeTypes={NODE_TYPES}
edgeTypes={EDGE_TYPES}
defaultEdgeOptions={{
type: "canvas",
data: { path: "smoothstep" },
}}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
isValidConnection={isValidConnection}
onMoveEnd={(event) => {
if (event) {
viewportMovedSinceAutoFitRef.current = true;
}
}}
nodesDraggable={canvasInteractive}
nodesConnectable={canvasInteractive}
elementsSelectable={canvasInteractive}
fitView={false}
className="h-full w-full rounded-t-none"
>
<LayoutControls
direction={layoutDirection}
onLayout={applyLayout}
onToggleDirection={handleToggleDirection}
/>
<InternalsSync nodeIds={displayNodeIds} />
<Background
variant={BackgroundVariant.Dots}
gap={18}
size={1}
color="#d4d4d8"
/>
{nodes.length === 0 && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center p-4">
<div className="pointer-events-auto w-full max-w-md rounded-2xl border border-dashed border-border/70 bg-background/80 px-6 py-6 text-center shadow-border backdrop-blur-[1px]">
<div className="mx-auto flex size-12 items-center justify-center corner-squircle rounded-xl border border-border/70 bg-muted/40">
<HugeiconsIcon
icon={DocumentAttachmentIcon}
className="size-6 text-muted-foreground"
/>
</div>
<div className="mt-4 space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-primary">
Best place to start
</p>
<p className="text-sm font-semibold text-foreground">
Start with source data
</p>
<p className="text-xs text-muted-foreground">
Most synthetic-data recipes begin with a document, dataset, or
file before adding generation and checks.
</p>
</div>
<div className="mt-5 flex flex-col justify-center gap-2 sm:flex-row">
<Button
type="button"
className="corner-squircle"
onClick={openSourceBlockSheet}
>
<HugeiconsIcon
icon={DocumentAttachmentIcon}
className="size-4"
/>
Start with source data
</Button>
<Button
type="button"
variant="outline"
className="corner-squircle"
onClick={openRootBlockSheet}
>
<HugeiconsIcon icon={PlusSignIcon} className="size-4" />
Browse all steps
</Button>
</div>
</div>
</div>
)}
<Panel position="top-right" className="m-3">
<BlockSheet
container={sheetContainer}
sheetView={sheetView}
onViewChange={setSheetView}
open={sheetOpen}
onOpenChange={setSheetOpen}
onAddSampler={handleAddSamplerFromSheet}
onAddSeed={handleAddSeedFromSheet}
onAddLlm={handleAddLlmFromSheet}
onAddModelProvider={handleAddModelProviderFromSheet}
onAddModelConfig={handleAddModelConfigFromSheet}
onAddToolProfile={handleAddToolProfileFromSheet}
onAddExpression={handleAddExpressionFromSheet}
onAddValidator={handleAddValidatorFromSheet}
onAddMarkdownNote={handleAddMarkdownNoteFromSheet}
onOpenProcessors={openProcessorsFromSheet}
copied={copied}
onCopy={copyRecipe}
onImport={() => setImportOpen(true)}
/>
</Panel>
<ViewportControls
interactive={canvasInteractive}
lockDisabled={executionLocked}
onToggleInteractive={toggleInteractive}
/>
{islandExecution &&
(isExecutionInProgress(islandExecution.status) ||
islandExecution.status === "completed") && (
<Panel position="top-center" className="!m-0">
<ExecutionProgressIsland
execution={islandExecution}
currentColumnIcon={currentColumnIcon}
minimized={runtimeIslandMinimized}
onMinimizedChange={setRuntimeIslandMinimized}
onViewExecutions={() => setActiveView("executions")}
/>
</Panel>
)}
<RunValidateFloatingControls
runBusy={runBusy}
runDialogKind={runDialogKind}
validateLoading={validateLoading}
executionLocked={executionLocked}
onOpenRunDialog={openRunDialog}
onValidate={() => {
openRunDialog(runDialogKind);
void validateFromDialog();
}}
/>
</ReactFlow>
);
} else {
editorContent = (
<div className="flex h-full items-center justify-center px-6">
<div className="rounded-2xl border border-border/70 bg-background/80 px-5 py-4 text-center shadow-border backdrop-blur-[1px]">
<p className="text-sm font-medium text-foreground">Loading recipe</p>
<p className="mt-1 text-xs text-muted-foreground">
Restoring the studio graph and saved settings.
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<main className="w-full px-6 py-8">
@ -509,131 +694,19 @@ export function RecipeStudioPage({
saveTone={saveTone}
savedAtLabel={savedAtLabel}
workflowName={workflowName}
warnings={getGraphWarnings(configs, edges)}
onWorkflowNameChange={setWorkflowName}
onViewChange={setActiveView}
onSaveRecipe={() => {
void persistRecipe();
}}
/>
<div className="h-[75vh] w-full rounded-t-none" ref={flowContainerRef}>
<div
className="h-[75vh] w-full rounded-t-none"
ref={flowContainerRef}
>
{activeView === "editor" ? (
<ReactFlow<Node<RecipeNodeData | RecipeGraphAuxNodeData>, Edge>
onInit={setReactFlowInstance}
onDragOver={handleDragOver}
onDrop={handleDrop}
nodes={displayGraph.nodes}
edges={displayGraph.edges}
nodeTypes={NODE_TYPES}
edgeTypes={EDGE_TYPES}
defaultEdgeOptions={{
type: "canvas",
data: { path: "smoothstep" },
}}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
isValidConnection={isValidConnection}
onMoveEnd={(event) => {
if (event) {
viewportMovedSinceAutoFitRef.current = true;
}
}}
nodesDraggable={canvasInteractive}
nodesConnectable={canvasInteractive}
elementsSelectable={canvasInteractive}
fitView={false}
className="h-full w-full rounded-t-none"
>
<LayoutControls
direction={layoutDirection}
onLayout={applyLayout}
onToggleDirection={handleToggleDirection}
/>
<InternalsSync nodeIds={displayNodeIds} />
<Background
variant={BackgroundVariant.Dots}
gap={18}
size={1}
color="#d4d4d8"
/>
{nodes.length === 0 && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center p-4">
<button
type="button"
onClick={openRootBlockSheet}
className="pointer-events-auto corner-squircle flex min-h-36 w-full max-w-md flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border/70 bg-background/75 px-6 py-6 text-center backdrop-blur-[1px] transition hover:border-primary/60 hover:bg-background"
>
<div className="flex size-12 items-center justify-center corner-squircle rounded-xl border border-border/70 bg-muted/40">
<HugeiconsIcon
icon={PlusSignIcon}
className="size-6 text-muted-foreground"
/>
</div>
<div>
<p className="text-sm font-semibold text-foreground">
Add your first block
</p>
<p className="text-xs text-muted-foreground">
Click to open block library.
</p>
</div>
</button>
</div>
)}
<Panel position="top-right" className="m-3">
<BlockSheet
container={sheetContainer}
sheetView={sheetView}
onViewChange={setSheetView}
open={blockSheetOpen}
onOpenChange={setBlockSheetOpen}
onAddSampler={handleAddSamplerFromSheet}
onAddSeed={handleAddSeedFromSheet}
onAddLlm={handleAddLlmFromSheet}
onAddModelProvider={handleAddModelProviderFromSheet}
onAddModelConfig={handleAddModelConfigFromSheet}
onAddToolProfile={handleAddToolProfileFromSheet}
onAddExpression={handleAddExpressionFromSheet}
onAddValidator={handleAddValidatorFromSheet}
onAddMarkdownNote={handleAddMarkdownNoteFromSheet}
onOpenProcessors={openProcessorsFromSheet}
copied={copied}
onCopy={copyRecipe}
onImport={() => setImportOpen(true)}
/>
</Panel>
<ViewportControls
interactive={canvasInteractive}
lockDisabled={executionLocked}
onToggleInteractive={toggleInteractive}
/>
{islandExecution &&
(isExecutionInProgress(islandExecution.status) ||
islandExecution.status === "completed") && (
<Panel position="top-center" className="!m-0">
<ExecutionProgressIsland
execution={islandExecution}
currentColumnIcon={currentColumnIcon}
minimized={runtimeIslandMinimized}
onMinimizedChange={setRuntimeIslandMinimized}
onViewExecutions={() => setActiveView("executions")}
/>
</Panel>
)}
<RunValidateFloatingControls
runBusy={runBusy}
runDialogKind={runDialogKind}
validateLoading={validateLoading}
executionLocked={executionLocked}
onOpenRunDialog={openRunDialog}
onValidate={() => {
openRunDialog(runDialogKind);
void validateFromDialog();
}}
/>
</ReactFlow>
editorContent
) : (
<ExecutionsView
executions={executions}

View file

@ -67,6 +67,7 @@ type RecipeStudioState = {
llmAuxVisibility: Record<string, boolean>;
configs: Record<string, NodeConfig>;
processors: RecipeProcessorConfig[];
sheetOpen: boolean;
sheetView: SheetView;
activeConfigId: string | null;
dialogOpen: boolean;
@ -75,6 +76,7 @@ type RecipeStudioState = {
nextId: number;
nextY: number;
fitViewTick: number;
setSheetOpen: (open: boolean) => void;
setSheetView: (view: SheetView) => void;
setProcessors: (processors: RecipeProcessorConfig[]) => void;
setDialogOpen: (open: boolean) => void;
@ -122,6 +124,7 @@ const INITIAL_STATE = {
llmAuxVisibility: {},
configs: {},
processors: [],
sheetOpen: false,
sheetView: "root",
activeConfigId: null,
dialogOpen: false,
@ -138,6 +141,7 @@ const INITIAL_STATE = {
| "llmAuxVisibility"
| "configs"
| "processors"
| "sheetOpen"
| "sheetView"
| "activeConfigId"
| "dialogOpen"
@ -260,6 +264,7 @@ function isModelSemanticEdge(edge: Edge, configs: Record<string, NodeConfig>): b
export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
...INITIAL_STATE,
setSheetOpen: (open) => set({ sheetOpen: open }),
setSheetView: (view) => set({ sheetView: view }),
setProcessors: (processors) =>
set((state) => (state.executionLocked ? state : { processors })),
@ -314,6 +319,7 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
direction: state.layoutDirection,
nodesep: isTopBottom ? 120 : 80,
ranksep: isTopBottom ? 140 : 80,
configs: state.configs,
});
const layoutedPositions = new Map(
nodes.map((node) => [node.id, node.position] as const),
@ -427,7 +433,37 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
if (state.executionLocked) {
return state;
}
return buildAddedNodeState(state, "llm", type, position, openDialog);
const added = buildAddedNodeState(state, "llm", type, position, openDialog);
const context = getAddedNodeContext(added);
if (!context) {
return added;
}
let { nodes, configs } = context;
let edges = state.edges;
const modelConfigs = Object.values(configs).filter(
(config) => config.kind === "model_config",
);
if (modelConfigs.length === 1) {
if (!position) {
nodes = placeNodeNear(
nodes,
context.newNodeId,
modelConfigs[0].id,
state.layoutDirection,
"after",
);
}
const next = connectSemantic(
edges,
configs,
modelConfigs[0].id,
context.newNodeId,
state.layoutDirection,
);
edges = next.edges;
configs = next.configs;
}
return { ...added, nodes, edges, configs };
}),
addModelProviderNode: (position, openDialog = true) =>
set((state) => {

View file

@ -39,6 +39,11 @@ export type LayoutDirection = "LR" | "TB";
export type SeedSamplingStrategy = "ordered" | "shuffle";
export type SeedSelectionType = "none" | "index_range" | "partition_block";
export type SeedSourceType = "hf" | "local" | "unstructured";
export const INFRA_NODE_KINDS = new Set([
"model_provider",
"model_config",
"tool_config",
]);
export type RecipeNodeData = {
title: string;

View file

@ -10,21 +10,21 @@ import type {
const SAMPLER_LABELS: Record<SamplerType, string> = {
category: "Category",
subcategory: "Subcategory",
uniform: "Uniform",
gaussian: "Gaussian",
bernoulli: "Bernoulli",
datetime: "Datetime",
timedelta: "Timedelta",
uuid: "UUID",
person: "Person",
person_from_faker: "Person (Faker)",
uniform: "Random number",
gaussian: "Bell-curve number",
bernoulli: "Yes/no value",
datetime: "Date and time",
timedelta: "Time offset",
uuid: "Unique ID",
person: "Synthetic person",
person_from_faker: "Synthetic person",
};
const LLM_LABELS: Record<LlmType, string> = {
text: "LLM Text",
structured: "LLM Structured",
code: "LLM Code",
judge: "LLM Judge",
text: "AI text",
structured: "AI structured data",
code: "AI code",
judge: "AI scorer",
};
const EXPRESSION_LABELS: Record<ExpressionDtype, string> = {
@ -35,13 +35,13 @@ const EXPRESSION_LABELS: Record<ExpressionDtype, string> = {
};
export function labelForSampler(type: SamplerType): string {
return SAMPLER_LABELS[type] ?? "Sampler";
return SAMPLER_LABELS[type] ?? "Generated field";
}
export function labelForLlm(type: LlmType): string {
return LLM_LABELS[type] ?? "LLM";
return LLM_LABELS[type] ?? "AI";
}
export function labelForExpression(type: ExpressionDtype): string {
return EXPRESSION_LABELS[type] ?? "Expression";
return EXPRESSION_LABELS[type] ?? "Formula";
}

View file

@ -0,0 +1,208 @@
// 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 type { Edge } from "@xyflow/react";
import { INFRA_NODE_KINDS, type NodeConfig } from "../types";
export type GraphWarning = {
nodeId?: string;
nodeName?: string;
global?: boolean;
message: string;
severity: "error" | "warning";
};
function checkDataSourceRequired(allConfigs: NodeConfig[]): GraphWarning[] {
const hasLlm = allConfigs.some((c) => c.kind === "llm");
const hasDataSource = allConfigs.some(
(c) => c.kind === "seed" || c.kind === "sampler" || c.kind === "expression",
);
if (hasLlm && !hasDataSource) {
return [
{
global: true,
message:
"Add a data source (seed, sampler, or expression) before LLM blocks can generate data.",
severity: "warning",
},
];
}
return [];
}
function checkLlmModelAlias(allConfigs: NodeConfig[]): GraphWarning[] {
const warnings: GraphWarning[] = [];
for (const config of allConfigs) {
if (config.kind === "llm" && !config.model_alias?.trim()) {
warnings.push({
nodeId: config.id,
nodeName: config.name,
message: "Needs a model preset.",
severity: "error",
});
}
}
return warnings;
}
function checkModelConfigProvider(allConfigs: NodeConfig[]): GraphWarning[] {
const warnings: GraphWarning[] = [];
for (const config of allConfigs) {
if (config.kind === "model_config" && !config.provider?.trim()) {
warnings.push({
nodeId: config.id,
nodeName: config.name,
message: "Needs a provider connection.",
severity: "error",
});
}
}
return warnings;
}
function checkSubcategoryParent(allConfigs: NodeConfig[]): GraphWarning[] {
const categoryNames = new Set(
allConfigs
.filter((c) => c.kind === "sampler" && c.sampler_type === "category")
.map((c) => c.name),
);
const warnings: GraphWarning[] = [];
for (const config of allConfigs) {
if (config.kind !== "sampler" || config.sampler_type !== "subcategory") {
continue;
}
if (!config.subcategory_parent?.trim()) {
warnings.push({
nodeId: config.id,
nodeName: config.name,
message: "Needs a parent category block.",
severity: "error",
});
} else if (!categoryNames.has(config.subcategory_parent)) {
warnings.push({
nodeId: config.id,
nodeName: config.name,
message: `Parent category "${config.subcategory_parent}" not found.`,
severity: "error",
});
}
}
return warnings;
}
function checkValidatorTargets(allConfigs: NodeConfig[]): GraphWarning[] {
const warnings: GraphWarning[] = [];
for (const config of allConfigs) {
if (
config.kind === "validator" &&
(!config.target_columns || config.target_columns.length === 0)
) {
warnings.push({
nodeId: config.id,
nodeName: config.name,
message: "Needs at least one target column.",
severity: "warning",
});
}
}
return warnings;
}
function checkDisconnectedNodes(
allConfigs: NodeConfig[],
edges: Edge[],
): GraphWarning[] {
const connectedIds = new Set<string>();
for (const edge of edges) {
connectedIds.add(edge.source);
connectedIds.add(edge.target);
}
const warnings: GraphWarning[] = [];
for (const config of allConfigs) {
if (config.kind === "markdown_note") {
continue;
}
if (connectedIds.has(config.id)) {
continue;
}
warnings.push({
nodeId: config.id,
nodeName: config.name,
message: "This block has no connections.",
severity: "warning",
});
}
return warnings;
}
function checkLlmMissingDataInput(
allConfigs: NodeConfig[],
edges: Edge[],
): GraphWarning[] {
const configById = new Map(allConfigs.map((c) => [c.id, c]));
/** LLM IDs that have at least one non-infra pipeline edge. */
const llmWithPipelineEdge = new Set<string>();
for (const edge of edges) {
const sourceConfig = configById.get(edge.source);
const targetConfig = configById.get(edge.target);
if (
sourceConfig?.kind === "llm" &&
targetConfig &&
!INFRA_NODE_KINDS.has(targetConfig.kind)
) {
llmWithPipelineEdge.add(sourceConfig.id);
}
if (
targetConfig?.kind === "llm" &&
sourceConfig &&
!INFRA_NODE_KINDS.has(sourceConfig.kind)
) {
llmWithPipelineEdge.add(targetConfig.id);
}
}
const warnings: GraphWarning[] = [];
for (const config of allConfigs) {
if (config.kind !== "llm") {
continue;
}
if (llmWithPipelineEdge.has(config.id)) {
continue;
}
const hasAnyEdge = edges.some(
(e) => e.source === config.id || e.target === config.id,
);
if (!hasAnyEdge) {
continue; // already caught by checkDisconnectedNodes
}
warnings.push({
nodeId: config.id,
nodeName: config.name,
message: "No data-pipeline connection — connect it to a source or downstream step.",
severity: "warning",
});
}
return warnings;
}
export function getGraphWarnings(
configs: Record<string, NodeConfig>,
edges: Edge[] = [],
): GraphWarning[] {
const allConfigs = Object.values(configs);
return [
...checkDataSourceRequired(allConfigs),
...checkLlmModelAlias(allConfigs),
...checkModelConfigProvider(allConfigs),
...checkSubcategoryParent(allConfigs),
...checkValidatorTargets(allConfigs),
...checkDisconnectedNodes(allConfigs, edges),
...checkLlmMissingDataInput(allConfigs, edges),
];
}

View file

@ -25,6 +25,7 @@ export {
isSubcategoryConfig,
isValidatorConfig,
} from "./config-type-guards";
export { getGraphWarnings, type GraphWarning } from "./graph-warnings";
export { nextName } from "./naming";
export { nodeDataFromConfig } from "./node-data";
export { getConfigErrors } from "./validation";

View file

@ -4,7 +4,7 @@
import dagre from "@dagrejs/dagre";
import type { Edge, Node } from "@xyflow/react";
import { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_WIDTH } from "../constants";
import type { LayoutDirection } from "../types";
import { INFRA_NODE_KINDS, type LayoutDirection, type NodeConfig } from "../types";
import { readNodeHeight, readNodeWidth } from "./rf-node-dimensions";
type LayoutOptions = {
@ -14,8 +14,106 @@ type LayoutOptions = {
edgesep?: number;
nodeWidth?: number;
nodeHeight?: number;
configs?: Record<string, NodeConfig>;
};
/**
* Pipeline rank order used to enforce a logical flow even for disconnected nodes.
* Lower rank = earlier in the pipeline.
*/
function getPipelineRank(config: NodeConfig | undefined): number {
if (!config) {
return 2;
}
switch (config.kind) {
case "seed":
return 0;
case "sampler":
return 1;
case "expression":
return 2;
case "llm":
return 3;
case "validator":
return 4;
default:
return 2;
}
}
function isInfraNode(
nodeId: string,
configs: Record<string, NodeConfig>,
): boolean {
const config = configs[nodeId];
return config ? INFRA_NODE_KINDS.has(config.kind) : false;
}
function isAuxNode(nodeId: string): boolean {
return nodeId.startsWith("aux-");
}
function getEdgeWeight(edgeType: string | undefined): number {
if (edgeType === "phantom") {
return 0;
}
if (edgeType === "semantic") {
return 10;
}
return 3;
}
/**
* Build phantom edges between disconnected data-pipeline nodes so dagre
* respects the pipeline rank order even when blocks aren't wired together.
*
* Groups nodes by rank, then inserts invisible edges from the last node of
* rank N to the first node of rank N+1 when no real edge already connects them.
*/
function buildPhantomEdges(
nodes: Node[],
edges: Edge[],
configs: Record<string, NodeConfig>,
): Edge[] {
// Group nodes by rank
const byRank = new Map<number, string[]>();
for (const node of nodes) {
const rank = getPipelineRank(configs[node.id]);
const list = byRank.get(rank) ?? [];
list.push(node.id);
byRank.set(rank, list);
}
const ranks = Array.from(byRank.keys()).sort((a, b) => a - b);
const phantoms: Edge[] = [];
for (let i = 0; i < ranks.length - 1; i++) {
const currentIds = byRank.get(ranks[i]) ?? [];
const nextIds = byRank.get(ranks[i + 1]) ?? [];
if (currentIds.length === 0 || nextIds.length === 0) {
continue;
}
// Check if any real edge already connects these rank groups
const hasRealEdge = edges.some(
(e) => currentIds.includes(e.source) && nextIds.includes(e.target),
);
if (hasRealEdge) {
continue;
}
// Insert one phantom edge from last node in current rank to first in next
phantoms.push({
id: `phantom-${ranks[i]}-${ranks[i + 1]}`,
source: currentIds[currentIds.length - 1],
target: nextIds[0],
type: "phantom",
});
}
return phantoms;
}
export function getLayoutedElements<TNode extends Node>(
nodes: TNode[],
edges: Edge[],
@ -28,8 +126,31 @@ export function getLayoutedElements<TNode extends Node>(
edgesep = 28,
nodeWidth = DEFAULT_NODE_WIDTH,
nodeHeight = DEFAULT_NODE_HEIGHT,
configs,
} = options;
// When configs are provided, filter out infra and aux nodes from dagre
const hasConfigs = configs && Object.keys(configs).length > 0;
const dataNodes = hasConfigs
? nodes.filter((n) => !(isInfraNode(n.id, configs) || isAuxNode(n.id)))
: nodes;
const dataEdges = hasConfigs
? edges.filter(
(e) =>
!(
isInfraNode(e.source, configs) ||
isInfraNode(e.target, configs) ||
isAuxNode(e.source) ||
isAuxNode(e.target)
),
)
: edges;
// Build phantom edges to enforce pipeline rank ordering for disconnected nodes
const phantomEdges = hasConfigs
? buildPhantomEdges(dataNodes, dataEdges, configs)
: [];
const graph = new dagre.graphlib.Graph();
graph.setDefaultEdgeLabel(() => ({}));
graph.setGraph({
@ -40,33 +161,41 @@ export function getLayoutedElements<TNode extends Node>(
ranker: "network-simplex",
});
nodes.forEach((node) => {
for (const node of dataNodes) {
const width = readNodeWidth(node) ?? nodeWidth;
const height = readNodeHeight(node) ?? nodeHeight;
graph.setNode(node.id, { width, height });
});
}
edges.forEach((edge) => {
const semantic = edge.type === "semantic";
const aux = edge.source.startsWith("aux-") || edge.target.startsWith("aux-");
graph.setEdge(edge.source, edge.target, {
minlen: semantic ? 1 : 1,
weight: semantic ? 10 : aux ? 1 : 3,
});
});
const allDagreEdges = [...dataEdges, ...phantomEdges];
for (const edge of allDagreEdges) {
const weight = getEdgeWeight(edge.type);
graph.setEdge(edge.source, edge.target, { minlen: 1, weight });
}
dagre.layout(graph);
const layoutedNodes = nodes.map((node) => {
// Build position map from dagre results (data nodes only)
const layoutedPositions = new Map<string, { x: number; y: number }>();
for (const node of dataNodes) {
const pos = graph.node(node.id);
const width = readNodeWidth(node) ?? nodeWidth;
const height = readNodeHeight(node) ?? nodeHeight;
layoutedPositions.set(node.id, {
x: pos.x - width / 2,
y: pos.y - height / 2,
});
}
// Apply positions: data nodes get dagre positions, infra/aux keep original
const layoutedNodes = nodes.map((node) => {
const position = layoutedPositions.get(node.id);
if (!position) {
return node;
}
return {
...node,
position: {
x: pos.x - width / 2,
y: pos.y - height / 2,
},
position,
};
});

View file

@ -14,7 +14,7 @@ export function nodeDataFromConfig(
): RecipeNodeData {
if (config.kind === "sampler") {
return {
title: "Sampler",
title: "Generated field",
kind: "sampler",
subtype: labelForSampler(config.sampler_type),
blockType: config.sampler_type,
@ -24,7 +24,7 @@ export function nodeDataFromConfig(
}
if (config.kind === "expression") {
return {
title: "Expression",
title: "Formula",
kind: "expression",
subtype: labelForExpression(config.dtype),
blockType: "expression",
@ -45,7 +45,7 @@ export function nodeDataFromConfig(
blockType = "validator_sql";
}
return {
title: "Validator",
title: "Check",
kind: "validator",
subtype,
blockType,
@ -69,10 +69,10 @@ export function nodeDataFromConfig(
seedSourceType === "hf"
? "Hugging Face dataset"
: seedSourceType === "local"
? "Structured file"
: "Unstructured document";
? "CSV or JSON file"
: "Document file";
return {
title: "Seed",
title: "Source data",
kind: "seed",
subtype: sourceLabel,
blockType: "seed",
@ -82,9 +82,9 @@ export function nodeDataFromConfig(
}
if (config.kind === "model_provider") {
return {
title: "Model Provider",
title: "Provider connection",
kind: "model_provider",
subtype: config.provider_type || "Provider",
subtype: config.provider_type || "Connection",
blockType: "model_provider",
name: config.name,
layoutDirection,
@ -92,7 +92,7 @@ export function nodeDataFromConfig(
}
if (config.kind === "model_config") {
return {
title: "Model Config",
title: "Model preset",
kind: "model_config",
subtype: config.model || "Model",
blockType: "model_config",
@ -103,16 +103,16 @@ export function nodeDataFromConfig(
if (config.kind === "tool_config") {
const providerCount = config.mcp_providers.length;
return {
title: "Tool Profile",
title: "Tool access",
kind: "tool_config",
subtype: providerCount === 1 ? "1 MCP server" : `${providerCount} MCP servers`,
subtype: providerCount === 1 ? "1 server" : `${providerCount} servers`,
blockType: "tool_config",
name: config.name,
layoutDirection,
};
}
return {
title: "LLM",
title: "AI step",
kind: "llm",
subtype: labelForLlm(config.llm_type),
blockType: config.llm_type,

View file

@ -0,0 +1,46 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export const RECIPE_STUDIO_NODE_TONES = {
sampler:
"bg-emerald-50 text-emerald-700 border-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-300 dark:border-emerald-900/60",
llm:
"bg-sky-50 text-sky-700 border-sky-100 dark:bg-sky-950/30 dark:text-sky-300 dark:border-sky-900/60",
validator:
"bg-rose-50 text-rose-700 border-rose-100 dark:bg-rose-950/30 dark:text-rose-300 dark:border-rose-900/60",
expression:
"bg-indigo-50 text-indigo-700 border-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-300 dark:border-indigo-900/60",
note:
"bg-violet-50 text-violet-700 border-violet-100 dark:bg-violet-950/30 dark:text-violet-300 dark:border-violet-900/60",
seed:
"bg-lime-50 text-lime-700 border-lime-100 dark:bg-lime-950/30 dark:text-lime-300 dark:border-lime-900/60",
model_provider:
"bg-amber-50 text-amber-700 border-amber-100 dark:bg-amber-950/30 dark:text-amber-300 dark:border-amber-900/60",
model_config:
"bg-orange-50 text-orange-700 border-orange-100 dark:bg-orange-950/30 dark:text-orange-300 dark:border-orange-900/60",
tool_config:
"bg-cyan-50 text-cyan-700 border-cyan-100 dark:bg-cyan-950/30 dark:text-cyan-300 dark:border-cyan-900/60",
} as const;
export const RECIPE_STUDIO_USER_NODE_TONE =
"bg-amber-50 text-amber-700 border-amber-100 dark:bg-amber-950/30 dark:text-amber-300 dark:border-amber-900/60";
export const RECIPE_STUDIO_REFERENCE_BADGE_TONES = {
user:
"corner-squircle border-amber-500/25 bg-amber-500/10 font-mono text-[11px] text-amber-700 dark:text-amber-300",
seed:
"corner-squircle border-blue-500/25 bg-blue-500/10 font-mono text-[11px] text-blue-700 dark:text-blue-300",
default: "corner-squircle font-mono text-[11px]",
} as const;
export const RECIPE_STUDIO_WARNING_BADGE_TONE =
"border-amber-500/40 bg-amber-500/10 text-amber-700 hover:bg-amber-500/20 dark:text-amber-300";
export const RECIPE_STUDIO_WARNING_ICON_TONE =
"text-amber-600 dark:text-amber-400";
export const RECIPE_STUDIO_ONBOARDING_SURFACE_TONE =
"border-primary/20 bg-primary/[0.045]";
export const RECIPE_STUDIO_ONBOARDING_ICON_TONE =
"bg-primary/10 text-primary";

View file

@ -136,7 +136,7 @@ export function getConfigErrors(config: NodeConfig | null): string[] {
}
if (config.kind === "llm") {
if (!config.model_alias.trim()) {
errors.push("Model alias is required.");
errors.push("Choose a saved model.");
}
if (!config.prompt.trim()) {
errors.push("Prompt is required.");
@ -158,23 +158,23 @@ export function getConfigErrors(config: NodeConfig | null): string[] {
if (config.llm_type === "judge") {
const scores = config.scores ?? [];
if (scores.length === 0) {
errors.push("LLM Judge needs at least one score.");
errors.push("Add at least one scoring rule.");
}
for (const score of scores) {
if (!score.name.trim()) {
errors.push("LLM Judge score name is required.");
errors.push("Each scoring rule needs a name.");
}
if (!score.description.trim()) {
errors.push("LLM Judge score description is required.");
errors.push("Each scoring rule needs a description.");
}
const options = score.options ?? [];
if (options.length === 0) {
errors.push(`LLM Judge score ${score.name || "Unnamed"} needs options.`);
errors.push(`Scoring rule ${score.name || "Untitled"} needs options.`);
}
for (const option of options) {
if (!option.value.trim() || !option.description.trim()) {
errors.push(
`LLM Judge score ${score.name || "Unnamed"} options need value + description.`,
`Scoring rule ${score.name || "Untitled"} needs both a value and a description for each option.`,
);
break;
}
@ -200,25 +200,25 @@ export function getConfigErrors(config: NodeConfig | null): string[] {
}
if (config.kind === "tool_config") {
if (config.mcp_providers.length === 0) {
errors.push("Add at least one MCP server.");
errors.push("Add at least one tool server.");
}
const serverNames = new Set<string>();
for (const provider of config.mcp_providers) {
const name = provider.name.trim();
if (!name) {
errors.push("Each MCP server needs a name.");
errors.push("Each tool server needs a name.");
continue;
}
if (serverNames.has(name)) {
errors.push(`Duplicate MCP server name: ${name}.`);
errors.push(`Tool server names must be unique: ${name}.`);
}
serverNames.add(name);
if (provider.provider_type === "stdio") {
if (!provider.command?.trim()) {
errors.push(`MCP server ${name}: command is required.`);
errors.push(`Tool server ${name}: add a command.`);
}
} else if (!provider.endpoint?.trim()) {
errors.push(`MCP server ${name}: endpoint is required.`);
errors.push(`Tool server ${name}: add an endpoint.`);
}
}
const maxTurnsRaw = config.max_tool_call_turns?.trim();
@ -226,7 +226,7 @@ export function getConfigErrors(config: NodeConfig | null): string[] {
maxTurnsRaw &&
(!Number.isFinite(Number(maxTurnsRaw)) || Number(maxTurnsRaw) < 1)
) {
errors.push("Max tool call turns must be >= 1.");
errors.push("Max tool-use turns must be 1 or more.");
}
const timeoutRaw = config.timeout_sec?.trim();
if (
@ -241,38 +241,38 @@ export function getConfigErrors(config: NodeConfig | null): string[] {
.map((value) => value.trim())
.filter(Boolean);
if (targets.length === 0) {
errors.push("Target code column is required.");
errors.push("Choose the code step to check.");
}
const batch = parseIntNumber(config.batch_size);
if (batch === null || batch < 1) {
errors.push("Batch size must be an integer >= 1.");
}
if (!config.code_lang.trim()) {
errors.push("Validator code language is required.");
errors.push("Choose a code language for this check.");
} else if (config.validator_type === "oxc") {
if (!VALIDATOR_OXC_CODE_LANGS.includes(config.code_lang)) {
errors.push("OXC validator code language must be javascript/typescript/jsx/tsx.");
errors.push("This JS/TS check only supports JavaScript or TypeScript.");
}
if (!isOxcValidationMode(config.oxc_validation_mode)) {
errors.push("OXC validation mode must be syntax, lint, or syntax+lint.");
errors.push("Choose whether to check syntax, lint rules, or both.");
}
if (!isOxcCodeShape(config.oxc_code_shape)) {
errors.push("OXC code shape must be auto, module, or snippet.");
errors.push("Choose whether this code is a full file or a snippet.");
}
} else if (
config.code_lang !== "python" &&
!VALIDATOR_SQL_CODE_LANGS.includes(config.code_lang)
) {
errors.push("Code validator code language must be python or sql dialect.");
errors.push("This check supports Python or SQL.");
}
}
if (config.kind === "seed") {
const seedSourceType = config.seed_source_type ?? "hf";
if (seedSourceType === "hf" && !config.hf_repo_id.trim()) {
errors.push("Seed dataset repo is required.");
errors.push("Choose a Hugging Face dataset.");
}
if (!config.hf_path.trim()) {
errors.push("Seed metadata not loaded. Click 'Load columns + 10 rows'.");
errors.push("Load the source-data preview first.");
}
if (
seedSourceType === "hf" &&
@ -283,7 +283,7 @@ export function getConfigErrors(config: NodeConfig | null): string[] {
}
if (seedSourceType === "unstructured") {
if (config.drop && (config.seed_columns?.length ?? 0) === 0) {
errors.push("Seed drop needs loaded columns.");
errors.push("Load the available fields before hiding any from the final dataset.");
}
const chunkSizeRaw = Number(config.unstructured_chunk_size);
const chunkOverlapRaw = Number(config.unstructured_chunk_overlap);
@ -305,7 +305,7 @@ export function getConfigErrors(config: NodeConfig | null): string[] {
.map((value) => value.trim())
.filter(Boolean);
if (selectedDropColumns.length > 0 && (config.seed_columns?.length ?? 0) === 0) {
errors.push("Seed drop columns need loaded columns.");
errors.push("Load the available fields before hiding any from the final dataset.");
}
}

View file

@ -8,7 +8,7 @@ export const MAX_RENDER_POINTS = 800;
export const DEFAULT_VISIBLE_POINTS = 160;
export const CHART_CONTAINER_CLASS = "h-[220px] w-full";
export const DEFAULT_CHART_MARGIN = { top: 4, right: 8, bottom: 0, left: 4 };
export const DEFAULT_Y_AXIS_WIDTH = 41;
export const DEFAULT_Y_AXIS_WIDTH = 45;
const TRAILING_ZEROES_RE = /\.?0+$/;
const NEGATIVE_ZERO_RE = /^-0$/;

View file

@ -46,6 +46,7 @@ import {
} from "@/features/training";
import { listLocalDatasets } from "@/features/training/api/datasets-api";
import type { LocalDatasetInfo } from "@/features/training/types/datasets";
import { useNavigate } from "@tanstack/react-router";
import {
ArrowDown01Icon,
CloudUploadIcon,
@ -59,8 +60,13 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useShallow } from "zustand/react/shallow";
import { DocumentUploadRedirectDialog } from "./document-upload-redirect-dialog";
const DOCUMENT_REDIRECT_EXTENSIONS = new Set([".pdf", ".docx", ".txt"]);
const SEARCH_INPUT_REASONS = new Set(["input-change", "input-paste", "input-clear"]);
const OPEN_LEARNING_RECIPES_ON_ARRIVAL_KEY =
"data-recipes:open-learning-recipes";
function isLikelyLocalDatasetRef(value: string) {
return (
@ -97,6 +103,7 @@ function normalizeSliceInput(value: string): string | null {
}
export function DatasetSection() {
const navigate = useNavigate();
const {
dataset,
datasetSource,
@ -341,6 +348,8 @@ export function DatasetSection() {
);
const [isUploading, setIsUploading] = useState(false);
const [documentRedirectOpen, setDocumentRedirectOpen] = useState(false);
const [redirectFileName, setRedirectFileName] = useState<string | null>(null);
const handleUploadButtonClick = () => {
fileInputRef.current?.click();
@ -351,6 +360,13 @@ export function DatasetSection() {
event.target.value = "";
if (!file) return;
const extension = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
if (DOCUMENT_REDIRECT_EXTENSIONS.has(extension)) {
setRedirectFileName(file.name);
setDocumentRedirectOpen(true);
return;
}
const MAX_SIZE_BYTES = 512 * 1024 * 1024;
if (file.size > MAX_SIZE_BYTES) {
toast.error("File too large", {
@ -377,6 +393,12 @@ export function DatasetSection() {
}
};
const handleOpenLearningRecipes = useCallback(() => {
sessionStorage.setItem(OPEN_LEARNING_RECIPES_ON_ARRIVAL_KEY, "1");
setDocumentRedirectOpen(false);
void navigate({ to: "/data-recipes" });
}, [navigate]);
return (
<div data-tour="studio-dataset" className="col-span-1 xl:col-span-4">
<SectionCard
@ -929,12 +951,18 @@ export function DatasetSection() {
<input
ref={fileInputRef}
type="file"
accept=".json,.jsonl,.csv,.parquet"
accept=".json,.jsonl,.csv,.parquet,.pdf,.docx,.txt"
className="hidden"
onChange={(event) => {
void handleDatasetFileChange(event);
}}
/>
<DocumentUploadRedirectDialog
open={documentRedirectOpen}
onOpenChange={setDocumentRedirectOpen}
fileName={redirectFileName}
onOpenLearningRecipes={handleOpenLearningRecipes}
/>
</div>
</SectionCard>
</div>

View file

@ -0,0 +1,93 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import {
ArrowRight01Icon,
DocumentAttachmentIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { ReactElement } from "react";
type DocumentUploadRedirectDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string | null;
onOpenLearningRecipes: () => void;
};
export function DocumentUploadRedirectDialog({
open,
onOpenChange,
fileName,
onOpenLearningRecipes,
}: DocumentUploadRedirectDialogProps): ReactElement {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-lg"
overlayClassName="bg-background/45 supports-backdrop-filter:backdrop-blur-[1px]"
>
<DialogHeader className="gap-3">
<div className="flex items-center gap-2">
<div className="flex size-10 items-center justify-center rounded-2xl border border-border/70 bg-muted/30">
<HugeiconsIcon
icon={DocumentAttachmentIcon}
className="size-5 text-foreground/90"
/>
</div>
<Badge variant="outline">Recipe Studio</Badge>
</div>
<div className="space-y-1">
<DialogTitle>This file needs conversion first</DialogTitle>
<DialogDescription>
{fileName ? (
<>
<span className="font-medium text-foreground">{fileName}</span>{" "}
is source material, not a ready-to-train dataset.
</>
) : (
"This file is source material, not a ready-to-train dataset."
)}{" "}
Use Data Recipes to turn documents into a dataset, then bring the
result back here for fine-tuning.
</DialogDescription>
</div>
</DialogHeader>
<div className="corner-squircle rounded-2xl border border-border/70 bg-muted/20 p-4">
<p className="text-sm font-medium text-foreground">
Best next step
</p>
<p className="mt-1 text-sm text-muted-foreground">
Open Learning Recipes and start from a document-based recipe like PDF
grounded QA.
</p>
</div>
<DialogFooter className="sm:justify-between">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="button" onClick={onOpenLearningRecipes}>
Open Learning Recipes
<HugeiconsIcon icon={ArrowRight01Icon} className="size-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -12,7 +12,7 @@ export const studioBaseModelStep: TourStep = {
Paste <span className="font-mono">org/model</span> or search. Pick a base
model close to your task (chat/instruct vs base). Smaller models iterate
faster; scale up once prompts + data look good.{" "}
<ReadMore href="https://docs.unsloth.ai/basics/fine-tuning-llms-guide" />
<ReadMore href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/what-model-should-i-use" />
</>
),
};

View file

@ -14,7 +14,7 @@ export const studioDatasetStep: TourStep = {
your dataset into a supported training format. If we cant infer it
cleanly, well prompt you to map the fields manually. If outputs look off
in Chat later, dataset formatting/template is the first thing to check.{" "}
<ReadMore href="https://docs.unsloth.ai/basics/fine-tuning-llms-guide" />
<ReadMore href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/datasets-guide" />
</>
),
};

View file

@ -12,7 +12,7 @@ export const studioLocalModelStep: TourStep = {
Use this if you already downloaded weights locally (eg{" "}
<span className="font-mono">./models/...</span>) to avoid re-downloading.
Folder should look like a Hugging Face model (config + tokenizer + weights).{" "}
<ReadMore href="https://docs.unsloth.ai/basics/fine-tuning-llms-guide" />
<ReadMore href="https://unsloth.ai/docs/basics/fine-tuning-llms-guide" />
</>
),
};

View file

@ -12,7 +12,7 @@ export const studioMethodStep: TourStep = {
LoRA: trains small adapter weights (fast, common default). QLoRA: LoRA on
4-bit base weights (much lower VRAM). Full: updates all weights (highest
cost, usually needs more data to be worth it).{" "}
<ReadMore href="https://docs.unsloth.ai/basics/lora-hyperparameters-guide" />
<ReadMore href="https://unsloth.ai/docs/basics/lora-hyperparameters-guide" />
</>
),
};

View file

@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import type { TourStep } from "@/features/tour";
import { ReadMore, type TourStep } from "@/features/tour";
export const studioNavStep: TourStep = {
id: "nav",
@ -11,7 +11,8 @@ export const studioNavStep: TourStep = {
<>
Studio: pick base model, dataset, hyperparams, then start training. After
you start, youll see a Training view with live loss/metrics. Chat is for
testing base vs LoRA adapters. Export packages checkpoints for deployment.
testing base vs LoRA adapters. Export packages checkpoints for deployment.{" "}
<ReadMore href="https://unsloth.ai/docs/get-started/fine-tuning-for-beginners" />
</>
),
};

View file

@ -12,7 +12,7 @@ export const studioParamsStep: TourStep = {
Start boring, then iterate. We usually recommend starting with 1-3 epochs
(higher can overfit fast). If youre unsure, change 1 knob at a time, and
watch train vs eval loss.{" "}
<ReadMore href="https://docs.unsloth.ai/basics/lora-hyperparameters-guide" />
<ReadMore href="https://unsloth.ai/docs/basics/lora-hyperparameters-guide" />
</>
),
};