+
{activeView === "editor" ? (
-
, 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"
- >
-
-
-
- {nodes.length === 0 && (
-
-
-
- )}
-
- setImportOpen(true)}
- />
-
-
- {islandExecution &&
- (isExecutionInProgress(islandExecution.status) ||
- islandExecution.status === "completed") && (
-
- setActiveView("executions")}
- />
-
- )}
- {
- openRunDialog(runDialogKind);
- void validateFromDialog();
- }}
- />
-
+ editorContent
) : (
;
configs: Record;
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): b
export const useRecipeStudioStore = create((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((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((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) => {
diff --git a/studio/frontend/src/features/recipe-studio/types/index.ts b/studio/frontend/src/features/recipe-studio/types/index.ts
index a80d1ce073..66643fc427 100644
--- a/studio/frontend/src/features/recipe-studio/types/index.ts
+++ b/studio/frontend/src/features/recipe-studio/types/index.ts
@@ -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;
diff --git a/studio/frontend/src/features/recipe-studio/utils/config-labels.ts b/studio/frontend/src/features/recipe-studio/utils/config-labels.ts
index 0dae4ea3a5..20fee08338 100644
--- a/studio/frontend/src/features/recipe-studio/utils/config-labels.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/config-labels.ts
@@ -10,21 +10,21 @@ import type {
const SAMPLER_LABELS: Record = {
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 = {
- 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 = {
@@ -35,13 +35,13 @@ const EXPRESSION_LABELS: Record = {
};
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";
}
diff --git a/studio/frontend/src/features/recipe-studio/utils/graph-warnings.ts b/studio/frontend/src/features/recipe-studio/utils/graph-warnings.ts
new file mode 100644
index 0000000000..010978ea2a
--- /dev/null
+++ b/studio/frontend/src/features/recipe-studio/utils/graph-warnings.ts
@@ -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();
+ 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();
+ 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,
+ 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),
+ ];
+}
diff --git a/studio/frontend/src/features/recipe-studio/utils/index.ts b/studio/frontend/src/features/recipe-studio/utils/index.ts
index 39cf2b7fc0..8bbcc02d04 100644
--- a/studio/frontend/src/features/recipe-studio/utils/index.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/index.ts
@@ -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";
diff --git a/studio/frontend/src/features/recipe-studio/utils/layout.ts b/studio/frontend/src/features/recipe-studio/utils/layout.ts
index 8da9477795..6b357c9028 100644
--- a/studio/frontend/src/features/recipe-studio/utils/layout.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/layout.ts
@@ -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;
};
+/**
+ * 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,
+): 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,
+): Edge[] {
+ // Group nodes by rank
+ const byRank = new Map();
+ 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(
nodes: TNode[],
edges: Edge[],
@@ -28,8 +126,31 @@ export function getLayoutedElements(
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(
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();
+ 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,
};
});
diff --git a/studio/frontend/src/features/recipe-studio/utils/node-data.ts b/studio/frontend/src/features/recipe-studio/utils/node-data.ts
index 92c75aa2c0..2fc5205db8 100644
--- a/studio/frontend/src/features/recipe-studio/utils/node-data.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/node-data.ts
@@ -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,
diff --git a/studio/frontend/src/features/recipe-studio/utils/ui-tones.ts b/studio/frontend/src/features/recipe-studio/utils/ui-tones.ts
new file mode 100644
index 0000000000..3d9ecff15a
--- /dev/null
+++ b/studio/frontend/src/features/recipe-studio/utils/ui-tones.ts
@@ -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";
diff --git a/studio/frontend/src/features/recipe-studio/utils/validation.ts b/studio/frontend/src/features/recipe-studio/utils/validation.ts
index 7089e3be20..1f1ebb3eb4 100644
--- a/studio/frontend/src/features/recipe-studio/utils/validation.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/validation.ts
@@ -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();
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.");
}
}
diff --git a/studio/frontend/src/features/studio/sections/charts/utils.ts b/studio/frontend/src/features/studio/sections/charts/utils.ts
index cd8b77f644..4a4a1f3b48 100644
--- a/studio/frontend/src/features/studio/sections/charts/utils.ts
+++ b/studio/frontend/src/features/studio/sections/charts/utils.ts
@@ -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$/;
diff --git a/studio/frontend/src/features/studio/sections/dataset-section.tsx b/studio/frontend/src/features/studio/sections/dataset-section.tsx
index e2ef83b1a0..a7e2c6555d 100644
--- a/studio/frontend/src/features/studio/sections/dataset-section.tsx
+++ b/studio/frontend/src/features/studio/sections/dataset-section.tsx
@@ -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(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 (
{
void handleDatasetFileChange(event);
}}
/>
+
diff --git a/studio/frontend/src/features/studio/sections/document-upload-redirect-dialog.tsx b/studio/frontend/src/features/studio/sections/document-upload-redirect-dialog.tsx
new file mode 100644
index 0000000000..1e78559041
--- /dev/null
+++ b/studio/frontend/src/features/studio/sections/document-upload-redirect-dialog.tsx
@@ -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 (
+
+ );
+}
diff --git a/studio/frontend/src/features/studio/tour/steps/base-model.tsx b/studio/frontend/src/features/studio/tour/steps/base-model.tsx
index 2802e6be5b..70f49c58b8 100644
--- a/studio/frontend/src/features/studio/tour/steps/base-model.tsx
+++ b/studio/frontend/src/features/studio/tour/steps/base-model.tsx
@@ -12,7 +12,7 @@ export const studioBaseModelStep: TourStep = {
Paste
org/model 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.{" "}
-
+
>
),
};
diff --git a/studio/frontend/src/features/studio/tour/steps/dataset.tsx b/studio/frontend/src/features/studio/tour/steps/dataset.tsx
index b83bb06f98..8783a33795 100644
--- a/studio/frontend/src/features/studio/tour/steps/dataset.tsx
+++ b/studio/frontend/src/features/studio/tour/steps/dataset.tsx
@@ -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.{" "}
-
+
>
),
};
diff --git a/studio/frontend/src/features/studio/tour/steps/local-model.tsx b/studio/frontend/src/features/studio/tour/steps/local-model.tsx
index 08648e6074..c762d1b798 100644
--- a/studio/frontend/src/features/studio/tour/steps/local-model.tsx
+++ b/studio/frontend/src/features/studio/tour/steps/local-model.tsx
@@ -12,7 +12,7 @@ export const studioLocalModelStep: TourStep = {
Use this if you already downloaded weights locally (eg{" "}
./models/...) to avoid re-downloading.
Folder should look like a Hugging Face model (config + tokenizer + weights).{" "}
-
+
>
),
};
diff --git a/studio/frontend/src/features/studio/tour/steps/method.tsx b/studio/frontend/src/features/studio/tour/steps/method.tsx
index 079b8897ba..a3e663096b 100644
--- a/studio/frontend/src/features/studio/tour/steps/method.tsx
+++ b/studio/frontend/src/features/studio/tour/steps/method.tsx
@@ -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).{" "}
-
+
>
),
};
diff --git a/studio/frontend/src/features/studio/tour/steps/nav.tsx b/studio/frontend/src/features/studio/tour/steps/nav.tsx
index 10ff49a2bc..41c107888c 100644
--- a/studio/frontend/src/features/studio/tour/steps/nav.tsx
+++ b/studio/frontend/src/features/studio/tour/steps/nav.tsx
@@ -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.{" "}
+
>
),
};
diff --git a/studio/frontend/src/features/studio/tour/steps/params.tsx b/studio/frontend/src/features/studio/tour/steps/params.tsx
index e25d8c2339..73bf86d84b 100644
--- a/studio/frontend/src/features/studio/tour/steps/params.tsx
+++ b/studio/frontend/src/features/studio/tour/steps/params.tsx
@@ -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.{" "}
-
+
>
),
};