diff --git a/web/packages/studio/src/components/AddModelPalette/AddModelPalette.stories.tsx b/web/packages/studio/src/components/AddModelPalette/AddModelPalette.stories.tsx
new file mode 100644
index 0000000000..5bcc287424
--- /dev/null
+++ b/web/packages/studio/src/components/AddModelPalette/AddModelPalette.stories.tsx
@@ -0,0 +1,55 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { Meta, StoryObj } from '@storybook/react';
+import { AddModelPalette } from '@studio/components/AddModelPalette';
+import type { BuilderModel } from '@studio/routes/DataDesignerJobBuildRoute/models';
+
+const meta = {
+ component: AddModelPalette,
+ title: 'Components/AddModelPalette',
+ parameters: {
+ layout: 'fullscreen',
+ },
+ args: {
+ modelGroups: [],
+ onAddModel: () => {},
+ onSelectModel: () => {},
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const models: BuilderModel[] = [
+ {
+ id: 'model-0',
+ alias: 'default',
+ model: 'openai/gpt-4o-mini',
+ provider: 'openai',
+ inferenceParams: { temperature: 0.7 },
+ },
+ {
+ id: 'model-1',
+ alias: 'judge',
+ model: 'meta/llama-3.1-70b-instruct',
+ provider: 'nvidia',
+ inferenceParams: { temperature: 0, max_tokens: 1024 },
+ },
+];
+
+/** A few configured models, the second selected for editing. */
+export const WithModels: Story = {
+ args: { models, selectedId: 'model-1' },
+};
+
+export const Empty: Story = {
+ args: { models: [], selectedId: null },
+};
diff --git a/web/packages/studio/src/components/AddModelPalette/index.tsx b/web/packages/studio/src/components/AddModelPalette/index.tsx
new file mode 100644
index 0000000000..6cd5fbbb4c
--- /dev/null
+++ b/web/packages/studio/src/components/AddModelPalette/index.tsx
@@ -0,0 +1,81 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { ModelWorkspaceGroup } from '@nemo/common/src/api/models/useModels';
+import { ModelSelectV2 } from '@nemo/common/src/components/ModelSelectV2/ModelSelectV2';
+import type { ModelSelection } from '@nemo/common/src/components/ModelSelectV2/types';
+import { Stack, Text } from '@nvidia/foundations-react-core';
+import { CardIconBadge, SelectableCard } from '@studio/components/common/SelectableCard';
+import {
+ type BuilderModel,
+ providerForModel,
+} from '@studio/routes/DataDesignerJobBuildRoute/models';
+import { Cpu } from 'lucide-react';
+import type { FC } from 'react';
+
+export interface AddModelPaletteProps {
+ models: BuilderModel[];
+ selectedId?: string | null;
+ modelGroups: ModelWorkspaceGroup[];
+ isLoadingModels?: boolean;
+ onAddModel: (selection: ModelSelection, provider: string) => void;
+ onSelectModel: (id: string) => void;
+ className?: string;
+}
+export const AddModelPalette: FC = ({
+ models,
+ selectedId,
+ modelGroups,
+ isLoadingModels,
+ onAddModel,
+ onSelectModel,
+ className,
+}) => (
+
+
+ Models
+
+ Referenced by LLM columns via their model alias
+
+
+
+
+
+ onAddModel(selection, providerForModel(modelGroups, selection.model))
+ }
+ groups={modelGroups}
+ loading={isLoadingModels}
+ placeholder="Add a model"
+ fullWidth
+ dropdownSide="bottom"
+ aria-label="Add a model"
+ />
+
+
+
+ {models.length === 0 ? (
+
+ No models yet. Add one to reference it from an LLM column.
+
+ ) : (
+ models.map((model) => (
+ onSelectModel(model.id)}
+ className="w-full"
+ leading={
+
+
+
+ }
+ />
+ ))
+ )}
+
+
+);
diff --git a/web/packages/studio/src/components/ColumnConfigPanel/ColumnConfigPanel.tsx b/web/packages/studio/src/components/ColumnConfigPanel/ColumnConfigPanel.tsx
index 51360b2cb5..39514c1f6f 100644
--- a/web/packages/studio/src/components/ColumnConfigPanel/ColumnConfigPanel.tsx
+++ b/web/packages/studio/src/components/ColumnConfigPanel/ColumnConfigPanel.tsx
@@ -38,7 +38,7 @@ export const ColumnConfigPanel: FC = ({
}) => {
const { option, name, values } = column;
const { icon: Icon, label, description, color } = option;
- const fields = getColumnFields(option.columnType);
+ const fields = getColumnFields(option);
const nameError = validateColumnName(name, takenNames);
const setValue = (key: string, value: string) =>
diff --git a/web/packages/studio/src/components/CreateFilesetStart/templates.ts b/web/packages/studio/src/components/CreateFilesetStart/templates.ts
index 55c4e772c7..7633416f80 100644
--- a/web/packages/studio/src/components/CreateFilesetStart/templates.ts
+++ b/web/packages/studio/src/components/CreateFilesetStart/templates.ts
@@ -3,6 +3,7 @@
import { SamplerType } from '@nemo/sdk/generated/data-designer/schema';
import type { FilesetTemplate } from '@studio/components/CreateFilesetStart/types';
+import { DEFAULT_BUILD_MODEL_NAME } from '@studio/constants/constants';
import { GraduationCap } from 'lucide-react';
/**
@@ -23,6 +24,10 @@ export const FILESET_TEMPLATES: FilesetTemplate[] = [
columnType: 'sampler',
samplerType: SamplerType.category,
name: 'domain',
+ values: {
+ values:
+ 'science, technology, history, arts, business, health, education, sports, travel, cooking',
+ },
},
{
columnType: 'llm-text',
@@ -43,6 +48,7 @@ export const FILESET_TEMPLATES: FilesetTemplate[] = [
},
},
],
+ models: [{ alias: 'default', model: DEFAULT_BUILD_MODEL_NAME }],
},
];
diff --git a/web/packages/studio/src/components/CreateFilesetStart/types.ts b/web/packages/studio/src/components/CreateFilesetStart/types.ts
index f0a754a0cd..5eca3139d2 100644
--- a/web/packages/studio/src/components/CreateFilesetStart/types.ts
+++ b/web/packages/studio/src/components/CreateFilesetStart/types.ts
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
+import type { InferenceParams } from '@nemo/sdk/generated/platform/schema';
import type { BadgeProps } from '@nvidia/foundations-react-core';
import type { AddColumnSelection } from '@studio/components/AddColumnPalette/types';
import type { LucideIcon } from 'lucide-react';
@@ -34,6 +35,22 @@ export interface TemplateColumnSpec extends AddColumnSelection {
values?: Record;
}
+/** Picking one preloads the build canvas with its columns and any models they reference. */
+
+export interface TemplateModelSpec {
+ /** Alias the template's columns reference via `model_alias`. */
+ alias: string;
+ /** Preferred model URN (e.g. `nvidia/llama-3.3-nemotron-super-49b-v1.5`); optional. */
+ model?: string;
+ /** Optional inference parameter defaults. */
+ inferenceParams?: Partial;
+}
+
+/**
+ * A ready-made recipe shown as a card in the secondary area when the "Start from a
+ * template" option is selected. Picking one preloads the build canvas with its columns
+ * and any models they reference.
+ */
export interface FilesetTemplate {
/** Stable id passed to {@link CreateFilesetStartProps.onContinue} when chosen. */
id: string;
@@ -42,6 +59,8 @@ export interface FilesetTemplate {
icon: LucideIcon;
tag: StartOptionTag;
columns: TemplateColumnSpec[];
+ /** Models preloaded into the job config, referenced by the columns' `model_alias`. */
+ models?: TemplateModelSpec[];
}
export interface TemplateCardProps {
diff --git a/web/packages/studio/src/components/ModelConfigPanel/index.tsx b/web/packages/studio/src/components/ModelConfigPanel/index.tsx
new file mode 100644
index 0000000000..4506f9d5eb
--- /dev/null
+++ b/web/packages/studio/src/components/ModelConfigPanel/index.tsx
@@ -0,0 +1,123 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { ModelWorkspaceGroup } from '@nemo/common/src/api/models/useModels';
+import { ModelSelectV2 } from '@nemo/common/src/components/ModelSelectV2/ModelSelectV2';
+import type { ModelSelection } from '@nemo/common/src/components/ModelSelectV2/types';
+import type { InferenceParams } from '@nemo/sdk/generated/platform/schema';
+import { Button, Flex, FormField, Stack, Text, TextInput } from '@nvidia/foundations-react-core';
+import { CardIconBadge } from '@studio/components/common/SelectableCard';
+import {
+ type BuilderModel,
+ type BuilderModelPatch,
+ providerForModel,
+ validateModelAlias,
+} from '@studio/routes/DataDesignerJobBuildRoute/models';
+import { Cpu, Trash2, X } from 'lucide-react';
+import type { FC } from 'react';
+
+export interface ModelConfigPanelProps {
+ model: BuilderModel;
+ takenAliases: Set;
+ modelGroups: ModelWorkspaceGroup[];
+ isLoadingModels?: boolean;
+ onChange: (patch: BuilderModelPatch) => void;
+ onRemove: () => void;
+ onClose: () => void;
+}
+
+/** Right-hand config panel for a model — sibling of ColumnConfigPanel, same inline layout. */
+export const ModelConfigPanel: FC = ({
+ model,
+ takenAliases,
+ modelGroups,
+ isLoadingModels,
+ onChange,
+ onRemove,
+ onClose,
+}) => {
+ const aliasError = validateModelAlias(model.alias, takenAliases);
+ const modelValue: ModelSelection | null = model.model ? { model: model.model } : null;
+
+ const handleModelChange = (selection: ModelSelection) =>
+ onChange({ model: selection.model, provider: providerForModel(modelGroups, selection.model) });
+ const handleParamsChange = (params: Partial) =>
+ onChange({ inferenceParams: params });
+
+ return (
+
+ );
+};
diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderConfigPane.tsx b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderConfigPane.tsx
new file mode 100644
index 0000000000..6c2a101797
--- /dev/null
+++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderConfigPane.tsx
@@ -0,0 +1,71 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { ModelWorkspaceGroup } from '@nemo/common/src/api/models/useModels';
+import { Flex, Text } from '@nvidia/foundations-react-core';
+import { ColumnConfigPanel } from '@studio/components/ColumnConfigPanel';
+import { ModelConfigPanel } from '@studio/components/ModelConfigPanel';
+import type { BuilderColumn } from '@studio/routes/DataDesignerJobBuildRoute/columns';
+import type {
+ BuilderModel,
+ BuilderModelPatch,
+} from '@studio/routes/DataDesignerJobBuildRoute/models';
+import type { FC } from 'react';
+
+export interface BuilderConfigPaneProps {
+ selectedColumn: BuilderColumn | null;
+ selectedModel: BuilderModel | null;
+ takenNames: Set;
+ takenAliases: Set;
+ modelGroups: ModelWorkspaceGroup[];
+ isLoadingModels: boolean;
+ onColumnChange: (patch: { name?: string; values?: Record }) => void;
+ onColumnRemove: () => void;
+ onColumnClose: () => void;
+ onModelChange: (patch: BuilderModelPatch) => void;
+ onModelRemove: () => void;
+ onModelClose: () => void;
+}
+
+export const BuilderConfigPane: FC = ({
+ selectedColumn,
+ selectedModel,
+ takenNames,
+ takenAliases,
+ modelGroups,
+ isLoadingModels,
+ onColumnChange,
+ onColumnRemove,
+ onColumnClose,
+ onModelChange,
+ onModelRemove,
+ onModelClose,
+}) => (
+
+ {selectedColumn ? (
+
+ ) : selectedModel ? (
+
+ ) : (
+
+
+ Select a column or model to configure it, or add one from the left.
+
+
+ )}
+
+);
diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderDetailsPanel.tsx b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderDetailsPanel.tsx
new file mode 100644
index 0000000000..0f0f566758
--- /dev/null
+++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderDetailsPanel.tsx
@@ -0,0 +1,78 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { Banner, Button, CodeSnippet, Flex, Stack } from '@nvidia/foundations-react-core';
+import { formatPreviewLogsForDisplay } from '@studio/components/NewDataDesignerJobForm/previewApi';
+import { ChevronDown, ChevronRight } from 'lucide-react';
+import type { FC } from 'react';
+
+export interface BuilderDetailsPanelProps {
+ validationErrors: string[];
+ submitError: string | null;
+ previewLogs: string;
+ isOpen: boolean;
+ onToggle: () => void;
+}
+export const BuilderDetailsPanel: FC = ({
+ validationErrors,
+ submitError,
+ previewLogs,
+ isOpen,
+ onToggle,
+}) => {
+ const hasDetails = validationErrors.length > 0 || !!submitError || !!previewLogs;
+ if (!hasDetails) return null;
+
+ const summary = [
+ validationErrors.length > 0 && `${validationErrors.length} validation issue(s)`,
+ submitError && 'Job creation error',
+ previewLogs && 'Preview logs',
+ ]
+ .filter(Boolean)
+ .join(' · ');
+
+ return (
+
+
+
+ {isOpen && (
+
+ {validationErrors.length > 0 && (
+
+ Please fix the following before continuing:
+
+ {validationErrors.map((error) => (
+ - {error}
+ ))}
+
+
+ )}
+ {submitError && (
+
+ There was an error creating this job: {submitError}
+
+ )}
+ {previewLogs && (
+
+ )}
+
+ )}
+
+ );
+};
diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderPalette.tsx b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderPalette.tsx
new file mode 100644
index 0000000000..1f5a9a6cbe
--- /dev/null
+++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderPalette.tsx
@@ -0,0 +1,64 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { ModelWorkspaceGroup } from '@nemo/common/src/api/models/useModels';
+import type { ModelSelection } from '@nemo/common/src/components/ModelSelectV2/types';
+import { SegmentedControl } from '@nvidia/foundations-react-core';
+import { AddColumnPalette } from '@studio/components/AddColumnPalette';
+import type { AddColumnSelection } from '@studio/components/AddColumnPalette/types';
+import { AddModelPalette } from '@studio/components/AddModelPalette';
+import type { BuilderModel } from '@studio/routes/DataDesignerJobBuildRoute/models';
+import type { PaletteTab } from '@studio/routes/DataDesignerJobBuildRoute/useJobBuilder';
+import type { FC } from 'react';
+
+export interface BuilderPaletteProps {
+ tab: PaletteTab;
+ onTabChange: (tab: PaletteTab) => void;
+ models: BuilderModel[];
+ selectedModelId: string | null;
+ modelGroups: ModelWorkspaceGroup[];
+ isLoadingModels?: boolean;
+ onAddColumn: (selection: AddColumnSelection) => void;
+ onAddModel: (selection: ModelSelection, provider: string) => void;
+ onSelectModel: (id: string | null) => void;
+}
+
+// Tabs only swap what you're adding — column and model configs both open in the right pane.
+export const BuilderPalette: FC = ({
+ tab,
+ onTabChange,
+ models,
+ selectedModelId,
+ modelGroups,
+ isLoadingModels,
+ onAddColumn,
+ onAddModel,
+ onSelectModel,
+}) => (
+
+);
diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderToolbar.tsx b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderToolbar.tsx
new file mode 100644
index 0000000000..b6e17695e0
--- /dev/null
+++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderToolbar.tsx
@@ -0,0 +1,112 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { LoadingButton } from '@nemo/common/src/components/LoadingButton';
+import { Button, Flex, Tag, Text, TextInput } from '@nvidia/foundations-react-core';
+import type { StartOptionTag } from '@studio/components/CreateFilesetStart/types';
+import { FileJson, Pencil } from 'lucide-react';
+import { type FC, useState } from 'react';
+
+export interface BuilderToolbarProps {
+ /** The fileset name; shown read-only until the pencil icon is clicked. */
+ name: string;
+ onNameChange: (name: string) => void;
+ /** Number of columns currently on the canvas. */
+ columnCount: number;
+ /** The template's badge (recipe use case), shown when building from a template. */
+ templateTag?: StartOptionTag;
+ /** Full-run record count, as a raw digit string (no thousands separators). */
+ rows: string;
+ onRowsChange: (rows: string) => void;
+ onPreview: () => void;
+ isPreviewing: boolean;
+ onSubmit: () => void;
+ isSubmitting: boolean;
+}
+
+export const BuilderToolbar: FC = ({
+ name,
+ onNameChange,
+ columnCount,
+ templateTag,
+ rows,
+ onRowsChange,
+ onPreview,
+ isPreviewing,
+ onSubmit,
+ isSubmitting,
+}) => {
+ const [isEditingName, setIsEditingName] = useState(false);
+ const previewRows = Number(rows) > 0 ? Math.min(Number(rows), 10) : 10;
+
+ return (
+
+
+
+ {isEditingName ? (
+ setIsEditingName(false)}
+ attributes={{ Input: { 'aria-label': 'Fileset name', className: 'w-[220px]' } }}
+ />
+ ) : (
+
+ {name}
+
+ )}
+
+ {templateTag && (
+
+ {templateTag.label}
+
+ )}
+
+ ·
+
+
+ {columnCount} {columnCount === 1 ? 'column' : 'columns'}
+
+
+
+
+
+
+ Rows
+
+
+
+ {`Preview ${previewRows} rows`}
+
+ Create fileset
+
+
+
+ );
+};
diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.test.ts b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.test.ts
index d1feaadc3b..0653fe8432 100644
--- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.test.ts
+++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.test.ts
@@ -6,11 +6,13 @@ import type { ColumnTypeOption } from '@studio/components/AddColumnPalette/types
import {
type BuilderColumn,
buildColumnsFromTemplate,
+ buildDataDesignerConfig,
buildGraph,
defaultColumnName,
extractJinjaReferences,
findColumnOption,
validateColumnName,
+ validateColumns,
} from '@studio/routes/DataDesignerJobBuildRoute/columns';
const optionFor = (columnType: string, samplerType?: string): ColumnTypeOption => {
@@ -23,8 +25,9 @@ const column = (
id: string,
name: string,
columnType: string,
- values: Record
-): BuilderColumn => ({ id, name, option: optionFor(columnType), values });
+ values: Record,
+ samplerType?: string
+): BuilderColumn => ({ id, name, option: optionFor(columnType, samplerType), values });
describe('extractJinjaReferences', () => {
it('pulls identifiers out of {{ }} tokens, including filters and whitespace variants', () => {
@@ -149,6 +152,85 @@ describe('buildGraph', () => {
});
});
+describe('sampler columns', () => {
+ it('nests category values under the required params object', () => {
+ const columns = [
+ column('a', 'domain', 'sampler', { values: 'science, history, , arts' }, 'category'),
+ ];
+
+ expect(buildDataDesignerConfig(columns).columns[0]).toEqual({
+ name: 'domain',
+ column_type: 'sampler',
+ sampler_type: 'category',
+ params: { values: ['science', 'history', 'arts'] },
+ });
+ });
+
+ it('keeps convert_to at the top level alongside params', () => {
+ const columns = [
+ column('a', 'domain', 'sampler', { values: 'a, b', convert_to: 'str' }, 'category'),
+ ];
+
+ expect(buildDataDesignerConfig(columns).columns[0]).toMatchObject({
+ params: { values: ['a', 'b'] },
+ convert_to: 'str',
+ });
+ });
+
+ it('requires category values', () => {
+ const columns = [column('a', 'domain', 'sampler', {}, 'category')];
+
+ expect(validateColumns(columns)).toContainEqual(expect.stringContaining('Categories'));
+ });
+
+ it('serializes binomial params as numbers', () => {
+ const columns = [column('a', 'returns', 'sampler', { n: '10', p: '0.1' }, 'binomial')];
+
+ expect(buildDataDesignerConfig(columns).columns[0]).toEqual({
+ name: 'returns',
+ column_type: 'sampler',
+ sampler_type: 'binomial',
+ params: { n: 10, p: 0.1 },
+ });
+ });
+
+ it('serializes boolean and JSON params for their SDK types', () => {
+ const columns = [
+ column(
+ 'a',
+ 'dist',
+ 'sampler',
+ { dist_name: 'norm', dist_params: '{ "loc": 0, "scale": 1 }', p: '0.5' },
+ 'bernoulli_mixture'
+ ),
+ column('b', 'ids', 'sampler', { short_form: 'true' }, 'uuid'),
+ ];
+
+ const built = buildDataDesignerConfig(columns);
+ expect(built.columns[0]).toMatchObject({
+ params: { p: 0.5, dist_name: 'norm', dist_params: { loc: 0, scale: 1 } },
+ });
+ expect(built.columns[1]).toMatchObject({ params: { short_form: true } });
+ });
+
+ it('flags non-numeric and malformed-JSON sampler params', () => {
+ const columns = [
+ column('a', 'returns', 'sampler', { n: 'not-a-number', p: '0.1' }, 'binomial'),
+ column(
+ 'b',
+ 'dist',
+ 'sampler',
+ { dist_name: 'norm', dist_params: '{ bad', p: '0.5' },
+ 'bernoulli_mixture'
+ ),
+ ];
+
+ const errors = validateColumns(columns);
+ expect(errors).toContainEqual(expect.stringContaining('Number of trials'));
+ expect(errors).toContainEqual(expect.stringContaining('Distribution params'));
+ });
+});
+
describe('palette catalog', () => {
it('has a field descriptor path for every catalog column type', () => {
// Sanity: findColumnOption resolves every option the palette can emit.
diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.ts b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.ts
index edf45a65fb..144b14133b 100644
--- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.ts
+++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.ts
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
+import { type DataDesignerConfig, SamplerType } from '@nemo/sdk/generated/data-designer/schema';
import { COLUMN_TYPE_GROUPS } from '@studio/components/AddColumnPalette/constants';
import type {
AddColumnSelection,
@@ -10,6 +11,10 @@ import type {
} from '@studio/components/AddColumnPalette/types';
import type { TemplateColumnSpec } from '@studio/components/CreateFilesetStart/types';
import type { DagEdge, DagNode } from '@studio/components/DagCanvas/types';
+import {
+ type BuilderModel,
+ buildModelConfigs,
+} from '@studio/routes/DataDesignerJobBuildRoute/models';
export type FieldReference =
/** Value is a Jinja2 template; `{{ column_name }}` tokens are dependencies. */
@@ -32,20 +37,25 @@ export interface ColumnField {
options?: readonly { label: string; value: string }[];
/** If set, values in this field create dependency edges to other columns. */
reference?: FieldReference;
+ /**
+ * Serialize the value as a comma-separated list (e.g. sampler category values). Unlike
+ * `reference: 'list'`, this does not treat the entries as column names / dependencies.
+ */
+ list?: boolean;
+ /**
+ * How to coerce the string form value when serializing to the SDK config. Defaults to a
+ * plain string; `number`/`boolean` parse the value, `json` parses an object/array literal.
+ * Used by sampler params whose SDK types are non-string (see PARAM_FIELDS_BY_SAMPLER_TYPE).
+ */
+ valueType?: 'number' | 'boolean' | 'json';
}
-/**
- * A column the user has added to the canvas: the picked catalog option plus the values
- * entered in the config modal. `name` is the column's identifier — other columns
- * reference it via `{{ name }}`. Not yet the SDK column config.
- */
+/** Not yet the SDK column config — that's produced by {@link buildDataDesignerConfig}. */
export interface BuilderColumn {
/** Canvas-unique id (also the DAG node id). */
id: string;
option: ColumnTypeOption;
- /** The column name (Jinja2 identifier other columns can reference). */
name: string;
- /** Field values keyed by {@link ColumnField.key}. */
values: Record;
}
@@ -101,10 +111,6 @@ const SYSTEM_PROMPT_FIELD: ColumnField = {
helperText: 'Optional. Also supports {{ column_name }} references.',
};
-/**
- * The user-editable fields for a column type, in display order. `name` is handled
- * separately by the modal (every column has one), so it is not included here.
- */
const FIELDS_BY_COLUMN_TYPE: Record, ColumnField[]> = {
'llm-text': [PROMPT_FIELD, MODEL_ALIAS_FIELD, SYSTEM_PROMPT_FIELD],
'llm-code': [
@@ -194,6 +200,8 @@ const FIELDS_BY_COLUMN_TYPE: Record, ColumnF
options: asOptions(['cell_by_cell', 'full_column']),
},
],
+ // Shared across all sampler sub-types, emitted at the top level of the sampler config.
+ // Sub-type-specific fields (collected into `params`) come from PARAM_FIELDS_BY_SAMPLER_TYPE.
sampler: [
{
key: 'convert_to',
@@ -205,8 +213,302 @@ const FIELDS_BY_COLUMN_TYPE: Record, ColumnF
],
};
-export const getColumnFields = (columnType: DataDesignerColumnType): ColumnField[] =>
- columnType ? (FIELDS_BY_COLUMN_TYPE[columnType] ?? []) : [];
+const BOOL_OPTIONS = [
+ { label: 'Yes', value: 'true' },
+ { label: 'No', value: 'false' },
+] as const;
+
+const PARAM_FIELDS_BY_SAMPLER_TYPE: Partial> = {
+ [SamplerType.uuid]: [
+ {
+ key: 'prefix',
+ label: 'Prefix (optional)',
+ kind: 'text',
+ placeholder: 'e.g. user-',
+ helperText: 'Prepended to each generated UUID.',
+ },
+ {
+ key: 'short_form',
+ label: 'Short form (optional)',
+ kind: 'select',
+ valueType: 'boolean',
+ options: BOOL_OPTIONS,
+ helperText: 'Truncate UUIDs to 8 characters.',
+ },
+ {
+ key: 'uppercase',
+ label: 'Uppercase (optional)',
+ kind: 'select',
+ valueType: 'boolean',
+ options: BOOL_OPTIONS,
+ helperText: 'Capitalize all letters in the UUID.',
+ },
+ ],
+ [SamplerType.category]: [
+ {
+ key: 'values',
+ label: 'Categories',
+ kind: 'textarea',
+ required: true,
+ list: true,
+ placeholder: 'science, technology, history, arts, business',
+ helperText: 'Comma-separated values to sample from.',
+ },
+ ],
+ [SamplerType.subcategory]: [
+ {
+ key: 'category',
+ label: 'Parent category column',
+ kind: 'text',
+ required: true,
+ reference: 'single',
+ placeholder: 'Name of the parent category column',
+ helperText: 'The category column each subcategory value depends on.',
+ },
+ {
+ key: 'values',
+ label: 'Subcategory values (JSON)',
+ kind: 'textarea',
+ required: true,
+ valueType: 'json',
+ placeholder: '{ "science": ["physics", "chemistry"], "arts": ["music", "film"] }',
+ helperText: 'JSON mapping each parent value to a list of subcategory values.',
+ },
+ ],
+ [SamplerType.uniform]: [
+ {
+ key: 'low',
+ label: 'Low',
+ kind: 'text',
+ required: true,
+ valueType: 'number',
+ helperText: 'Lower bound of the range (inclusive).',
+ },
+ {
+ key: 'high',
+ label: 'High',
+ kind: 'text',
+ required: true,
+ valueType: 'number',
+ helperText: 'Upper bound of the range (must be greater than low).',
+ },
+ {
+ key: 'decimal_places',
+ label: 'Decimal places (optional)',
+ kind: 'text',
+ valueType: 'number',
+ helperText: 'Round sampled values to this many decimals.',
+ },
+ ],
+ [SamplerType.gaussian]: [
+ { key: 'mean', label: 'Mean', kind: 'text', required: true, valueType: 'number' },
+ {
+ key: 'stddev',
+ label: 'Standard deviation',
+ kind: 'text',
+ required: true,
+ valueType: 'number',
+ helperText: 'Must be positive.',
+ },
+ {
+ key: 'decimal_places',
+ label: 'Decimal places (optional)',
+ kind: 'text',
+ valueType: 'number',
+ helperText: 'Round sampled values to this many decimals.',
+ },
+ ],
+ [SamplerType.bernoulli]: [
+ {
+ key: 'p',
+ label: 'Probability of success (p)',
+ kind: 'text',
+ required: true,
+ valueType: 'number',
+ helperText: 'Between 0 and 1.',
+ },
+ ],
+ [SamplerType.bernoulli_mixture]: [
+ {
+ key: 'p',
+ label: 'Mixture probability (p)',
+ kind: 'text',
+ required: true,
+ valueType: 'number',
+ helperText: 'Between 0 and 1; otherwise the sample is 0.',
+ },
+ {
+ key: 'dist_name',
+ label: 'Distribution name',
+ kind: 'text',
+ required: true,
+ placeholder: 'e.g. norm, gamma, expon',
+ helperText: 'A scipy.stats distribution name.',
+ },
+ {
+ key: 'dist_params',
+ label: 'Distribution params (JSON)',
+ kind: 'textarea',
+ required: true,
+ valueType: 'json',
+ placeholder: '{ "loc": 0, "scale": 1 }',
+ helperText: 'JSON parameters for the distribution.',
+ },
+ ],
+ [SamplerType.binomial]: [
+ {
+ key: 'n',
+ label: 'Number of trials (n)',
+ kind: 'text',
+ required: true,
+ valueType: 'number',
+ helperText: 'Positive integer.',
+ },
+ {
+ key: 'p',
+ label: 'Probability of success (p)',
+ kind: 'text',
+ required: true,
+ valueType: 'number',
+ helperText: 'Between 0 and 1.',
+ },
+ ],
+ [SamplerType.poisson]: [
+ {
+ key: 'mean',
+ label: 'Mean (rate λ)',
+ kind: 'text',
+ required: true,
+ valueType: 'number',
+ helperText: 'Must be positive.',
+ },
+ ],
+ [SamplerType.scipy]: [
+ {
+ key: 'dist_name',
+ label: 'Distribution name',
+ kind: 'text',
+ required: true,
+ placeholder: 'e.g. beta, gamma, lognorm',
+ helperText: 'A scipy.stats distribution name.',
+ },
+ {
+ key: 'dist_params',
+ label: 'Distribution params (JSON)',
+ kind: 'textarea',
+ required: true,
+ valueType: 'json',
+ placeholder: '{ "a": 2, "b": 5 }',
+ helperText: 'JSON parameters for the distribution.',
+ },
+ {
+ key: 'decimal_places',
+ label: 'Decimal places (optional)',
+ kind: 'text',
+ valueType: 'number',
+ helperText: 'Round sampled values to this many decimals.',
+ },
+ ],
+ [SamplerType.person]: [
+ {
+ key: 'locale',
+ label: 'Locale (optional)',
+ kind: 'text',
+ placeholder: 'e.g. en_US',
+ helperText: 'Managed persona locale (e.g. en_US, ja_JP).',
+ },
+ { key: 'sex', label: 'Sex (optional)', kind: 'select', options: asOptions(['Male', 'Female']) },
+ {
+ key: 'city',
+ label: 'Cities (optional)',
+ kind: 'text',
+ list: true,
+ placeholder: 'comma,separated,cities',
+ helperText: 'Comma-separated city filter.',
+ },
+ {
+ key: 'with_synthetic_personas',
+ label: 'Synthetic personas (optional)',
+ kind: 'select',
+ valueType: 'boolean',
+ options: BOOL_OPTIONS,
+ helperText: 'Append persona trait columns to each person.',
+ },
+ ],
+ [SamplerType.datetime]: [
+ {
+ key: 'start',
+ label: 'Start',
+ kind: 'text',
+ required: true,
+ placeholder: 'e.g. 2020-01-01',
+ helperText: 'Earliest datetime (inclusive).',
+ },
+ {
+ key: 'end',
+ label: 'End',
+ kind: 'text',
+ required: true,
+ placeholder: 'e.g. 2025-01-01',
+ helperText: 'Exclusive upper bound.',
+ },
+ {
+ key: 'unit',
+ label: 'Unit (optional)',
+ kind: 'select',
+ options: asOptions(['Y', 'M', 'D', 'h', 'm', 's']),
+ helperText: 'Sampling granularity (defaults to days).',
+ },
+ ],
+ [SamplerType.timedelta]: [
+ {
+ key: 'dt_min',
+ label: 'Minimum delta',
+ kind: 'text',
+ required: true,
+ valueType: 'number',
+ helperText: 'Non-negative and less than the maximum.',
+ },
+ {
+ key: 'dt_max',
+ label: 'Maximum delta',
+ kind: 'text',
+ required: true,
+ valueType: 'number',
+ helperText: 'Greater than the minimum.',
+ },
+ {
+ key: 'reference_column_name',
+ label: 'Reference datetime column',
+ kind: 'text',
+ required: true,
+ reference: 'single',
+ placeholder: 'Name of an existing datetime column',
+ helperText: 'The datetime column each delta is added to.',
+ },
+ {
+ key: 'unit',
+ label: 'Unit (optional)',
+ kind: 'select',
+ options: asOptions(['D', 'h', 'm', 's']),
+ helperText: 'Time unit for the deltas (defaults to days).',
+ },
+ ],
+};
+
+/** The sampler `params` fields for a sampler sub-type (empty for sub-types without any). */
+const getSamplerParamFields = (samplerType: SamplerType | undefined): ColumnField[] =>
+ samplerType ? (PARAM_FIELDS_BY_SAMPLER_TYPE[samplerType] ?? []) : [];
+
+export const getColumnFields = (
+ option: Pick
+): ColumnField[] => {
+ const { columnType, samplerType } = option;
+ if (!columnType) return [];
+ const base = FIELDS_BY_COLUMN_TYPE[columnType] ?? [];
+ if (columnType === 'sampler') return [...getSamplerParamFields(samplerType), ...base];
+ return base;
+};
/** Accent color → NVIDIA Foundations text token, matching `CardNode`'s idle styling. */
const ACCENT_VAR_CLASS: Record = {
@@ -219,10 +521,6 @@ const ACCENT_VAR_CLASS: Record = {
yellow: 'text-[color:var(--text-color-accent-yellow)]',
};
-/**
- * Resolves an {@link AddColumnSelection} (fired by the palette) to its full catalog
- * option. Matches on `column_type`, and additionally on `sampler_type` for samplers.
- */
export const findColumnOption = (selection: AddColumnSelection): ColumnTypeOption | undefined => {
for (const group of COLUMN_TYPE_GROUPS) {
const match = group.options.find(
@@ -234,11 +532,7 @@ export const findColumnOption = (selection: AddColumnSelection): ColumnTypeOptio
return undefined;
};
-/**
- * Resolves a template's column specs into placed {@link BuilderColumn}s, numbering ids
- * from `startId` (so subsequent user-added columns can continue from the returned count).
- * Specs whose column type can't be matched in the palette are skipped.
- */
+// Specs whose column type can't be matched in the palette are silently skipped.
export const buildColumnsFromTemplate = (
specs: readonly TemplateColumnSpec[],
startId = 0
@@ -261,18 +555,13 @@ export const extractJinjaReferences = (text: string): string[] => {
return refs;
};
-/**
- * The names of columns this column depends on, resolved against the set of known column
- * names. Combines Jinja2 template references (prompt, expr, …) with explicit column-name
- * fields (embedding target, validation targets). Self-references are ignored.
- */
const columnDependencies = (column: BuilderColumn, knownNames: Set): Set => {
const deps = new Set();
const add = (candidate: string) => {
const name = candidate.trim();
if (name && name !== column.name && knownNames.has(name)) deps.add(name);
};
- for (const field of getColumnFields(column.option.columnType)) {
+ for (const field of getColumnFields(column.option)) {
const value = column.values[field.key]?.trim();
if (!value) continue;
switch (field.reference) {
@@ -290,11 +579,6 @@ const columnDependencies = (column: BuilderColumn, knownNames: Set): Set
return deps;
};
-/**
- * Builds the DAG from the current columns. Nodes carry the column name/type for display;
- * edges are drawn only where one column references another (via Jinja2 `{{ }}` or a
- * column-name field), so unconnected columns render as independent roots.
- */
export const buildGraph = (columns: BuilderColumn[]): { nodes: DagNode[]; edges: DagEdge[] } => {
const knownNames = new Set(columns.map((column) => column.name).filter(Boolean));
const idByName = new Map(columns.filter((c) => c.name).map((c) => [c.name, c.id]));
@@ -325,11 +609,6 @@ export const buildGraph = (columns: BuilderColumn[]): { nodes: DagNode[]; edges:
return { nodes, edges };
};
-/**
- * A default, unique column name for a freshly added column, derived from its type
- * (e.g. `llm_text_1`). Ensures the new column is immediately referenceable and never
- * collides with an existing name.
- */
export const defaultColumnName = (option: ColumnTypeOption, takenNames: Set): string => {
const base = (option.samplerType ?? option.columnType ?? 'column').replace(/[^a-zA-Z0-9]+/g, '_');
for (let n = 1; ; n++) {
@@ -347,3 +626,124 @@ export const validateColumnName = (name: string, takenNames: Set): strin
if (takenNames.has(trimmed)) return 'A column with this name already exists.';
return null;
};
+
+const isValidJson = (value: string): boolean => {
+ try {
+ JSON.parse(value);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+export const validateColumns = (columns: BuilderColumn[]): string[] => {
+ if (columns.length === 0) return ['Add at least one column before creating the job.'];
+
+ const errors: string[] = [];
+ for (const column of columns) {
+ const label = column.name || column.option.label;
+ const takenNames = new Set(
+ columns.filter((other) => other.id !== column.id).map((other) => other.name)
+ );
+ const nameError = validateColumnName(column.name, takenNames);
+ if (nameError) errors.push(`${label}: ${nameError}`);
+
+ for (const field of getColumnFields(column.option)) {
+ const value = column.values[field.key]?.trim();
+ if (field.required && !value) {
+ errors.push(`${label}: ${field.label} is required.`);
+ continue;
+ }
+ if (!value) continue;
+ if (field.key === 'output_format' && !isValidJson(value)) {
+ errors.push(`${label}: ${field.label} must be valid JSON.`);
+ }
+ if (field.valueType === 'number' && !Number.isFinite(Number(value))) {
+ errors.push(`${label}: ${field.label} must be a number.`);
+ }
+ if (field.valueType === 'json' && !isValidJson(value)) {
+ errors.push(`${label}: ${field.label} must be valid JSON.`);
+ }
+ }
+ }
+ return errors;
+};
+
+/** Splits a comma-separated field value into trimmed, non-empty entries. */
+const splitList = (value: string): string[] =>
+ value
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter(Boolean);
+
+/** Coerces a (non-empty) string field value into its SDK config form per {@link ColumnField}. */
+const serializeFieldValue = (field: ColumnField, value: string): unknown => {
+ if (field.list) return splitList(value);
+ switch (field.valueType) {
+ case 'number':
+ return Number(value);
+ case 'boolean':
+ return value === 'true';
+ case 'json':
+ return JSON.parse(value);
+ default:
+ return value;
+ }
+};
+
+/**
+ * Converts a sampler column into the SDK's `SamplerColumnConfig` shape. Sub-type params
+ * are nested under the required `params` object; `convert_to` stays at the top level.
+ */
+const toSamplerConfig = (column: BuilderColumn): Record => {
+ const params: Record = {};
+ for (const field of getSamplerParamFields(column.option.samplerType)) {
+ const value = column.values[field.key]?.trim();
+ if (!value) continue;
+ params[field.key] = serializeFieldValue(field, value);
+ }
+
+ const config: Record = {
+ name: column.name,
+ column_type: 'sampler',
+ sampler_type: column.option.samplerType,
+ params,
+ };
+ const convertTo = column.values.convert_to?.trim();
+ if (convertTo) config.convert_to = convertTo;
+ return config;
+};
+
+const toColumnConfig = (column: BuilderColumn): Record => {
+ if (column.option.columnType === 'sampler') return toSamplerConfig(column);
+
+ const config: Record = {
+ name: column.name,
+ column_type: column.option.columnType,
+ };
+
+ for (const field of getColumnFields(column.option)) {
+ const value = column.values[field.key]?.trim();
+ if (!value) continue;
+ if (field.key === 'output_format') {
+ config[field.key] = JSON.parse(value);
+ } else if (field.reference === 'list' || field.list) {
+ config[field.key] = splitList(value);
+ } else {
+ config[field.key] = value;
+ }
+ }
+ return config;
+};
+
+export const buildDataDesignerConfig = (
+ columns: BuilderColumn[],
+ models: BuilderModel[] = []
+): DataDesignerConfig => {
+ const config: DataDesignerConfig = {
+ columns: columns.map(toColumnConfig) as unknown as DataDesignerConfig['columns'],
+ };
+ const modelConfigs = buildModelConfigs(models);
+ if (modelConfigs) config.model_configs = modelConfigs;
+ return config;
+};
diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/index.tsx b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/index.tsx
index 75f6deb25e..e43b28e9ea 100644
--- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/index.tsx
+++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/index.tsx
@@ -1,42 +1,47 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
-import { Flex, PageHeader, Text } from '@nvidia/foundations-react-core';
+import { useAllModels } from '@nemo/common/src/api/models/useModels';
+import { groupModelsByWorkspace } from '@nemo/common/src/utils/models';
+import { useDataDesignerCreateJob } from '@nemo/sdk/generated/data-designer/api';
+import { Flex, Stack, Text } from '@nvidia/foundations-react-core';
+import { getErrorMessage } from '@studio/api/common/utils';
import { AccessibleTitle } from '@studio/components/AccessibleTitle';
-import { AddColumnPalette } from '@studio/components/AddColumnPalette';
-import type { AddColumnSelection } from '@studio/components/AddColumnPalette/types';
-import { ColumnConfigPanel } from '@studio/components/ColumnConfigPanel';
import { findTemplate } from '@studio/components/CreateFilesetStart/templates';
import { DagCanvas } from '@studio/components/DagCanvas';
+import { usePreview } from '@studio/components/NewDataDesignerJobForm/usePreview';
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs';
+import { BuilderConfigPane } from '@studio/routes/DataDesignerJobBuildRoute/BuilderConfigPane';
+import { BuilderDetailsPanel } from '@studio/routes/DataDesignerJobBuildRoute/BuilderDetailsPanel';
+import { BuilderPalette } from '@studio/routes/DataDesignerJobBuildRoute/BuilderPalette';
+import { BuilderToolbar } from '@studio/routes/DataDesignerJobBuildRoute/BuilderToolbar';
import {
- type BuilderColumn,
- buildColumnsFromTemplate,
- buildGraph,
- defaultColumnName,
- findColumnOption,
+ buildDataDesignerConfig,
+ validateColumns,
} from '@studio/routes/DataDesignerJobBuildRoute/columns';
-import { getDataDesignerJobListRoute, getNewDataDesignerJobRoute } from '@studio/routes/utils';
-import { type FC, useMemo, useRef, useState } from 'react';
-import { useSearchParams } from 'react-router-dom';
+import { validateModels } from '@studio/routes/DataDesignerJobBuildRoute/models';
+import { useJobBuilder } from '@studio/routes/DataDesignerJobBuildRoute/useJobBuilder';
+import {
+ getDataDesignerJobDetailsRoute,
+ getDataDesignerJobListRoute,
+ getNewDataDesignerJobRoute,
+} from '@studio/routes/utils';
+import { type FC, useCallback, useMemo, useState } from 'react';
+import { useAuth } from 'react-oidc-context';
+import { useNavigate, useSearchParams } from 'react-router-dom';
/**
- * The "Build from scratch" column builder. Composes the {@link AddColumnPalette} (left),
- * the {@link DagCanvas} recipe graph (center), and a {@link ColumnConfigPanel} that opens
- * on the right when a column is added or a node is clicked — so the canvas stays visible
- * while the column is configured.
- *
- * Edges are derived from the entered values: a column that references another via a
- * Jinja2 `{{ column_name }}` token (or a column-name field) gets an edge from the
- * referenced column, so the graph reflects real data dependencies rather than add order.
+ * Edges are derived from entered values: Jinja2 `{{ column_name }}` references (and
+ * column-name fields) draw edges so the graph reflects data dependencies, not add order.
*/
export const DataDesignerJobBuildRoute: FC = () => {
const workspace = useWorkspaceFromPath();
+ const navigate = useNavigate();
+ const { user } = useAuth();
const [searchParams] = useSearchParams();
- // A `?template=` param (set by the "Start from a template" flow) preloads the
- // canvas with that recipe's columns; without it, the canvas starts empty ("scratch").
+ // `?template=` seeds the canvas from a template recipe; absent = empty canvas.
const template = useMemo(() => {
const templateId = searchParams.get('template');
return templateId ? (findTemplate(templateId) ?? null) : null;
@@ -52,67 +57,125 @@ export const DataDesignerJobBuildRoute: FC = () => {
],
});
- // Seed once from the template (if any). `useState` initializer runs a single time, so
- // navigating with a template preloads its columns without re-seeding on every render.
- const [columns, setColumns] = useState(() =>
- template ? buildColumnsFromTemplate(template.columns) : []
+ const {
+ data: modelsData,
+ isLoading: isLoadingModels,
+ hasNextPage,
+ isFetchingNextPage,
+ } = useAllModels({ workspace });
+ const modelGroups = useMemo(
+ () =>
+ groupModelsByWorkspace(modelsData?.pages.flatMap((page) => page.data ?? []) ?? [], {
+ sort: true,
+ }),
+ [modelsData?.pages]
);
- const [selectedId, setSelectedId] = useState(null);
- // Set only when a column is added, so the canvas centers new nodes but not clicked ones.
- const [focusId, setFocusId] = useState(null);
- // Continue numbering after any preloaded template columns so ids stay unique.
- const nextId = useRef(columns.length);
-
- const selectedColumn = columns.find((column) => column.id === selectedId) ?? null;
-
- const takenNames = useMemo(
+ const modelsSettled = !isLoadingModels && !hasNextPage && !isFetchingNextPage;
+
+ const builder = useJobBuilder(template, modelGroups, modelsSettled);
+ const { columns, models } = builder;
+
+ const [name, setName] = useState(() => template?.id ?? 'untitled-dataset');
+ const [rows, setRows] = useState('100');
+ const [validationErrors, setValidationErrors] = useState([]);
+ // Whether the errors/preview panel below the toolbar is expanded. Runs that produce
+ // output re-open it; the user can collapse it again to focus on the canvas.
+ const [isDetailsOpen, setIsDetailsOpen] = useState(false);
+
+ const validateAndCollectErrors = useCallback(() => {
+ const numRecords = Number(rows);
+ const errors = [...validateColumns(columns), ...validateModels(models)];
+ if (!name.trim()) {
+ errors.push('Fileset name is required.');
+ }
+ if (!Number.isInteger(numRecords) || numRecords < 1) {
+ errors.push('Records to generate must be a whole number of at least 1.');
+ }
+ setValidationErrors(errors);
+ setIsDetailsOpen(true);
+ return errors;
+ }, [columns, models, rows, name]);
+
+ const getCurrentConfig = useCallback(
() =>
- new Set(columns.filter((column) => column.id !== selectedId).map((column) => column.name)),
- [columns, selectedId]
+ validateColumns(columns).length === 0 && validateModels(models).length === 0
+ ? buildDataDesignerConfig(columns, models)
+ : undefined,
+ [columns, models]
);
+ const { previewLogs, isPreviewing, runPreview } = usePreview({
+ workspace,
+ accessToken: user?.access_token ?? undefined,
+ getCurrentConfig,
+ });
- const { nodes, edges } = useMemo(() => buildGraph(columns), [columns]);
-
- const handleAddColumn = (selection: AddColumnSelection) => {
- const option = findColumnOption(selection);
- if (!option) return;
- const id = `col-${nextId.current++}`;
- setColumns((prev) => {
- const name = defaultColumnName(option, new Set(prev.map((column) => column.name)));
- return [...prev, { id, option, name, values: {} }];
- });
- setSelectedId(id);
- setFocusId(id);
+ const handlePreview = () => {
+ if (validateAndCollectErrors().length > 0) return;
+ setIsDetailsOpen(true);
+ void runPreview();
};
- const patchColumn = (id: string, patch: { name?: string; values?: Record }) =>
- setColumns((prev) =>
- prev.map((column) => (column.id === id ? { ...column, ...patch } : column))
- );
-
- const removeColumn = (id: string) => {
- setColumns((prev) => prev.filter((column) => column.id !== id));
- setSelectedId((current) => (current === id ? null : current));
+ const createJob = useDataDesignerCreateJob();
+ const submitError = createJob.error ? getErrorMessage(createJob.error) : null;
+
+ const handleSubmit = async () => {
+ if (validateAndCollectErrors().length > 0) return;
+
+ try {
+ const created = await createJob.mutateAsync({
+ workspace,
+ data: {
+ name,
+ spec: { num_records: Number(rows), config: buildDataDesignerConfig(columns, models) },
+ },
+ });
+ if (created?.name) {
+ navigate(getDataDesignerJobDetailsRoute(workspace, created.name));
+ } else {
+ navigate(getDataDesignerJobListRoute(workspace));
+ }
+ } catch {
+ setIsDetailsOpen(true);
+ // Error surfaced via createJob.error / submitError below.
+ }
};
return (
-
-
-
+
+
+ setIsDetailsOpen((open) => !open)}
+ />
+
+
+
-
-
-
-
{columns.length === 0 ? (
@@ -123,34 +186,39 @@ export const DataDesignerJobBuildRoute: FC = () => {
) : (
)}
-
- {selectedColumn ? (
- patchColumn(selectedColumn.id, patch)}
- onRemove={() => removeColumn(selectedColumn.id)}
- onClose={() => setSelectedId(null)}
- />
- ) : (
-
-
- Select a column to configure it, or add one from the left.
-
-
- )}
-
-
-
+
+ builder.selectedColumn && builder.patchColumn(builder.selectedColumn.id, patch)
+ }
+ onColumnRemove={() =>
+ builder.selectedColumn && builder.removeColumn(builder.selectedColumn.id)
+ }
+ onColumnClose={() => builder.selectColumn(null)}
+ onModelChange={(patch) =>
+ builder.selectedModel && builder.patchModel(builder.selectedModel.id, patch)
+ }
+ onModelRemove={() =>
+ builder.selectedModel && builder.removeModel(builder.selectedModel.id)
+ }
+ onModelClose={() => builder.selectModel(null)}
+ />
+
+
);
};
diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.test.ts b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.test.ts
new file mode 100644
index 0000000000..06fb4a1b00
--- /dev/null
+++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.test.ts
@@ -0,0 +1,198 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { ModelWorkspaceGroup } from '@nemo/common/src/api/models/useModels';
+import {
+ type BuilderModel,
+ buildModelConfigs,
+ buildModelsFromTemplate,
+ builderModelFromSelection,
+ defaultModelAlias,
+ firstAvailableModel,
+ providerForModel,
+ resolveTemplateModel,
+ validateModelAlias,
+ validateModels,
+} from '@studio/routes/DataDesignerJobBuildRoute/models';
+
+const model = (overrides: Partial = {}): BuilderModel => ({
+ id: 'model-0',
+ alias: 'default',
+ model: 'openai/gpt-4o-mini',
+ provider: 'openai',
+ inferenceParams: {},
+ ...overrides,
+});
+
+describe('defaultModelAlias', () => {
+ it('returns the first unused model_N alias', () => {
+ expect(defaultModelAlias(new Set())).toBe('model_1');
+ expect(defaultModelAlias(new Set(['model_1', 'model_2']))).toBe('model_3');
+ });
+});
+
+describe('providerForModel', () => {
+ const groups = [
+ {
+ workspace: 'steramae',
+ models: [
+ { workspace: 'steramae', name: 'gpt-oss', model_providers: ['steramae/build'] },
+ {
+ workspace: 'steramae',
+ name: 'nvidia-llama-3-3-nemotron-super-49b-v1',
+ model_providers: ['steramae/build'],
+ },
+ { workspace: 'steramae', name: 'no-provider' },
+ ],
+ },
+ ] as unknown as ModelWorkspaceGroup[];
+
+ it('returns the model’s first provider ref', () => {
+ expect(providerForModel(groups, 'steramae/gpt-oss')).toBe('steramae/build');
+ });
+
+ it('returns empty string when the model or its provider is missing', () => {
+ expect(providerForModel(groups, 'steramae/no-provider')).toBe('');
+ expect(providerForModel(groups, 'steramae/unknown')).toBe('');
+ });
+
+ it('firstAvailableModel picks the first model and its provider', () => {
+ expect(firstAvailableModel(groups)).toEqual({
+ model: 'steramae/gpt-oss',
+ provider: 'steramae/build',
+ });
+ expect(firstAvailableModel([])).toBeNull();
+ });
+
+ it('resolveTemplateModel prefers a model matching the name across workspaces', () => {
+ expect(resolveTemplateModel(groups, 'nvidia-llama-3-3-nemotron-super-49b-v1')).toEqual({
+ model: 'steramae/nvidia-llama-3-3-nemotron-super-49b-v1',
+ provider: 'steramae/build',
+ });
+ });
+
+ it('resolveTemplateModel matches a full URN too', () => {
+ expect(resolveTemplateModel(groups, 'steramae/gpt-oss')).toEqual({
+ model: 'steramae/gpt-oss',
+ provider: 'steramae/build',
+ });
+ });
+
+ it('resolveTemplateModel falls back to the first model when the preferred is absent', () => {
+ expect(resolveTemplateModel(groups, 'not-in-workspace')).toEqual({
+ model: 'steramae/gpt-oss',
+ provider: 'steramae/build',
+ });
+ expect(resolveTemplateModel([], 'anything')).toBeNull();
+ });
+});
+
+describe('buildModelsFromTemplate', () => {
+ it('seeds models with sequential ids, leaving model/provider empty for auto-fill', () => {
+ const models = buildModelsFromTemplate([{ alias: 'default' }], 2);
+ expect(models).toEqual([
+ { id: 'model-2', alias: 'default', model: '', provider: '', inferenceParams: {} },
+ ]);
+ });
+
+ it('carries a preferred model and inference params through', () => {
+ const models = buildModelsFromTemplate([
+ { alias: 'judge', model: 'nvidia/gpt-oss', inferenceParams: { temperature: 0 } },
+ ]);
+ expect(models[0]).toMatchObject({
+ alias: 'judge',
+ model: 'nvidia/gpt-oss',
+ inferenceParams: { temperature: 0 },
+ });
+ });
+
+ it('returns an empty array when no specs are given', () => {
+ expect(buildModelsFromTemplate()).toEqual([]);
+ });
+});
+
+describe('builderModelFromSelection', () => {
+ it('seeds the model and provider from the selection with a unique default alias', () => {
+ expect(
+ builderModelFromSelection(
+ 'model-5',
+ { model: 'openai/gpt-4o-mini' },
+ 'default/nvidia-build',
+ new Set(['model_1'])
+ )
+ ).toEqual({
+ id: 'model-5',
+ alias: 'model_2',
+ model: 'openai/gpt-4o-mini',
+ provider: 'default/nvidia-build',
+ inferenceParams: {},
+ });
+ });
+});
+
+describe('validateModelAlias', () => {
+ it('requires a non-empty, unique alias', () => {
+ expect(validateModelAlias(' ', new Set())).toMatch(/required/);
+ expect(validateModelAlias('a', new Set(['a']))).toMatch(/already exists/);
+ expect(validateModelAlias('a', new Set(['b']))).toBeNull();
+ });
+});
+
+describe('validateModels', () => {
+ it('accepts a fully-specified model', () => {
+ expect(
+ validateModels([
+ model({ inferenceParams: { temperature: 0.7, top_p: 0.9, max_tokens: 512 } }),
+ ])
+ ).toEqual([]);
+ });
+
+ it('flags a model with no selection', () => {
+ expect(validateModels([model({ model: '' })])).toContainEqual(
+ expect.stringContaining('A model must be selected')
+ );
+ });
+
+ it('flags duplicate aliases across models', () => {
+ const errors = validateModels([
+ model({ id: 'model-0', alias: 'dupe' }),
+ model({ id: 'model-1', alias: 'dupe' }),
+ ]);
+ expect(errors.filter((e) => e.includes('already exists'))).toHaveLength(2);
+ });
+});
+
+describe('buildModelConfigs', () => {
+ it('returns undefined when there are no models', () => {
+ expect(buildModelConfigs([])).toBeUndefined();
+ });
+
+ it('omits empty optional fields and inference parameters', () => {
+ expect(buildModelConfigs([model({ provider: '' })])).toEqual([
+ { alias: 'default', model: 'openai/gpt-4o-mini', provider: '' },
+ ]);
+ });
+
+ it('maps inference parameters and trims the alias', () => {
+ expect(
+ buildModelConfigs([
+ model({
+ alias: ' spaced ',
+ inferenceParams: { temperature: 0.7, top_p: 0.9, max_tokens: 512 },
+ }),
+ ])
+ ).toEqual([
+ {
+ alias: 'spaced',
+ model: 'openai/gpt-4o-mini',
+ provider: 'openai',
+ inference_parameters: {
+ generation_type: 'chat-completion',
+ temperature: 0.7,
+ top_p: 0.9,
+ max_tokens: 512,
+ },
+ },
+ ]);
+ });
+});
diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.ts b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.ts
new file mode 100644
index 0000000000..c8fcd28b7a
--- /dev/null
+++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.ts
@@ -0,0 +1,159 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { ModelWorkspaceGroup } from '@nemo/common/src/api/models/useModels';
+import type { ModelSelection } from '@nemo/common/src/components/ModelSelectV2/types';
+import { getURNFromNamedEntityRef } from '@nemo/common/src/namedEntity';
+import type {
+ ChatCompletionInferenceParams,
+ ModelConfig,
+} from '@nemo/sdk/generated/data-designer/schema';
+import type { InferenceParams } from '@nemo/sdk/generated/platform/schema';
+import type { TemplateModelSpec } from '@studio/components/CreateFilesetStart/types';
+
+/** Mirrors the SDK ModelConfig shape; `alias` is what LLM columns reference via `model_alias`. */
+export interface BuilderModel {
+ /** Canvas-unique id (stable across alias edits, used for selection). */
+ id: string;
+ alias: string;
+ model: string;
+ provider: string;
+ inferenceParams: Partial;
+}
+
+export type BuilderModelPatch = Partial>;
+
+/**
+ * Resolves the provider for a model URN from the platform model list: the model's first
+ * `model_providers` entry (a `workspace/provider-name` resource ref). Data Designer needs
+ * an explicit provider on each model config — an unset provider is deprecated and the job
+ * fails with "the model does not have a provider". Returns '' when the model isn't found
+ * or has no provider (the user can still fill it in manually).
+ */
+export const providerForModel = (modelGroups: ModelWorkspaceGroup[], model: string): string => {
+ for (const group of modelGroups) {
+ for (const entity of group.models) {
+ if (getURNFromNamedEntityRef(entity) === model) return entity.model_providers?.[0] ?? '';
+ }
+ }
+ return '';
+};
+
+/** First platform model (with resolved provider), used to auto-fill a template's model. */
+export const firstAvailableModel = (
+ modelGroups: ModelWorkspaceGroup[]
+): { model: string; provider: string } | null => {
+ for (const group of modelGroups) {
+ for (const entity of group.models) {
+ const model = getURNFromNamedEntityRef(entity);
+ if (model) return { model, provider: entity.model_providers?.[0] ?? '' };
+ }
+ }
+ return null;
+};
+
+/**
+ * Resolves the model + provider to auto-fill for a template-seeded model. Prefers a model
+ * matching `preferred` (by full URN, or by name so it resolves across workspaces —
+ * the URN's workspace prefix varies per user) when it exists in the workspace, otherwise
+ * falls back to the first available model. Returns null when no models are available.
+ */
+export const resolveTemplateModel = (
+ modelGroups: ModelWorkspaceGroup[],
+ preferred?: string
+): { model: string; provider: string } | null => {
+ if (preferred) {
+ for (const group of modelGroups) {
+ for (const entity of group.models) {
+ const urn = getURNFromNamedEntityRef(entity);
+ const baseName = entity.name?.split('@')[0];
+ if (urn && (urn === preferred || entity.name === preferred || baseName === preferred)) {
+ return { model: urn, provider: entity.model_providers?.[0] ?? '' };
+ }
+ }
+ }
+ }
+ return firstAvailableModel(modelGroups);
+};
+
+/**
+ * Resolves a template's model specs into {@link BuilderModel}s, numbering ids from
+ * `startId`. `model`/`provider` may be empty when the spec omits a preferred model — the
+ * build route auto-fills them from the workspace once the platform model list loads.
+ */
+export const buildModelsFromTemplate = (
+ specs: readonly TemplateModelSpec[] = [],
+ startId = 0
+): BuilderModel[] =>
+ specs.map((spec, index) => ({
+ id: `model-${startId + index}`,
+ alias: spec.alias,
+ model: spec.model ?? '',
+ provider: '',
+ inferenceParams: { ...spec.inferenceParams },
+ }));
+
+export const builderModelFromSelection = (
+ id: string,
+ selection: ModelSelection,
+ provider: string,
+ takenAliases: Set
+): BuilderModel => ({
+ id,
+ alias: defaultModelAlias(takenAliases),
+ model: selection.model,
+ provider,
+ inferenceParams: {},
+});
+
+/** A default, unique model alias (e.g. `model_1`), never colliding with an existing one. */
+export const defaultModelAlias = (takenAliases: Set): string => {
+ for (let n = 1; ; n++) {
+ const candidate = `model_${n}`;
+ if (!takenAliases.has(candidate)) return candidate;
+ }
+};
+
+export const validateModelAlias = (alias: string, takenAliases: Set): string | null => {
+ const trimmed = alias.trim();
+ if (!trimmed) return 'Alias is required.';
+ if (takenAliases.has(trimmed)) return 'A model with this alias already exists.';
+ return null;
+};
+
+export const validateModels = (models: BuilderModel[]): string[] => {
+ const errors: string[] = [];
+ for (const model of models) {
+ const label = model.alias.trim() || 'Model';
+ const takenAliases = new Set(
+ models.filter((other) => other.id !== model.id).map((other) => other.alias.trim())
+ );
+ const aliasError = validateModelAlias(model.alias, takenAliases);
+ if (aliasError) errors.push(`${label}: ${aliasError}`);
+ if (!model.model.trim()) errors.push(`${label}: A model must be selected.`);
+ }
+ return errors;
+};
+
+const toModelConfig = (model: BuilderModel): ModelConfig => {
+ const config: ModelConfig = {
+ alias: model.alias.trim(),
+ model: model.model.trim(),
+ provider: model.provider.trim(),
+ };
+ if (model.provider.trim()) config.provider = model.provider.trim();
+
+ const { temperature, top_p, max_tokens } = model.inferenceParams;
+ const inference: ChatCompletionInferenceParams = {};
+ if (temperature !== undefined) inference.temperature = temperature;
+ if (top_p !== undefined) inference.top_p = top_p;
+ if (max_tokens !== undefined) inference.max_tokens = max_tokens;
+ if (Object.keys(inference).length > 0) {
+ config.inference_parameters = { generation_type: 'chat-completion', ...inference };
+ }
+ return config;
+};
+
+/** Returns undefined when there are no models so the key is omitted from the config. */
+export const buildModelConfigs = (models: BuilderModel[]): ModelConfig[] | undefined =>
+ models.length > 0 ? models.map(toModelConfig) : undefined;
diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/useJobBuilder.ts b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/useJobBuilder.ts
new file mode 100644
index 0000000000..bfb6d3db27
--- /dev/null
+++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/useJobBuilder.ts
@@ -0,0 +1,174 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { ModelWorkspaceGroup } from '@nemo/common/src/api/models/useModels';
+import type { ModelSelection } from '@nemo/common/src/components/ModelSelectV2/types';
+import type { AddColumnSelection } from '@studio/components/AddColumnPalette/types';
+import type { FilesetTemplate } from '@studio/components/CreateFilesetStart/types';
+import {
+ type BuilderColumn,
+ buildColumnsFromTemplate,
+ buildGraph,
+ defaultColumnName,
+ findColumnOption,
+} from '@studio/routes/DataDesignerJobBuildRoute/columns';
+import {
+ type BuilderModel,
+ type BuilderModelPatch,
+ buildModelsFromTemplate,
+ builderModelFromSelection,
+ resolveTemplateModel,
+} from '@studio/routes/DataDesignerJobBuildRoute/models';
+import { useEffect, useMemo, useRef, useState } from 'react';
+
+/** Which palette the left aside shows. */
+export type PaletteTab = 'columns' | 'models';
+
+/**
+ * Column/model state for the recipe builder. Selecting a column and selecting a model are
+ * mutually exclusive — only one config panel shows at a time.
+ *
+ * Job-level concerns (name, row count, validation, preview, submit) live in the route so
+ * this hook stays a pure graph-editing store.
+ *
+ * `modelGroups` auto-fills a template's seeded models once the platform model list loads.
+ * `modelsSettled` gates that auto-fill on the full (all-pages) model list being available.
+ */
+export const useJobBuilder = (
+ template: FilesetTemplate | null,
+ modelGroups: ModelWorkspaceGroup[],
+ modelsSettled: boolean
+) => {
+ // Seed once from the template (if any). `useState` initializer runs a single time, so
+ // navigating with a template preloads its columns without re-seeding on every render.
+ const [columns, setColumns] = useState(() =>
+ template ? buildColumnsFromTemplate(template.columns) : []
+ );
+ const [selectedId, setSelectedId] = useState(null);
+ // Set only when a column is added, so the canvas centers new nodes but not clicked ones.
+ const [focusId, setFocusId] = useState(null);
+ // Continue numbering after any preloaded template columns so ids stay unique.
+ const nextId = useRef(columns.length);
+
+ // The models referenced by LLM columns via `model_alias`; part of the same job config.
+ // Seeded once from the template (if any); providers/models are auto-filled below.
+ const [models, setModels] = useState(() =>
+ buildModelsFromTemplate(template?.models)
+ );
+ const [selectedModelId, setSelectedModelId] = useState(null);
+ const nextModelId = useRef(models.length);
+ const [paletteTab, setPaletteTab] = useState('columns');
+
+ const autoFilled = useRef(false);
+ useEffect(() => {
+ if (autoFilled.current || !modelsSettled || modelGroups.length === 0) return;
+ autoFilled.current = true;
+ setModels((prev) => {
+ let changed = false;
+ const next = prev.map((model) => {
+ if (model.provider) return model;
+ const resolved = resolveTemplateModel(modelGroups, model.model || undefined);
+ changed = true;
+ return resolved ? { ...model, ...resolved } : { ...model, model: '' };
+ });
+ return changed ? next : prev;
+ });
+ }, [modelGroups, modelsSettled]);
+
+ const selectedColumn = columns.find((column) => column.id === selectedId) ?? null;
+ const selectedModel = models.find((model) => model.id === selectedModelId) ?? null;
+
+ // Model aliases used by models other than the selected one (uniqueness check).
+ const takenAliases = useMemo(
+ () =>
+ new Set(
+ models.filter((model) => model.id !== selectedModelId).map((model) => model.alias.trim())
+ ),
+ [models, selectedModelId]
+ );
+
+ // Names taken by columns other than the selected one (uniqueness check).
+ const takenNames = useMemo(
+ () =>
+ new Set(columns.filter((column) => column.id !== selectedId).map((column) => column.name)),
+ [columns, selectedId]
+ );
+
+ const { nodes, edges } = useMemo(() => buildGraph(columns), [columns]);
+
+ const selectColumn = (id: string | null) => {
+ setSelectedId(id);
+ if (id !== null) setSelectedModelId(null);
+ };
+ const selectModel = (id: string | null) => {
+ setSelectedModelId(id);
+ if (id !== null) setSelectedId(null);
+ };
+
+ const handleAddColumn = (selection: AddColumnSelection) => {
+ const option = findColumnOption(selection);
+ if (!option) return;
+ const id = `col-${nextId.current++}`;
+ setColumns((prev) => {
+ const name = defaultColumnName(option, new Set(prev.map((column) => column.name)));
+ return [...prev, { id, option, name, values: {} }];
+ });
+ selectColumn(id);
+ setFocusId(id);
+ };
+
+ const patchColumn = (id: string, patch: { name?: string; values?: Record }) =>
+ setColumns((prev) =>
+ prev.map((column) => (column.id === id ? { ...column, ...patch } : column))
+ );
+
+ const removeColumn = (id: string) => {
+ setColumns((prev) => prev.filter((column) => column.id !== id));
+ setSelectedId((current) => (current === id ? null : current));
+ };
+
+ const handleAddModel = (selection: ModelSelection, provider: string) => {
+ const id = `model-${nextModelId.current++}`;
+ setModels((prev) => [
+ ...prev,
+ builderModelFromSelection(
+ id,
+ selection,
+ provider,
+ new Set(prev.map((model) => model.alias.trim()))
+ ),
+ ]);
+ selectModel(id);
+ };
+
+ const patchModel = (id: string, patch: BuilderModelPatch) =>
+ setModels((prev) => prev.map((model) => (model.id === id ? { ...model, ...patch } : model)));
+
+ const removeModel = (id: string) => {
+ setModels((prev) => prev.filter((model) => model.id !== id));
+ setSelectedModelId((current) => (current === id ? null : current));
+ };
+
+ return {
+ columns,
+ models,
+ selectedColumn,
+ selectedModel,
+ selectedModelId,
+ focusId,
+ paletteTab,
+ setPaletteTab,
+ nodes,
+ edges,
+ takenNames,
+ takenAliases,
+ selectColumn,
+ selectModel,
+ handleAddColumn,
+ patchColumn,
+ removeColumn,
+ handleAddModel,
+ patchModel,
+ removeModel,
+ };
+};