From e280b0bebc0b9aacb12154500a99c9fa3e69e345 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Sun, 15 Mar 2026 11:42:11 +0100 Subject: [PATCH] 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> --- .../backend/core/data_recipe/huggingface.py | 124 ++++ .../backend/core/data_recipe/jobs/manager.py | 2 + studio/backend/core/data_recipe/jobs/types.py | 1 + studio/backend/core/data_recipe/service.py | 33 +- studio/backend/models/data_recipe.py | 27 + studio/backend/routes/data_recipe/jobs.py | 72 +- studio/frontend/bun.lock | 30 +- studio/frontend/package.json | 2 +- .../assistant-ui/message-timing.tsx | 88 +++ .../src/components/assistant-ui/thread.tsx | 2 + studio/frontend/src/components/navbar.tsx | 8 +- studio/frontend/src/components/ui/slider.tsx | 42 +- .../src/features/chat/api/chat-adapter.ts | 58 +- .../features/data-recipes/data/recipes-db.ts | 37 +- .../data-recipes/pages/data-recipes-page.tsx | 41 +- .../data-recipes/pages/edit-recipe-page.tsx | 19 +- .../src/features/recipe-studio/api/index.ts | 21 + .../recipe-studio/blocks/definitions.ts | 110 +-- .../recipe-studio/components/block-sheet.tsx | 301 ++++++-- .../run-validate-floating-controls.tsx | 2 +- .../executions/execution-overview-tab.tsx | 18 + .../executions/execution-sidebar.tsx | 4 +- .../components/executions/executions-view.tsx | 58 +- .../executions/publish-execution-dialog.tsx | 345 +++++++++ .../components/recipe-graph-node.tsx | 102 +-- .../components/recipe-studio-header.tsx | 109 ++- .../runtime/execution-progress-island.tsx | 51 +- .../recipe-studio/dialogs/config-dialog.tsx | 16 +- .../dialogs/expression/expression-dialog.tsx | 10 +- .../recipe-studio/dialogs/import-dialog.tsx | 6 +- .../recipe-studio/dialogs/llm/general-tab.tsx | 240 ++++--- .../dialogs/models/model-config-dialog.tsx | 171 +++-- .../dialogs/models/model-provider-dialog.tsx | 35 +- .../recipe-studio/dialogs/preview-dialog.tsx | 656 +++++++++--------- .../dialogs/samplers/category-dialog.tsx | 12 +- .../dialogs/seed/seed-dialog.tsx | 12 +- .../dialogs/shared/available-variables.tsx | 21 +- .../shared/collapsible-section-trigger.tsx | 56 ++ .../dialogs/shared/dialog-shell.tsx | 4 +- .../dialogs/shared/field-label.tsx | 25 +- .../dialogs/shared/name-field.tsx | 4 +- .../dialogs/shared/validation-banner.tsx | 2 +- .../tool-profile/tool-profile-dialog.tsx | 212 +++--- .../dialogs/validators/validator-dialog.tsx | 36 +- .../hooks/use-node-connection-status.ts | 52 ++ .../hooks/use-recipe-persistence.ts | 5 + .../hooks/use-recipe-studio-actions.ts | 2 + .../recipe-studio/recipe-studio-page.tsx | 353 ++++++---- .../recipe-studio/stores/recipe-studio.ts | 38 +- .../src/features/recipe-studio/types/index.ts | 5 + .../recipe-studio/utils/config-labels.ts | 30 +- .../recipe-studio/utils/graph-warnings.ts | 208 ++++++ .../src/features/recipe-studio/utils/index.ts | 1 + .../features/recipe-studio/utils/layout.ts | 161 ++++- .../features/recipe-studio/utils/node-data.ts | 24 +- .../features/recipe-studio/utils/ui-tones.ts | 46 ++ .../recipe-studio/utils/validation.ts | 44 +- .../features/studio/sections/charts/utils.ts | 2 +- .../studio/sections/dataset-section.tsx | 30 +- .../document-upload-redirect-dialog.tsx | 93 +++ .../features/studio/tour/steps/base-model.tsx | 2 +- .../features/studio/tour/steps/dataset.tsx | 2 +- .../studio/tour/steps/local-model.tsx | 2 +- .../src/features/studio/tour/steps/method.tsx | 2 +- .../src/features/studio/tour/steps/nav.tsx | 5 +- .../src/features/studio/tour/steps/params.tsx | 2 +- 66 files changed, 3229 insertions(+), 1105 deletions(-) create mode 100644 studio/backend/core/data_recipe/huggingface.py create mode 100644 studio/frontend/src/components/assistant-ui/message-timing.tsx create mode 100644 studio/frontend/src/features/recipe-studio/components/executions/publish-execution-dialog.tsx create mode 100644 studio/frontend/src/features/recipe-studio/dialogs/shared/collapsible-section-trigger.tsx create mode 100644 studio/frontend/src/features/recipe-studio/hooks/use-node-connection-status.ts create mode 100644 studio/frontend/src/features/recipe-studio/utils/graph-warnings.ts create mode 100644 studio/frontend/src/features/recipe-studio/utils/ui-tones.ts create mode 100644 studio/frontend/src/features/studio/sections/document-upload-redirect-dialog.tsx diff --git a/studio/backend/core/data_recipe/huggingface.py b/studio/backend/core/data_recipe/huggingface.py new file mode 100644 index 0000000000..16f6b15af5 --- /dev/null +++ b/studio/backend/core/data_recipe/huggingface.py @@ -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 = ( + 'Made with ❤️ using 🎨 ' + 'NeMo Data Designer' +) +_UNSLOTH_STUDIO_FOOTER = ( + 'Made with ❤️ using 🦥 ' "Unsloth Studio" +) + + +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 diff --git a/studio/backend/core/data_recipe/jobs/manager.py b/studio/backend/core/data_recipe/jobs/manager.py index 61c3516b2a..3d7cf2dbe6 100644 --- a/studio/backend/core/data_recipe/jobs/manager.py +++ b/studio/backend/core/data_recipe/jobs/manager.py @@ -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: diff --git a/studio/backend/core/data_recipe/jobs/types.py b/studio/backend/core/data_recipe/jobs/types.py index 3079d76bdb..8d77903238 100644 --- a/studio/backend/core/data_recipe/jobs/types.py +++ b/studio/backend/core/data_recipe/jobs/types.py @@ -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) diff --git a/studio/backend/core/data_recipe/service.py b/studio/backend/core/data_recipe/service.py index 1c86ac42d9..550358ae61 100644 --- a/studio/backend/core/data_recipe/service.py +++ b/studio/backend/core/data_recipe/service.py @@ -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), ) diff --git a/studio/backend/models/data_recipe.py b/studio/backend/models/data_recipe.py index 6992572b00..d49a50d1e3 100644 --- a/studio/backend/models/data_recipe.py +++ b/studio/backend/models/data_recipe.py @@ -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 diff --git a/studio/backend/routes/data_recipe/jobs.py b/studio/backend/routes/data_recipe/jobs.py index 4661615338..1d5eceee03 100644 --- a/studio/backend/routes/data_recipe/jobs.py +++ b/studio/backend/routes/data_recipe/jobs.py @@ -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() diff --git a/studio/frontend/bun.lock b/studio/frontend/bun.lock index e096f34cd7..6ac0c76470 100644 --- a/studio/frontend/bun.lock +++ b/studio/frontend/bun.lock @@ -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=="], diff --git a/studio/frontend/package.json b/studio/frontend/package.json index 9a607f1c1f..b3acb6468f 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -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", diff --git a/studio/frontend/src/components/assistant-ui/message-timing.tsx b/studio/frontend/src/components/assistant-ui/message-timing.tsx new file mode 100644 index 0000000000..567e8468d0 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/message-timing.tsx @@ -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"; + * + * + * + * + * // <-- add this + * + * ``` + * + * @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 ( + + + + + +
+ {timing.firstTokenTime !== undefined && ( +
+ First token + + {formatTimingMs(timing.firstTokenTime)} + +
+ )} +
+ Total + + {formatTimingMs(timing.totalStreamTime)} + +
+
+ Chunks + {timing.totalChunks} +
+
+
+
+ ); +}; diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 890267936b..c875c5ae84 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -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 = () => { +
{/* Left: logo */} - + Unsloth + + BETA + {/* Center: pill nav */} diff --git a/studio/frontend/src/components/ui/slider.tsx b/studio/frontend/src/components/ui/slider.tsx index eef1e024e1..573dea835b 100644 --- a/studio/frontend/src/components/ui/slider.tsx +++ b/studio/frontend/src/components/ui/slider.tsx @@ -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 ( + {isSingleThumbHorizontal && ( +
+ )} - {isSingleThumbHorizontal && ( -
- )} {Array.from({ length: values.length }, (_, index) => ( ))} diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 87f2cfc840..0935728e89 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -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(); +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((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; diff --git a/studio/frontend/src/features/data-recipes/data/recipes-db.ts b/studio/frontend/src/features/data-recipes/data/recipes-db.ts index 883898a43e..f89707e159 100644 --- a/studio/frontend/src/features/data-recipes/data/recipes-db.ts +++ b/studio/frontend/src/features/data-recipes/data/recipes-db.ts @@ -15,6 +15,8 @@ db.version(1).stores({ recipes: "id, name, updatedAt, createdAt", }); +const recentRecipeCache = new Map(); + export function listRecipes(): Promise { return db.recipes.orderBy("updatedAt").reverse().toArray(); } @@ -23,6 +25,18 @@ export function getRecipe(id: string): Promise { 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 { @@ -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 { await db.recipes.delete(id); + recentRecipeCache.delete(id); } export function createRecipeDraft(): Promise { @@ -67,16 +83,29 @@ export function createRecipeFromLearningRecipe(input: { }); } -export function useRecipes(): RecipeRecord[] { +export function useRecipes(): { + recipes: RecipeRecord[]; + ready: boolean; +} { const [recipes, setRecipes] = useState([]); + 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 }; } diff --git a/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx b/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx index ad576f4fbe..9148b9e0da 100644 --- a/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx +++ b/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx @@ -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( 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 { 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 {
- {recipes.length === 0 ? ( + {!ready ? ( +
+

+ Loading recipes +

+

+ Fetching your saved recipes and learning templates. +

+
+ ) : recipes.length === 0 ? ( @@ -448,7 +472,7 @@ export function DataRecipesPage(): ReactElement { @@ -412,37 +447,68 @@ export function BlockSheet({ setSearch(event.target.value)} - placeholder="Search blocks..." + placeholder="Search steps..." className="corner-squircle h-9 pl-8" + aria-label="Search steps" />
-
+
+ {isRootView && !hasSearch && ( +
+
+
+ +
+
+
+

+ Need a place to start? +

+

+ Open Source data first, then add generation and checks + on top of it. +

+
+ +
+
+
+ )} {isRootView && hasSearch && - rootSearchBlocks.map((item, index) => ( + rootSearchBlocks.map((item) => ( onBlockClick(item.kind, item.type)} /> ))} {isRootView && !hasSearch && - rootGroups.map((item, index) => ( + rootGroups.map((item) => ( { - 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)) && ( ) )} {isScopedBlockView && + sheetView === "seed" && + featuredSeedBlock && ( +
+
+

+ Recommended first step +

+

+ Best when you want to turn PDFs, DOCX files, or text + files into source rows. +

+
+ + onBlockClick( + featuredSeedBlock.kind, + featuredSeedBlock.type, + ) + } + /> +
+ )} + {isScopedBlockView && + sheetView === "seed" && + !hasSearch && + otherSeedBlocks.length > 0 && ( +
+

+ Other source options +

+

+ Use a dataset or structured file when your source is + already tabular. +

+
+ )} + {isScopedBlockView && + sheetView === "llm" && + llmCreateBlocks.length > 0 && ( +
+

+ Create +

+

+ Start with the kind of output you want to generate. +

+
+ )} + {isScopedBlockView && + sheetView === "llm" && + llmCreateBlocks.map((item) => ( + onBlockClick(item.kind, item.type)} + /> + ))} + {isScopedBlockView && + sheetView === "llm" && + llmSetupBlocks.length > 0 && ( +
+

+ Setup +

+

+ Add these only when you need a new model or tool setup. +

+
+ )} + {isScopedBlockView && + sheetView === "llm" && + llmSetupBlocks.map((item) => ( + onBlockClick(item.kind, item.type)} + /> + ))} + {isScopedBlockView && + sheetView === "seed" && + otherSeedBlocks.map((item) => ( + onBlockClick(item.kind, item.type)} + /> + ))} + {isScopedBlockView && + sheetView !== "llm" && + sheetView !== "seed" && scopedBlocks.map( - (item, index) => ( + (item) => ( onBlockClick(item.kind, item.type)} /> ), )} + {SHOW_PROCESSOR_IN_BLOCK_SHEET && isRootView && !hasSearch && ( +
+ +
+ )} {showNoMatches && (

- No blocks match. + No matching steps.

)}
@@ -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" > - {validateLoading ? "Validating..." : "Validate"} + {validateLoading ? "Checking..." : "Check"}
diff --git a/studio/frontend/src/features/recipe-studio/components/executions/execution-overview-tab.tsx b/studio/frontend/src/features/recipe-studio/components/executions/execution-overview-tab.tsx index d57596242a..06b4b20629 100644 --- a/studio/frontend/src/features/recipe-studio/components/executions/execution-overview-tab.tsx +++ b/studio/frontend/src/features/recipe-studio/components/executions/execution-overview-tab.tsx @@ -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; onTerminalScroll: (event: UIEvent) => void; + canPublish: boolean; + onOpenPublish: () => void; }; export function ExecutionOverviewTab({ @@ -54,11 +57,26 @@ export function ExecutionOverviewTab({ terminalLines, terminalRef, onTerminalScroll, + canPublish, + onOpenPublish, }: ExecutionOverviewTabProps): ReactElement { return (
{showSummaryCards && (
+ {canPublish && ( +
+
+

Next step

+

+ This run is complete. Publish the generated dataset to Hugging Face. +

+
+ +
+ )}
diff --git a/studio/frontend/src/features/recipe-studio/components/executions/execution-sidebar.tsx b/studio/frontend/src/features/recipe-studio/components/executions/execution-sidebar.tsx index f1c44c8c47..fc21cfc581 100644 --- a/studio/frontend/src/features/recipe-studio/components/executions/execution-sidebar.tsx +++ b/studio/frontend/src/features/recipe-studio/components/executions/execution-sidebar.tsx @@ -32,13 +32,13 @@ export function ExecutionSidebar({
)} + { + if (!selectedExecution?.jobId) { + throw new Error("This run is missing a job id."); + } + const response = await publishRecipeJob(selectedExecution.jobId, payload); + return { url: response.url }; + }} + />
); } diff --git a/studio/frontend/src/features/recipe-studio/components/executions/publish-execution-dialog.tsx b/studio/frontend/src/features/recipe-studio/components/executions/publish-execution-dialog.tsx new file mode 100644 index 0000000000..2619d8e6d4 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/components/executions/publish-execution-dialog.tsx @@ -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(null); + const [publishedUrl, setPublishedUrl] = useState(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 => { + 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 => { + 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 ( + { + if (publishing) { + return; + } + onOpenChange(nextOpen); + }} + > + { + if (publishing) { + event.preventDefault(); + } + }} + > + {publishedUrl ? ( + <> +
+
+ +
+
+ Published + + Your dataset is live on Hugging Face. + +
+
+
+

Dataset URL

+

{publishedUrl}

+
+ + + + + + + ) : ( + <> + + Publish to Hugging Face + + Create or update a dataset repo from this completed run. + + + +
+
+

From this run

+
+

+ Run: {runLabel} +

+

+ Records: {recordLabel} +

+
+

+ We’ll upload the generated dataset, dataset card, images, and any processor + outputs from this execution. +

+
+ +
+ + setRepoId(event.target.value)} + disabled={publishing} + /> +

+ Use the format username-or-org/dataset-name. +

+
+ +
+ +