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:
parent
f44857b2df
commit
e280b0bebc
66 changed files with 3229 additions and 1105 deletions
124
studio/backend/core/data_recipe/huggingface.py
Normal file
124
studio/backend/core/data_recipe/huggingface.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
We’ll 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
46
studio/frontend/src/features/recipe-studio/utils/ui-tones.ts
Normal file
46
studio/frontend/src/features/recipe-studio/utils/ui-tones.ts
Normal 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";
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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$/;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const studioDatasetStep: TourStep = {
|
|||
your dataset into a supported training format. If we can’t infer it
|
||||
cleanly, we’ll 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" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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, you’ll 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" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 you’re 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" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue