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, + }; +};