From f5d0cc864c742e6c284f9187ee019a9a5fe57115 Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Wed, 1 Jul 2026 16:38:56 -0700 Subject: [PATCH 1/8] feat(studio): add model palette and config panel to DAG build route Signed-off-by: Sean Teramae --- .../AddModelPalette.stories.tsx | 56 ++++ .../src/components/AddModelPalette/index.tsx | 103 ++++++++ .../ColumnConfigPanel/ColumnConfigPanel.tsx | 2 +- .../CreateFilesetStart/templates.ts | 9 + .../components/CreateFilesetStart/types.ts | 24 ++ .../src/components/ModelConfigPanel/index.tsx | 139 ++++++++++ .../BuilderConfigPane.tsx | 75 ++++++ .../BuilderDetailsPanel.tsx | 87 +++++++ .../BuilderPalette.tsx | 67 +++++ .../BuilderToolbar.tsx | 115 ++++++++ .../DataDesignerJobBuildRoute/columns.test.ts | 39 ++- .../DataDesignerJobBuildRoute/columns.ts | 160 +++++++++++- .../DataDesignerJobBuildRoute/index.tsx | 246 +++++++++++------- .../DataDesignerJobBuildRoute/models.test.ts | 198 ++++++++++++++ .../DataDesignerJobBuildRoute/models.ts | 188 +++++++++++++ .../useJobBuilder.ts | 187 +++++++++++++ 16 files changed, 1600 insertions(+), 95 deletions(-) create mode 100644 web/packages/studio/src/components/AddModelPalette/AddModelPalette.stories.tsx create mode 100644 web/packages/studio/src/components/AddModelPalette/index.tsx create mode 100644 web/packages/studio/src/components/ModelConfigPanel/index.tsx create mode 100644 web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderConfigPane.tsx create mode 100644 web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderDetailsPanel.tsx create mode 100644 web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderPalette.tsx create mode 100644 web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderToolbar.tsx create mode 100644 web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.test.ts create mode 100644 web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.ts create mode 100644 web/packages/studio/src/routes/DataDesignerJobBuildRoute/useJobBuilder.ts 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..bd9c2da4e4 --- /dev/null +++ b/web/packages/studio/src/components/AddModelPalette/AddModelPalette.stories.tsx @@ -0,0 +1,56 @@ +// 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' }, +}; + +/** The empty state, before any model is added. */ +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..5b4bc120aa --- /dev/null +++ b/web/packages/studio/src/components/AddModelPalette/index.tsx @@ -0,0 +1,103 @@ +// 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 { + /** The models currently configured for this job. */ + models: BuilderModel[]; + /** The model currently open for editing, if any. */ + selectedId?: string | null; + /** Platform models to choose from, grouped by workspace. */ + modelGroups: ModelWorkspaceGroup[]; + /** Whether the platform model list is still loading. */ + isLoadingModels?: boolean; + /** + * Adds a model config seeded from the picked platform model (with its resolved provider) + * and opens it for editing. + */ + onAddModel: (selection: ModelSelection, provider: string) => void; + /** Opens an existing model config for editing. */ + onSelectModel: (id: string) => void; + className?: string; +} + +/** + * "Models" palette for the Data Designer recipe builder — the sibling of the + * {@link AddColumnPalette} behind the aside's segmented control. + * + * A {@link ModelSelectV2} at the top adds a model to the job config: picking a platform + * model appends a config seeded from it and opens it in the right-hand config panel. + * Existing models are listed below as keyboard-activatable {@link SelectableCard}s + * (matching the column palette's look); activating one selects it for editing. The models + * it lists are the same ones an LLM column's `model_alias` field references, so they live + * in — and submit as part of — the one job config. + */ +export const AddModelPalette: FC = ({ + models, + selectedId, + modelGroups, + isLoadingModels, + onAddModel, + onSelectModel, + className, +}) => ( + + + Models + + Referenced by LLM columns via their model alias + + + + {/* Picking a model adds it to the job config; the selector resets to its placeholder. */} +
+ + 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..52abfbd595 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,10 @@ export const FILESET_TEMPLATES: FilesetTemplate[] = [ }, }, ], + // The `default` alias the columns above reference. Prefers the Nemotron build model + // when it's in the workspace; the build route falls back to the first available model + // otherwise, so the recipe can be previewed immediately either way. + 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..89b970b63d 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,27 @@ export interface TemplateColumnSpec extends AddColumnSelection { values?: Record; } +/** + * One model a template preloads into the job config so its LLM columns have something to + * generate with out of the box. `alias` must match the `model_alias` its columns + * reference. `model` is an optional preferred URN; when omitted (or not present in the + * workspace) the build route auto-fills the first available model so the recipe can be + * previewed immediately. Resolved to a `BuilderModel` by the build route. + */ +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 +64,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..c34c73e740 --- /dev/null +++ b/web/packages/studio/src/components/ModelConfigPanel/index.tsx @@ -0,0 +1,139 @@ +// 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 { + /** The model currently being edited. */ + model: BuilderModel; + /** Aliases used by other models, for the uniqueness check. */ + takenAliases: Set; + /** Platform models to choose from, grouped by workspace. */ + modelGroups: ModelWorkspaceGroup[]; + /** Whether the model list is still loading. */ + isLoadingModels?: boolean; + /** Fired live as the user edits any field. */ + onChange: (patch: BuilderModelPatch) => void; + /** Removes this model from the config. */ + onRemove: () => void; + /** Closes the panel (deselects the model). */ + onClose: () => void; +} + +/** + * Right-hand config panel for the selected model on the build canvas — the sibling of + * {@link ColumnConfigPanel}, sharing its inline (non-overlay) layout and live-edit model. + * + * The model and its inference parameters are chosen through {@link ModelSelectV2} (the + * shared platform model picker), so this stays consistent with the rest of Studio, + * alongside the `alias` that LLM columns reference. + */ +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; + + // Switching the model also re-resolves its provider so the submitted config keeps them + // in sync (Data Designer requires an explicit provider per model). + 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..4c03531116 --- /dev/null +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderConfigPane.tsx @@ -0,0 +1,75 @@ +// 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; +} + +/** + * The right pane: the config panel for whichever of a column or model is selected (they + * are mutually exclusive), or a hint to select/add something when nothing is selected. + */ +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..3727537c21 --- /dev/null +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderDetailsPanel.tsx @@ -0,0 +1,87 @@ +// 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 { + /** Validation issues collected on the last validate/preview/submit attempt. */ + validationErrors: string[]; + /** Message from a failed job creation, if any. */ + submitError: string | null; + /** Raw preview run logs; empty until a preview has been run. */ + previewLogs: string; + /** Whether the panel body is expanded. */ + isOpen: boolean; + onToggle: () => void; +} + +/** + * The collapsible strip below the toolbar that surfaces validation issues, job-creation + * errors, and preview logs. Renders nothing when there is nothing to show. + */ +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..b637671e95 --- /dev/null +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderPalette.tsx @@ -0,0 +1,67 @@ +// 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; +} + +/** + * The left aside: a Columns/Models segmented control over the matching palette. The 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..116172127a --- /dev/null +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderToolbar.tsx @@ -0,0 +1,115 @@ +// 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; +} + +/** + * The dark toolbar strip above the canvas and side panels: fileset identity on the + * left (name, template badge, column count), run controls on the right (rows, + * validate, preview, create). + */ +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..c6b04b448d 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,38 @@ 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')); + }); +}); + 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..5c98305c91 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,6 +37,11 @@ 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; } /** @@ -194,6 +204,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 +217,42 @@ const FIELDS_BY_COLUMN_TYPE: Record, ColumnF ], }; -export const getColumnFields = (columnType: DataDesignerColumnType): ColumnField[] => - columnType ? (FIELDS_BY_COLUMN_TYPE[columnType] ?? []) : []; +/** + * Sampler sub-type-specific fields, collected into the sampler config's required `params` + * object (see `SamplerColumnConfig`). Only the sub-types with builder-editable params are + * listed; others fall back to an empty `params` object. + */ +const PARAM_FIELDS_BY_SAMPLER_TYPE: Partial> = { + [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.', + }, + ], +}; + +/** 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] ?? []) : []; + +/** + * Returns the config fields for a column option (excluding the always-present `name`). + * For sampler columns, the sub-type's `params` fields precede the shared sampler fields. + */ +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 = { @@ -272,7 +318,7 @@ const columnDependencies = (column: BuilderColumn, knownNames: Set): Set 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) { @@ -347,3 +393,111 @@ export const validateColumnName = (name: string, takenNames: Set): strin if (takenNames.has(trimmed)) return 'A column with this name already exists.'; return null; }; + +/** + * Validates every column is ready to submit: unique, well-formed names; every field + * marked `required` in {@link getColumnFields} filled in; and JSON-shaped fields (e.g. + * `output_format`) parse. Returns one human-readable message per problem found, or an + * empty array if the recipe is submittable. + */ +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 (field.key === 'output_format' && value) { + try { + JSON.parse(value); + } catch { + 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); + +/** + * 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] = field.list ? splitList(value) : 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; +}; + +/** Converts one builder column's string field values into the SDK's column config shape. */ +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; +}; + +/** + * Builds the Data Designer job config from the canvas columns and the configured models, + * ready to submit via `useDataDesignerCreateJob`. Columns reference models by their alias + * (the `model_alias` field), so the two live in the same config. Call + * {@link validateColumns} / {@link validateModels} first — this assumes both are valid and + * does not re-check them. + */ +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..d0beeac29d 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/index.tsx +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/index.tsx @@ -1,31 +1,41 @@ // 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. + * The "Build from scratch" column builder. Composes the {@link BuilderPalette} (left), + * the {@link DagCanvas} recipe graph (center), and a {@link BuilderConfigPane} that opens + * on the right when a column/model is added or a node is clicked — so the canvas stays + * visible while it 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 @@ -33,6 +43,8 @@ import { useSearchParams } from 'react-router-dom'; */ 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 @@ -52,67 +64,118 @@ 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) : [] + // Platform models to populate the model config panel's ModelSelectV2 dropdown and to + // auto-fill a template's seeded models. + const { data: modelsData, isLoading: isLoadingModels } = 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 builder = useJobBuilder(template, modelGroups); + 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 (!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]); + + 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..8fe11d16e1 --- /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' }, + ]); + }); + + 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..79d48ef8f6 --- /dev/null +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.ts @@ -0,0 +1,188 @@ +// 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'; + +/** + * A model config the user has added in the builder. Mirrors the SDK {@link ModelConfig} + * shape: `model` is the platform model URN picked in the `ModelSelectV2` dropdown and + * `inferenceParams` holds the values edited in its params popover. `alias` is the + * identifier a column's `model_alias` field references to generate with this model. + */ +export interface BuilderModel { + /** Builder-unique id (stable across alias edits, used for selection). */ + id: string; + /** Alias columns reference via their `model_alias` field. */ + alias: string; + /** Model identifier / URN (e.g. `workspace/model-name`), from the model dropdown. */ + model: string; + /** Model provider name (e.g. `openai`, or `workspace/provider-name`). */ + provider: string; + /** Inference parameters (temperature, top_p, max_tokens, …), from the params popover. */ + inferenceParams: Partial; +} + +/** The editable fields of a {@link BuilderModel} (everything but its id). */ +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 ''; +}; + +/** + * The first platform model (with its resolved provider) from the model list, used to + * auto-fill a template's model so the recipe can be previewed without picking one by + * hand. Returns null when no models are available. + */ +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 }, + })); + +/** + * A {@link BuilderModel} seeded from a platform model picked in the `ModelSelectV2` + * dropdown, with a unique default alias the user can rename in the config panel. The + * `provider` is resolved from the platform model list (see {@link providerForModel}) so + * the submitted config carries it; inference parameters start empty and are refined in + * the config panel. + */ +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; + } +}; + +/** Validates a proposed model alias; returns an error message, or null if valid. */ +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; +}; + +/** + * Validates every model is ready to submit: unique, non-empty aliases and a chosen model. + * Inference parameters are constrained by the `ModelSelectV2` params popover, so they are + * not re-checked here. Returns one human-readable message per problem, or an empty array + * if all models are valid. + */ +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; +}; + +/** Converts one builder model into the SDK's model config shape. */ +const toModelConfig = (model: BuilderModel): ModelConfig => { + const config: ModelConfig = { alias: model.alias.trim(), model: model.model.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; +}; + +/** + * Builds the `model_configs` for the Data Designer job config. Call {@link validateModels} + * first — this assumes the models are valid and does not re-check them. Returns undefined + * when there are no models so the key is omitted from the config entirely. + */ +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..522a93cc9d --- /dev/null +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/useJobBuilder.ts @@ -0,0 +1,187 @@ +// 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'; + +/** + * State container for the recipe builder: the columns and models that make up the job + * config, their selection/focus state, and the handlers that mutate them. Selecting a + * column and selecting a model are mutually exclusive so only one config panel shows in + * the right pane 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` is the platform model list; it's used to auto-fill a template's seeded + * models (model + provider) once loaded, so a templated recipe can be previewed without + * picking a model by hand. + */ +export const useJobBuilder = ( + template: FilesetTemplate | null, + modelGroups: ModelWorkspaceGroup[] +) => { + // 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); + // Continue numbering after any preloaded template models so ids stay unique. + const nextModelId = useRef(models.length); + // Column and model configs both open in the right pane, so the tabs only swap what + // you're adding, not what you're editing. + const [paletteTab, setPaletteTab] = useState('columns'); + + // Auto-fill template-seeded models once, when the platform model list first loads: each + // seeded model's `model` holds its preferred name (or is empty), which we resolve to a + // real workspace model + provider — preferring the named one, else the first available. + // Runs a single time so it never clobbers a model the user later picks themselves (those + // already carry a provider from the picker and would be skipped regardless). + const autoFilled = useRef(false); + useEffect(() => { + if (autoFilled.current || 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]); + + 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)); + }; + + // Adding a model is driven by the palette's ModelSelectV2: picking a platform model + // creates a config seeded from it (model + resolved provider) and opens it in the right + // pane for alias/param edits. + 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, + }; +}; From 75d8fb8f2271013aa32405ba465f684fb782eb19 Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Fri, 10 Jul 2026 17:10:20 -0700 Subject: [PATCH 2/8] fix comments Signed-off-by: Sean Teramae --- .../src/components/AddModelPalette/index.tsx | 22 ------- .../components/CreateFilesetStart/types.ts | 9 +-- .../src/components/ModelConfigPanel/index.tsx | 15 +---- .../BuilderConfigPane.tsx | 4 -- .../BuilderDetailsPanel.tsx | 9 --- .../BuilderPalette.tsx | 5 +- .../BuilderToolbar.tsx | 5 -- .../DataDesignerJobBuildRoute/columns.ts | 61 +------------------ .../DataDesignerJobBuildRoute/index.tsx | 15 +---- .../DataDesignerJobBuildRoute/models.ts | 41 ++----------- .../useJobBuilder.ts | 13 +--- 11 files changed, 17 insertions(+), 182 deletions(-) diff --git a/web/packages/studio/src/components/AddModelPalette/index.tsx b/web/packages/studio/src/components/AddModelPalette/index.tsx index 5b4bc120aa..6cd5fbbb4c 100644 --- a/web/packages/studio/src/components/AddModelPalette/index.tsx +++ b/web/packages/studio/src/components/AddModelPalette/index.tsx @@ -14,35 +14,14 @@ import { Cpu } from 'lucide-react'; import type { FC } from 'react'; export interface AddModelPaletteProps { - /** The models currently configured for this job. */ models: BuilderModel[]; - /** The model currently open for editing, if any. */ selectedId?: string | null; - /** Platform models to choose from, grouped by workspace. */ modelGroups: ModelWorkspaceGroup[]; - /** Whether the platform model list is still loading. */ isLoadingModels?: boolean; - /** - * Adds a model config seeded from the picked platform model (with its resolved provider) - * and opens it for editing. - */ onAddModel: (selection: ModelSelection, provider: string) => void; - /** Opens an existing model config for editing. */ onSelectModel: (id: string) => void; className?: string; } - -/** - * "Models" palette for the Data Designer recipe builder — the sibling of the - * {@link AddColumnPalette} behind the aside's segmented control. - * - * A {@link ModelSelectV2} at the top adds a model to the job config: picking a platform - * model appends a config seeded from it and opens it in the right-hand config panel. - * Existing models are listed below as keyboard-activatable {@link SelectableCard}s - * (matching the column palette's look); activating one selects it for editing. The models - * it lists are the same ones an LLM column's `model_alias` field references, so they live - * in — and submit as part of — the one job config. - */ export const AddModelPalette: FC = ({ models, selectedId, @@ -60,7 +39,6 @@ export const AddModelPalette: FC = ({ - {/* Picking a model adds it to the job config; the selector resets to its placeholder. */}
; } -/** - * One model a template preloads into the job config so its LLM columns have something to - * generate with out of the box. `alias` must match the `model_alias` its columns - * reference. `model` is an optional preferred URN; when omitted (or not present in the - * workspace) the build route auto-fills the first available model so the recipe can be - * previewed immediately. Resolved to a `BuilderModel` by the build route. - */ +/** 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; diff --git a/web/packages/studio/src/components/ModelConfigPanel/index.tsx b/web/packages/studio/src/components/ModelConfigPanel/index.tsx index c34c73e740..fb59a16b7b 100644 --- a/web/packages/studio/src/components/ModelConfigPanel/index.tsx +++ b/web/packages/studio/src/components/ModelConfigPanel/index.tsx @@ -17,30 +17,17 @@ import { Cpu, Trash2, X } from 'lucide-react'; import type { FC } from 'react'; export interface ModelConfigPanelProps { - /** The model currently being edited. */ model: BuilderModel; /** Aliases used by other models, for the uniqueness check. */ takenAliases: Set; - /** Platform models to choose from, grouped by workspace. */ modelGroups: ModelWorkspaceGroup[]; - /** Whether the model list is still loading. */ isLoadingModels?: boolean; - /** Fired live as the user edits any field. */ onChange: (patch: BuilderModelPatch) => void; - /** Removes this model from the config. */ onRemove: () => void; - /** Closes the panel (deselects the model). */ onClose: () => void; } -/** - * Right-hand config panel for the selected model on the build canvas — the sibling of - * {@link ColumnConfigPanel}, sharing its inline (non-overlay) layout and live-edit model. - * - * The model and its inference parameters are chosen through {@link ModelSelectV2} (the - * shared platform model picker), so this stays consistent with the rest of Studio, - * alongside the `alias` that LLM columns reference. - */ +/** Right-hand config panel for a model — sibling of ColumnConfigPanel, same inline layout. */ export const ModelConfigPanel: FC = ({ model, takenAliases, diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderConfigPane.tsx b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderConfigPane.tsx index 4c03531116..6c2a101797 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderConfigPane.tsx +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderConfigPane.tsx @@ -27,10 +27,6 @@ export interface BuilderConfigPaneProps { onModelClose: () => void; } -/** - * The right pane: the config panel for whichever of a column or model is selected (they - * are mutually exclusive), or a hint to select/add something when nothing is selected. - */ export const BuilderConfigPane: FC = ({ selectedColumn, selectedModel, diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderDetailsPanel.tsx b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderDetailsPanel.tsx index 3727537c21..0f0f566758 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderDetailsPanel.tsx +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderDetailsPanel.tsx @@ -7,21 +7,12 @@ import { ChevronDown, ChevronRight } from 'lucide-react'; import type { FC } from 'react'; export interface BuilderDetailsPanelProps { - /** Validation issues collected on the last validate/preview/submit attempt. */ validationErrors: string[]; - /** Message from a failed job creation, if any. */ submitError: string | null; - /** Raw preview run logs; empty until a preview has been run. */ previewLogs: string; - /** Whether the panel body is expanded. */ isOpen: boolean; onToggle: () => void; } - -/** - * The collapsible strip below the toolbar that surfaces validation issues, job-creation - * errors, and preview logs. Renders nothing when there is nothing to show. - */ export const BuilderDetailsPanel: FC = ({ validationErrors, submitError, diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderPalette.tsx b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderPalette.tsx index b637671e95..1f5a9a6cbe 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderPalette.tsx +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderPalette.tsx @@ -23,10 +23,7 @@ export interface BuilderPaletteProps { onSelectModel: (id: string | null) => void; } -/** - * The left aside: a Columns/Models segmented control over the matching palette. The tabs - * only swap what you're adding — column and model configs both open in the right pane. - */ +// Tabs only swap what you're adding — column and model configs both open in the right pane. export const BuilderPalette: FC = ({ tab, onTabChange, diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderToolbar.tsx b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderToolbar.tsx index 116172127a..35805a962e 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderToolbar.tsx +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderToolbar.tsx @@ -24,11 +24,6 @@ export interface BuilderToolbarProps { isSubmitting: boolean; } -/** - * The dark toolbar strip above the canvas and side panels: fileset identity on the - * left (name, template badge, column count), run controls on the right (rows, - * validate, preview, create). - */ export const BuilderToolbar: FC = ({ name, onNameChange, diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.ts b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.ts index 5c98305c91..99eb8ac2cb 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.ts +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.ts @@ -44,18 +44,12 @@ export interface ColumnField { list?: boolean; } -/** - * 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; } @@ -111,10 +105,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': [ @@ -217,11 +207,7 @@ const FIELDS_BY_COLUMN_TYPE: Record, ColumnF ], }; -/** - * Sampler sub-type-specific fields, collected into the sampler config's required `params` - * object (see `SamplerColumnConfig`). Only the sub-types with builder-editable params are - * listed; others fall back to an empty `params` object. - */ +// Sub-type params are nested under the SDK's required `params` key (see toSamplerConfig). const PARAM_FIELDS_BY_SAMPLER_TYPE: Partial> = { [SamplerType.category]: [ { @@ -240,10 +226,6 @@ const PARAM_FIELDS_BY_SAMPLER_TYPE: Partial> const getSamplerParamFields = (samplerType: SamplerType | undefined): ColumnField[] => samplerType ? (PARAM_FIELDS_BY_SAMPLER_TYPE[samplerType] ?? []) : []; -/** - * Returns the config fields for a column option (excluding the always-present `name`). - * For sampler columns, the sub-type's `params` fields precede the shared sampler fields. - */ export const getColumnFields = ( option: Pick ): ColumnField[] => { @@ -265,10 +247,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( @@ -280,11 +258,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 @@ -307,11 +281,6 @@ 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) => { @@ -336,11 +305,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])); @@ -371,11 +335,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++) { @@ -394,12 +353,6 @@ export const validateColumnName = (name: string, takenNames: Set): strin return null; }; -/** - * Validates every column is ready to submit: unique, well-formed names; every field - * marked `required` in {@link getColumnFields} filled in; and JSON-shaped fields (e.g. - * `output_format`) parse. Returns one human-readable message per problem found, or an - * empty array if the recipe is submittable. - */ export const validateColumns = (columns: BuilderColumn[]): string[] => { if (columns.length === 0) return ['Add at least one column before creating the job.']; @@ -460,7 +413,6 @@ const toSamplerConfig = (column: BuilderColumn): Record => { return config; }; -/** Converts one builder column's string field values into the SDK's column config shape. */ const toColumnConfig = (column: BuilderColumn): Record => { if (column.option.columnType === 'sampler') return toSamplerConfig(column); @@ -483,13 +435,6 @@ const toColumnConfig = (column: BuilderColumn): Record => { return config; }; -/** - * Builds the Data Designer job config from the canvas columns and the configured models, - * ready to submit via `useDataDesignerCreateJob`. Columns reference models by their alias - * (the `model_alias` field), so the two live in the same config. Call - * {@link validateColumns} / {@link validateModels} first — this assumes both are valid and - * does not re-check them. - */ export const buildDataDesignerConfig = ( columns: BuilderColumn[], models: BuilderModel[] = [] diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/index.tsx b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/index.tsx index d0beeac29d..f822a9bd33 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/index.tsx +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/index.tsx @@ -32,14 +32,8 @@ import { useAuth } from 'react-oidc-context'; import { useNavigate, useSearchParams } from 'react-router-dom'; /** - * The "Build from scratch" column builder. Composes the {@link BuilderPalette} (left), - * the {@link DagCanvas} recipe graph (center), and a {@link BuilderConfigPane} that opens - * on the right when a column/model is added or a node is clicked — so the canvas stays - * visible while it 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(); @@ -47,8 +41,7 @@ export const DataDesignerJobBuildRoute: FC = () => { 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; @@ -64,8 +57,6 @@ export const DataDesignerJobBuildRoute: FC = () => { ], }); - // Platform models to populate the model config panel's ModelSelectV2 dropdown and to - // auto-fill a template's seeded models. const { data: modelsData, isLoading: isLoadingModels } = useAllModels({ workspace }); const modelGroups = useMemo( () => diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.ts b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.ts index 79d48ef8f6..9e94c41d07 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.ts +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.ts @@ -11,26 +11,16 @@ import type { import type { InferenceParams } from '@nemo/sdk/generated/platform/schema'; import type { TemplateModelSpec } from '@studio/components/CreateFilesetStart/types'; -/** - * A model config the user has added in the builder. Mirrors the SDK {@link ModelConfig} - * shape: `model` is the platform model URN picked in the `ModelSelectV2` dropdown and - * `inferenceParams` holds the values edited in its params popover. `alias` is the - * identifier a column's `model_alias` field references to generate with this model. - */ +/** Mirrors the SDK ModelConfig shape; `alias` is what LLM columns reference via `model_alias`. */ export interface BuilderModel { - /** Builder-unique id (stable across alias edits, used for selection). */ + /** Canvas-unique id (stable across alias edits, used for selection). */ id: string; - /** Alias columns reference via their `model_alias` field. */ alias: string; - /** Model identifier / URN (e.g. `workspace/model-name`), from the model dropdown. */ model: string; - /** Model provider name (e.g. `openai`, or `workspace/provider-name`). */ provider: string; - /** Inference parameters (temperature, top_p, max_tokens, …), from the params popover. */ inferenceParams: Partial; } -/** The editable fields of a {@link BuilderModel} (everything but its id). */ export type BuilderModelPatch = Partial>; /** @@ -49,11 +39,7 @@ export const providerForModel = (modelGroups: ModelWorkspaceGroup[], model: stri return ''; }; -/** - * The first platform model (with its resolved provider) from the model list, used to - * auto-fill a template's model so the recipe can be previewed without picking one by - * hand. Returns null when no models are available. - */ +/** First platform model (with resolved provider), used to auto-fill a template's model. */ export const firstAvailableModel = ( modelGroups: ModelWorkspaceGroup[] ): { model: string; provider: string } | null => { @@ -107,13 +93,6 @@ export const buildModelsFromTemplate = ( inferenceParams: { ...spec.inferenceParams }, })); -/** - * A {@link BuilderModel} seeded from a platform model picked in the `ModelSelectV2` - * dropdown, with a unique default alias the user can rename in the config panel. The - * `provider` is resolved from the platform model list (see {@link providerForModel}) so - * the submitted config carries it; inference parameters start empty and are refined in - * the config panel. - */ export const builderModelFromSelection = ( id: string, selection: ModelSelection, @@ -135,7 +114,6 @@ export const defaultModelAlias = (takenAliases: Set): string => { } }; -/** Validates a proposed model alias; returns an error message, or null if valid. */ export const validateModelAlias = (alias: string, takenAliases: Set): string | null => { const trimmed = alias.trim(); if (!trimmed) return 'Alias is required.'; @@ -143,12 +121,6 @@ export const validateModelAlias = (alias: string, takenAliases: Set): st return null; }; -/** - * Validates every model is ready to submit: unique, non-empty aliases and a chosen model. - * Inference parameters are constrained by the `ModelSelectV2` params popover, so they are - * not re-checked here. Returns one human-readable message per problem, or an empty array - * if all models are valid. - */ export const validateModels = (models: BuilderModel[]): string[] => { const errors: string[] = []; for (const model of models) { @@ -163,7 +135,6 @@ export const validateModels = (models: BuilderModel[]): string[] => { return errors; }; -/** Converts one builder model into the SDK's model config shape. */ const toModelConfig = (model: BuilderModel): ModelConfig => { const config: ModelConfig = { alias: model.alias.trim(), model: model.model.trim() }; if (model.provider.trim()) config.provider = model.provider.trim(); @@ -179,10 +150,6 @@ const toModelConfig = (model: BuilderModel): ModelConfig => { return config; }; -/** - * Builds the `model_configs` for the Data Designer job config. Call {@link validateModels} - * first — this assumes the models are valid and does not re-check them. Returns undefined - * when there are no models so the key is omitted from the config entirely. - */ +/** 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 index 522a93cc9d..49b554e622 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/useJobBuilder.ts +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/useJobBuilder.ts @@ -25,17 +25,13 @@ import { useEffect, useMemo, useRef, useState } from 'react'; export type PaletteTab = 'columns' | 'models'; /** - * State container for the recipe builder: the columns and models that make up the job - * config, their selection/focus state, and the handlers that mutate them. Selecting a - * column and selecting a model are mutually exclusive so only one config panel shows in - * the right pane at a time. + * 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` is the platform model list; it's used to auto-fill a template's seeded - * models (model + provider) once loaded, so a templated recipe can be previewed without - * picking a model by hand. + * `modelGroups` auto-fills a template's seeded models once the platform model list loads. */ export const useJobBuilder = ( template: FilesetTemplate | null, @@ -137,9 +133,6 @@ export const useJobBuilder = ( setSelectedId((current) => (current === id ? null : current)); }; - // Adding a model is driven by the palette's ModelSelectV2: picking a platform model - // creates a config seeded from it (model + resolved provider) and opens it in the right - // pane for alias/param edits. const handleAddModel = (selection: ModelSelection, provider: string) => { const id = `model-${nextModelId.current++}`; setModels((prev) => [ From 3265d2a1c2aacb04a69fac66d1245fa36d92274a Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Mon, 13 Jul 2026 09:11:12 -0700 Subject: [PATCH 3/8] PR coderabbit Signed-off-by: Sean Teramae --- .../BuilderToolbar.tsx | 4 +- .../DataDesignerJobBuildRoute/columns.test.ts | 47 +++ .../DataDesignerJobBuildRoute/columns.ts | 319 +++++++++++++++++- .../DataDesignerJobBuildRoute/index.tsx | 15 +- .../useJobBuilder.ts | 19 +- 5 files changed, 385 insertions(+), 19 deletions(-) diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderToolbar.tsx b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderToolbar.tsx index 35805a962e..b6e17695e0 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderToolbar.tsx +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderToolbar.tsx @@ -75,7 +75,9 @@ export const BuilderToolbar: FC = ({ {templateTag.label} )} - · + + · + {columnCount} {columnCount === 1 ? 'column' : 'columns'} diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.test.ts b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.test.ts index c6b04b448d..0653fe8432 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.test.ts +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.test.ts @@ -182,6 +182,53 @@ describe('sampler columns', () => { 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', () => { diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.ts b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.ts index 99eb8ac2cb..cd66a12382 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.ts +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.ts @@ -42,6 +42,12 @@ export interface ColumnField { * `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'; } /** Not yet the SDK column config — that's produced by {@link buildDataDesignerConfig}. */ @@ -207,8 +213,41 @@ const FIELDS_BY_COLUMN_TYPE: Record, ColumnF ], }; +const BOOL_OPTIONS = [ + { label: 'Yes', value: 'true' }, + { label: 'No', value: 'false' }, +] as const; + // Sub-type params are nested under the SDK's required `params` key (see toSamplerConfig). +// Every sampler sub-type exposed by COLUMN_TYPE_GROUPS has an entry here so its required +// params serialize with the right shape; sub-types with only optional params (e.g. uuid, +// person) still list their fields so users can configure them. 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', @@ -220,6 +259,245 @@ const PARAM_FIELDS_BY_SAMPLER_TYPE: Partial> 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). */ @@ -353,6 +631,15 @@ export const validateColumnName = (name: string, takenNames: Set): strin 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.']; @@ -371,12 +658,15 @@ export const validateColumns = (columns: BuilderColumn[]): string[] => { errors.push(`${label}: ${field.label} is required.`); continue; } - if (field.key === 'output_format' && value) { - try { - JSON.parse(value); - } catch { - errors.push(`${label}: ${field.label} must be valid JSON.`); - } + 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.`); } } } @@ -390,6 +680,21 @@ const splitList = (value: string): string[] => .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. @@ -399,7 +704,7 @@ const toSamplerConfig = (column: BuilderColumn): Record => { for (const field of getSamplerParamFields(column.option.samplerType)) { const value = column.values[field.key]?.trim(); if (!value) continue; - params[field.key] = field.list ? splitList(value) : value; + params[field.key] = serializeFieldValue(field, value); } const config: Record = { diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/index.tsx b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/index.tsx index f822a9bd33..e43b28e9ea 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/index.tsx +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/index.tsx @@ -57,7 +57,12 @@ export const DataDesignerJobBuildRoute: FC = () => { ], }); - const { data: modelsData, isLoading: isLoadingModels } = useAllModels({ workspace }); + const { + data: modelsData, + isLoading: isLoadingModels, + hasNextPage, + isFetchingNextPage, + } = useAllModels({ workspace }); const modelGroups = useMemo( () => groupModelsByWorkspace(modelsData?.pages.flatMap((page) => page.data ?? []) ?? [], { @@ -65,8 +70,9 @@ export const DataDesignerJobBuildRoute: FC = () => { }), [modelsData?.pages] ); + const modelsSettled = !isLoadingModels && !hasNextPage && !isFetchingNextPage; - const builder = useJobBuilder(template, modelGroups); + const builder = useJobBuilder(template, modelGroups, modelsSettled); const { columns, models } = builder; const [name, setName] = useState(() => template?.id ?? 'untitled-dataset'); @@ -79,13 +85,16 @@ export const DataDesignerJobBuildRoute: FC = () => { 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]); + }, [columns, models, rows, name]); const getCurrentConfig = useCallback( () => diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/useJobBuilder.ts b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/useJobBuilder.ts index 49b554e622..7b150e3db3 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/useJobBuilder.ts +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/useJobBuilder.ts @@ -32,10 +32,12 @@ export type PaletteTab = 'columns' | 'models'; * 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[] + 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. @@ -60,14 +62,15 @@ export const useJobBuilder = ( // you're adding, not what you're editing. const [paletteTab, setPaletteTab] = useState('columns'); - // Auto-fill template-seeded models once, when the platform model list first loads: each - // seeded model's `model` holds its preferred name (or is empty), which we resolve to a - // real workspace model + provider — preferring the named one, else the first available. - // Runs a single time so it never clobbers a model the user later picks themselves (those - // already carry a provider from the picker and would be skipped regardless). + // Auto-fill template-seeded models once, when the full platform model list has loaded: + // each seeded model's `model` holds its preferred name (or is empty), which we resolve to + // a real workspace model + provider — preferring the named one, else the first available. + // Gated on `modelsSettled` so a partial (still-paginating) list can't lock in the fallback + // before a preferred model on a later page arrives. Runs a single time so it never clobbers + // a model the user later picks themselves (those already carry a provider and are skipped). const autoFilled = useRef(false); useEffect(() => { - if (autoFilled.current || modelGroups.length === 0) return; + if (autoFilled.current || !modelsSettled || modelGroups.length === 0) return; autoFilled.current = true; setModels((prev) => { let changed = false; @@ -79,7 +82,7 @@ export const useJobBuilder = ( }); return changed ? next : prev; }); - }, [modelGroups]); + }, [modelGroups, modelsSettled]); const selectedColumn = columns.find((column) => column.id === selectedId) ?? null; const selectedModel = models.find((model) => model.id === selectedModelId) ?? null; From 0dff41886288120ce99cb7d156e8e39a67f5fbed Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Mon, 13 Jul 2026 09:20:38 -0700 Subject: [PATCH 4/8] cleanup comments Signed-off-by: Sean Teramae --- .../studio/src/components/CreateFilesetStart/templates.ts | 3 --- web/packages/studio/src/components/ModelConfigPanel/index.tsx | 3 --- .../studio/src/routes/DataDesignerJobBuildRoute/columns.ts | 4 ---- 3 files changed, 10 deletions(-) diff --git a/web/packages/studio/src/components/CreateFilesetStart/templates.ts b/web/packages/studio/src/components/CreateFilesetStart/templates.ts index 52abfbd595..7633416f80 100644 --- a/web/packages/studio/src/components/CreateFilesetStart/templates.ts +++ b/web/packages/studio/src/components/CreateFilesetStart/templates.ts @@ -48,9 +48,6 @@ export const FILESET_TEMPLATES: FilesetTemplate[] = [ }, }, ], - // The `default` alias the columns above reference. Prefers the Nemotron build model - // when it's in the workspace; the build route falls back to the first available model - // otherwise, so the recipe can be previewed immediately either way. models: [{ alias: 'default', model: DEFAULT_BUILD_MODEL_NAME }], }, ]; diff --git a/web/packages/studio/src/components/ModelConfigPanel/index.tsx b/web/packages/studio/src/components/ModelConfigPanel/index.tsx index fb59a16b7b..4506f9d5eb 100644 --- a/web/packages/studio/src/components/ModelConfigPanel/index.tsx +++ b/web/packages/studio/src/components/ModelConfigPanel/index.tsx @@ -18,7 +18,6 @@ import type { FC } from 'react'; export interface ModelConfigPanelProps { model: BuilderModel; - /** Aliases used by other models, for the uniqueness check. */ takenAliases: Set; modelGroups: ModelWorkspaceGroup[]; isLoadingModels?: boolean; @@ -40,8 +39,6 @@ export const ModelConfigPanel: FC = ({ const aliasError = validateModelAlias(model.alias, takenAliases); const modelValue: ModelSelection | null = model.model ? { model: model.model } : null; - // Switching the model also re-resolves its provider so the submitted config keeps them - // in sync (Data Designer requires an explicit provider per model). const handleModelChange = (selection: ModelSelection) => onChange({ model: selection.model, provider: providerForModel(modelGroups, selection.model) }); const handleParamsChange = (params: Partial) => diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.ts b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.ts index cd66a12382..144b14133b 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.ts +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.ts @@ -218,10 +218,6 @@ const BOOL_OPTIONS = [ { label: 'No', value: 'false' }, ] as const; -// Sub-type params are nested under the SDK's required `params` key (see toSamplerConfig). -// Every sampler sub-type exposed by COLUMN_TYPE_GROUPS has an entry here so its required -// params serialize with the right shape; sub-types with only optional params (e.g. uuid, -// person) still list their fields so users can configure them. const PARAM_FIELDS_BY_SAMPLER_TYPE: Partial> = { [SamplerType.uuid]: [ { From 9fc245f0834259245cf4b575630d916c16834bcf Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Mon, 13 Jul 2026 09:21:28 -0700 Subject: [PATCH 5/8] cleanup comments Signed-off-by: Sean Teramae --- .../routes/DataDesignerJobBuildRoute/useJobBuilder.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/useJobBuilder.ts b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/useJobBuilder.ts index 7b150e3db3..bfb6d3db27 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/useJobBuilder.ts +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/useJobBuilder.ts @@ -56,18 +56,9 @@ export const useJobBuilder = ( buildModelsFromTemplate(template?.models) ); const [selectedModelId, setSelectedModelId] = useState(null); - // Continue numbering after any preloaded template models so ids stay unique. const nextModelId = useRef(models.length); - // Column and model configs both open in the right pane, so the tabs only swap what - // you're adding, not what you're editing. const [paletteTab, setPaletteTab] = useState('columns'); - // Auto-fill template-seeded models once, when the full platform model list has loaded: - // each seeded model's `model` holds its preferred name (or is empty), which we resolve to - // a real workspace model + provider — preferring the named one, else the first available. - // Gated on `modelsSettled` so a partial (still-paginating) list can't lock in the fallback - // before a preferred model on a later page arrives. Runs a single time so it never clobbers - // a model the user later picks themselves (those already carry a provider and are skipped). const autoFilled = useRef(false); useEffect(() => { if (autoFilled.current || !modelsSettled || modelGroups.length === 0) return; From b042a48f6c8191359567d21730a594c23d46825e Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Tue, 14 Jul 2026 12:31:29 -0700 Subject: [PATCH 6/8] fix type Signed-off-by: Sean Teramae --- .../studio/src/routes/DataDesignerJobBuildRoute/models.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.ts b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.ts index 9e94c41d07..c8fcd28b7a 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.ts +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.ts @@ -136,7 +136,11 @@ export const validateModels = (models: BuilderModel[]): string[] => { }; const toModelConfig = (model: BuilderModel): ModelConfig => { - const config: ModelConfig = { alias: model.alias.trim(), model: model.model.trim() }; + 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; From 8eff0091a88ba2d0f7911547bbee03c47de54c69 Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Tue, 14 Jul 2026 12:38:12 -0700 Subject: [PATCH 7/8] try to trigger ci Signed-off-by: Sean Teramae --- .../src/components/AddModelPalette/AddModelPalette.stories.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/packages/studio/src/components/AddModelPalette/AddModelPalette.stories.tsx b/web/packages/studio/src/components/AddModelPalette/AddModelPalette.stories.tsx index bd9c2da4e4..5bcc287424 100644 --- a/web/packages/studio/src/components/AddModelPalette/AddModelPalette.stories.tsx +++ b/web/packages/studio/src/components/AddModelPalette/AddModelPalette.stories.tsx @@ -50,7 +50,6 @@ export const WithModels: Story = { args: { models, selectedId: 'model-1' }, }; -/** The empty state, before any model is added. */ export const Empty: Story = { args: { models: [], selectedId: null }, }; From c6bb9ddbffb616981ee921c6cdad3098253545fd Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Tue, 14 Jul 2026 13:45:05 -0700 Subject: [PATCH 8/8] fix test Signed-off-by: Sean Teramae --- .../studio/src/routes/DataDesignerJobBuildRoute/models.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.test.ts b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.test.ts index 8fe11d16e1..06fb4a1b00 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.test.ts +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.test.ts @@ -169,7 +169,7 @@ describe('buildModelConfigs', () => { it('omits empty optional fields and inference parameters', () => { expect(buildModelConfigs([model({ provider: '' })])).toEqual([ - { alias: 'default', model: 'openai/gpt-4o-mini' }, + { alias: 'default', model: 'openai/gpt-4o-mini', provider: '' }, ]); });