diff --git a/web/packages/common/src/components/LogViewer/index.tsx b/web/packages/common/src/components/LogViewer/index.tsx index 2fffe068c1f..6456d3638b6 100644 --- a/web/packages/common/src/components/LogViewer/index.tsx +++ b/web/packages/common/src/components/LogViewer/index.tsx @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { useStickToBottom } from '@nemo/common/src/hooks/useStickToBottom'; import { triggerDownload } from '@nemo/common/src/utils/file'; import { formatLogs } from '@nemo/common/src/utils/logs'; import type { PlatformJobLog } from '@nemo/sdk/generated/platform/schema'; @@ -15,7 +16,7 @@ import { } from '@nvidia/foundations-react-core'; import classNames from 'classnames'; import { ArrowUp, Download } from 'lucide-react'; -import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { FC, useMemo, useState } from 'react'; const DEFAULT_ROW_COUNT = 30; @@ -34,79 +35,30 @@ export const LogViewer: FC = ({ rows = DEFAULT_ROW_COUNT, emptyMessage = 'No logs available yet', }) => { - const codeScrollRef = useRef(null); const [showAllLogs, setShowAllLogs] = useState(false); - const shouldAutoScrollRef = useRef(true); const tailLogs = logs?.slice(-rows) || []; const displayedLogs = showAllLogs ? logs : tailLogs; const logText = formatLogs(displayedLogs); const hasMoreLogs = logs && logs.length > rows; + const isShowingLogs = useMemo(() => logs.length > 0 && !isLoading, [logs.length, isLoading]); + + const { ref: codeScrollRef, scrollToBottom } = useStickToBottom({ + enabled: isShowingLogs, + resetKey: showAllLogs, + }); + const handleDownload = () => { if (downloadFilename) { triggerDownload(formatLogs(logs), downloadFilename); } }; - const scrollToBottomNow = useCallback(() => { - const codeElement = codeScrollRef.current; - if (codeElement) { - const maxScrollTop = codeElement.scrollHeight - codeElement.clientHeight; - codeElement.scrollTop = maxScrollTop; - } - }, []); - const handleLoadMore = () => { - shouldAutoScrollRef.current = true; + scrollToBottom(); setShowAllLogs(true); }; - const isShowingLogs = useMemo(() => logs.length > 0 && !isLoading, [logs.length, isLoading]); - - // Watch for actual DOM changes (catches CodeSnippet internal rendering) - useEffect(() => { - if (!isShowingLogs) return; - const codeElement = codeScrollRef.current; - if (!codeElement) return; - - const mutationObserver = new MutationObserver(() => { - if (shouldAutoScrollRef.current) { - scrollToBottomNow(); - } - }); - - mutationObserver.observe(codeElement, { - childList: true, - subtree: true, - characterData: true, - }); - - return () => { - mutationObserver.disconnect(); - }; - }, [scrollToBottomNow, isShowingLogs, showAllLogs]); - - // Track if user scrolls away from bottom - useEffect(() => { - if (!isShowingLogs) return; - const codeElement = codeScrollRef.current; - if (!codeElement) return; - - const handleScroll = () => { - const threshold = 50; - const isAtBottom = - Math.abs(codeElement.scrollHeight - codeElement.clientHeight - codeElement.scrollTop) < - threshold; - shouldAutoScrollRef.current = isAtBottom; - }; - - codeElement.addEventListener('scroll', handleScroll); - - return () => { - codeElement.removeEventListener('scroll', handleScroll); - }; - }, [isShowingLogs, showAllLogs]); - if (isLoading) { return ; } diff --git a/web/packages/common/src/hooks/useStickToBottom/index.test.tsx b/web/packages/common/src/hooks/useStickToBottom/index.test.tsx new file mode 100644 index 00000000000..12b2aebc6cb --- /dev/null +++ b/web/packages/common/src/hooks/useStickToBottom/index.test.tsx @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useStickToBottom } from '@nemo/common/src/hooks/useStickToBottom'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FC } from 'react'; + +// jsdom has no layout: scrollHeight/clientHeight report 0 and scrollTop is a no-op. +// Fake the geometry and back scrollTop with a real stored value so assignments stick. +const SCROLL_HEIGHT = 1000; +const CLIENT_HEIGHT = 100; +const MAX_SCROLL_TOP = SCROLL_HEIGHT - CLIENT_HEIGHT; + +const mockGeometry = (element: HTMLElement) => { + let scrollTop = 0; + Object.defineProperty(element, 'scrollHeight', { configurable: true, value: SCROLL_HEIGHT }); + Object.defineProperty(element, 'clientHeight', { configurable: true, value: CLIENT_HEIGHT }); + Object.defineProperty(element, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = value; + }, + }); +}; + +const Harness: FC<{ enabled?: boolean; attached?: boolean }> = ({ enabled, attached = true }) => { + const { ref, scrollToBottom } = useStickToBottom({ enabled }); + return ( + <> + + {attached &&
} + + ); +}; + +it('scrollToBottom() jumps the container to the bottom', async () => { + const user = userEvent.setup(); + render(); + const scroll = screen.getByTestId('scroll'); + mockGeometry(scroll); + expect(scroll.scrollTop).toBe(0); + + await user.click(screen.getByRole('button', { name: 'scroll' })); + + expect(scroll.scrollTop).toBe(MAX_SCROLL_TOP); +}); + +it('scrollToBottom() does not throw before the element is attached', async () => { + const user = userEvent.setup(); + render(); + + await expect(user.click(screen.getByRole('button', { name: 'scroll' }))).resolves.not.toThrow(); +}); diff --git a/web/packages/common/src/hooks/useStickToBottom/index.ts b/web/packages/common/src/hooks/useStickToBottom/index.ts new file mode 100644 index 00000000000..0de16f402f8 --- /dev/null +++ b/web/packages/common/src/hooks/useStickToBottom/index.ts @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { RefObject, useCallback, useEffect, useRef } from 'react'; + +interface UseStickToBottomOptions { + /** When false, the scroll listener and observer are detached (e.g. while loading). */ + enabled?: boolean; + /** Distance (px) from the bottom that still counts as "at the bottom". */ + threshold?: number; + /** + * Changing this re-attaches the observers and re-arms auto-scroll — use it when the + * scroll container's content is swapped out (e.g. toggling "show all" vs "tail"). + */ + resetKey?: unknown; +} + +interface UseStickToBottom { + /** Attach to the scrollable element (or the element whose content grows). */ + ref: RefObject; + /** Jump to the bottom now and re-arm auto-scroll so future growth stays pinned. */ + scrollToBottom: () => void; +} + +/** + * Keeps a scroll container pinned to the bottom as content streams in, but only while + * the user is already at the bottom. If the user scrolls up, auto-scroll pauses until + * they scroll back down (within `threshold`). + * + * Growth is detected with a MutationObserver so it also catches async content (e.g. a + * CodeSnippet that re-renders highlighted text after the value prop changes). + */ +export function useStickToBottom({ + enabled = true, + threshold = 50, + resetKey, +}: UseStickToBottomOptions = {}): UseStickToBottom { + const ref = useRef(null); + const shouldAutoScrollRef = useRef(true); + + const scrollToBottom = useCallback(() => { + shouldAutoScrollRef.current = true; + const element = ref.current; + if (element) { + element.scrollTop = element.scrollHeight - element.clientHeight; + } + }, []); + + // Pin to the bottom whenever content changes and the user is at the bottom. + useEffect(() => { + if (!enabled) return; + const element = ref.current; + if (!element) return; + + shouldAutoScrollRef.current = true; + element.scrollTop = element.scrollHeight - element.clientHeight; + + const observer = new MutationObserver(() => { + if (shouldAutoScrollRef.current) { + element.scrollTop = element.scrollHeight - element.clientHeight; + } + }); + + observer.observe(element, { childList: true, subtree: true, characterData: true }); + + return () => observer.disconnect(); + }, [enabled, resetKey]); + + // Track whether the user is at the bottom so we can pause/resume auto-scroll. + useEffect(() => { + if (!enabled) return; + const element = ref.current; + if (!element) return; + + const handleScroll = () => { + const distanceFromBottom = element.scrollHeight - element.clientHeight - element.scrollTop; + shouldAutoScrollRef.current = Math.abs(distanceFromBottom) < threshold; + }; + + element.addEventListener('scroll', handleScroll); + + return () => element.removeEventListener('scroll', handleScroll); + }, [enabled, threshold, resetKey]); + + return { ref, scrollToBottom }; +} diff --git a/web/packages/studio/src/components/AddModelPalette/AddModelPalette.stories.tsx b/web/packages/studio/src/components/AddModelPalette/AddModelPalette.stories.tsx index 5bcc2874241..87764a58b86 100644 --- a/web/packages/studio/src/components/AddModelPalette/AddModelPalette.stories.tsx +++ b/web/packages/studio/src/components/AddModelPalette/AddModelPalette.stories.tsx @@ -28,18 +28,20 @@ const meta = { export default meta; type Story = StoryObj; +const NEMOTRON_MODEL = 'nvidia/llama-3.3-nemotron-super-49b-v1'; + const models: BuilderModel[] = [ { id: 'model-0', alias: 'default', - model: 'openai/gpt-4o-mini', - provider: 'openai', + model: NEMOTRON_MODEL, + provider: 'nvidia', inferenceParams: { temperature: 0.7 }, }, { id: 'model-1', alias: 'judge', - model: 'meta/llama-3.1-70b-instruct', + model: 'nvidia/llama-3.1-nemotron-70b-instruct', provider: 'nvidia', inferenceParams: { temperature: 0, max_tokens: 1024 }, }, diff --git a/web/packages/studio/src/components/CreateFilesetStart/templates.ts b/web/packages/studio/src/components/CreateFilesetStart/templates.ts index 6183e860d4d..10c1746a425 100644 --- a/web/packages/studio/src/components/CreateFilesetStart/templates.ts +++ b/web/packages/studio/src/components/CreateFilesetStart/templates.ts @@ -3,7 +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 { DEFAULT_BUILD_MODEL_NAME, DEFAULT_EMBEDDER_MODEL_NAME } from '@studio/constants/constants'; import { Braces, Code2, @@ -284,6 +284,8 @@ export const FILESET_TEMPLATES: FilesetTemplate[] = [ prompt: 'On a scale of 1–5 (1 = very poor, 5 = excellent), rate the quality of the following answer.\n\nQuestion: {{ instruction }}\nAnswer: {{ chosen }}\n\nReturn only the integer score.', model_alias: 'default', + scores: + '[{ "name": "Quality", "description": "Overall answer quality.", "options": { "1": "Very poor", "5": "Excellent" } }]', }, }, ], @@ -334,7 +336,15 @@ export const FILESET_TEMPLATES: FilesetTemplate[] = [ ], models: [ { alias: 'default', model: DEFAULT_BUILD_MODEL_NAME }, - { alias: 'embedder', model: 'nvidia/nv-embedqa-e5-v5' }, + { + alias: 'embedder', + model: DEFAULT_EMBEDDER_MODEL_NAME, + inferenceParams: { + generation_type: 'embedding', + encoding_format: 'float', + extra_body: { input_type: 'passage', truncate: 'NONE' }, + }, + }, ], }, { diff --git a/web/packages/studio/src/components/dataViews/DataDesignerJobsDataView/index.tsx b/web/packages/studio/src/components/dataViews/DataDesignerJobsDataView/index.tsx index c089344b361..15aed5657b8 100644 --- a/web/packages/studio/src/components/dataViews/DataDesignerJobsDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/DataDesignerJobsDataView/index.tsx @@ -26,6 +26,7 @@ import type { import { Banner, Button, Text } from '@nvidia/foundations-react-core'; import { BulkDeleteModal } from '@studio/components/BulkDeleteModal'; import { DataDesignerJobActionsMenu } from '@studio/components/DataDesignerJobActionsMenu'; +import { DataDesignerIconFc } from '@studio/constants/constants'; import { STATUS_FILTER_OPTIONS } from '@studio/constants/platformJobs'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { getDataDesignerJobDetailsRoute, getNewDataDesignerJobRoute } from '@studio/routes/utils'; @@ -230,6 +231,7 @@ export const DataDesignerJobsDataView: FC = () => { /> ) : ( } header="Data Designer Jobs" emptyMessage="Create and manage data designer jobs to generate or transform datasets." actions={ diff --git a/web/packages/studio/src/constants/constants.ts b/web/packages/studio/src/constants/constants.ts index 97363e02d8f..03ab75ead7c 100644 --- a/web/packages/studio/src/constants/constants.ts +++ b/web/packages/studio/src/constants/constants.ts @@ -10,6 +10,8 @@ * its affiliates is strictly prohibited. */ +import { Palette } from 'lucide-react'; + export const CHAT_DEFAULT_MAX_TOKENS = 4096; export const DEFAULT_LARGE_PAGE_SIZE = 1000; export const DATASET_NAME_REGEX = /^[a-zA-Z0-9._-]+$/; @@ -20,6 +22,7 @@ export const DEFAULT_TOOLS_FILE_NAME = 'tools.json'; export const EMPTY_FIELD_VALUE = '-'; export const EMPTY_FIELD_EMDASH_VALUE = '—'; export const DEFAULT_BUILD_MODEL_NAME = 'nvidia-llama-3-3-nemotron-super-49b-v1'; +export const DEFAULT_EMBEDDER_MODEL_NAME = 'nvidia-nv-embedqa-e5-v5'; export const KNOWN_TEXT_EXTENSIONS = new Set([ // Data @@ -76,3 +79,5 @@ export const KNOWN_TEXT_EXTENSIONS = new Set([ 'dockerfile', 'makefile', ]); + +export const DataDesignerIconFc = Palette; diff --git a/web/packages/studio/src/mocks/evaluation/configs.ts b/web/packages/studio/src/mocks/evaluation/configs.ts index abd2915ed63..13c14b5f19a 100644 --- a/web/packages/studio/src/mocks/evaluation/configs.ts +++ b/web/packages/studio/src/mocks/evaluation/configs.ts @@ -322,7 +322,7 @@ export const mockEvalConfigRag = { version_id: '', api_endpoint: { url: 'http://nemo-embedding-ms.nemo-retrieval.svc.cluster.local:8080/v1/embeddings', - model_id: 'nvidia/nv-embedqa-e5-v5', + model_id: 'nvidia-nv-embedqa-e5-v5', format: 'nim', }, }, diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderDetailsPanel.tsx b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderDetailsPanel.tsx index 0f0f5667580..6b196e98275 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderDetailsPanel.tsx +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/BuilderDetailsPanel.tsx @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { useStickToBottom } from '@nemo/common/src/hooks/useStickToBottom'; import { Banner, Button, CodeSnippet, Flex, Stack } from '@nvidia/foundations-react-core'; import { formatPreviewLogsForDisplay } from '@studio/components/NewDataDesignerJobForm/previewApi'; import { ChevronDown, ChevronRight } from 'lucide-react'; @@ -20,6 +21,10 @@ export const BuilderDetailsPanel: FC = ({ isOpen, onToggle, }) => { + const { ref: logsScrollRef } = useStickToBottom({ + enabled: isOpen && !!previewLogs, + }); + const hasDetails = validationErrors.length > 0 || !!submitError || !!previewLogs; if (!hasDetails) return null; @@ -68,7 +73,7 @@ export const BuilderDetailsPanel: FC = ({ value={formatPreviewLogsForDisplay(previewLogs)} language="json" kind="block" - attributes={{ CodeSnippetCode: { className: 'max-h-[240px]' } }} + attributes={{ CodeSnippetCode: { ref: logsScrollRef, className: 'max-h-[240px]' } }} /> )} diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.test.ts b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.test.ts index fa5aefb83f7..34700b8eebd 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.test.ts +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.test.ts @@ -345,6 +345,79 @@ describe('sampler-showcase template', () => { }); }); +describe('llm-judge columns', () => { + const scores = + '[{ "name": "Quality", "description": "Overall answer quality.", "options": { "1": "Very poor", "5": "Excellent" } }]'; + + it('serializes required judge score definitions', () => { + const columns = [ + column('a', 'quality_score', 'llm-judge', { + prompt: 'Rate {{ answer }}', + model_alias: 'default', + scores, + }), + ]; + + expect(buildDataDesignerConfig(columns).columns[0]).toMatchObject({ + name: 'quality_score', + column_type: 'llm-judge', + prompt: 'Rate {{ answer }}', + model_alias: 'default', + scores: [ + { + name: 'Quality', + description: 'Overall answer quality.', + options: { '1': 'Very poor', '5': 'Excellent' }, + }, + ], + }); + }); + + it('requires valid score JSON before submit', () => { + expect( + validateColumns([ + column('a', 'quality_score', 'llm-judge', { + prompt: 'Rate {{ answer }}', + model_alias: 'default', + }), + ]) + ).toContainEqual(expect.stringContaining('Scores (JSON) is required')); + + expect( + validateColumns([ + column('a', 'quality_score', 'llm-judge', { + prompt: 'Rate {{ answer }}', + model_alias: 'default', + scores: '{ bad', + }), + ]) + ).toContainEqual(expect.stringContaining('Scores (JSON) must be valid JSON')); + }); +}); + +describe('preference-pairs template', () => { + const template = FILESET_TEMPLATES.find((t) => t.id === 'preference-pairs'); + if (!template) throw new Error('preference-pairs template is missing'); + + it('builds a valid llm-judge column with scores', () => { + const columns = buildColumnsFromTemplate(template.columns); + + expect(validateColumns(columns)).toEqual([]); + expect(buildDataDesignerConfig(columns).columns).toContainEqual( + expect.objectContaining({ + name: 'quality_score', + column_type: 'llm-judge', + scores: [ + expect.objectContaining({ + name: 'Quality', + options: expect.objectContaining({ '1': 'Very poor', '5': 'Excellent' }), + }), + ], + }) + ); + }); +}); + 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 873c0191cdb..a8a9ebc2e2c 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.ts +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/columns.ts @@ -156,7 +156,21 @@ const FIELDS_BY_COLUMN_TYPE: Record, ColumnF }, SYSTEM_PROMPT_FIELD, ], - 'llm-judge': [PROMPT_FIELD, MODEL_ALIAS_FIELD, SYSTEM_PROMPT_FIELD], + 'llm-judge': [ + PROMPT_FIELD, + MODEL_ALIAS_FIELD, + { + key: 'scores', + label: 'Scores (JSON)', + kind: 'textarea', + dataType: 'json', + required: true, + placeholder: + '[{ "name": "Quality", "description": "Overall answer quality.", "options": { "1": "Very poor", "5": "Excellent" } }]', + helperText: 'JSON array of judge score definitions.', + }, + SYSTEM_PROMPT_FIELD, + ], image: [ { ...PROMPT_FIELD, placeholder: 'Generate an image of {{ subject }}.' }, MODEL_ALIAS_FIELD, @@ -683,11 +697,15 @@ export const validateColumnName = (name: string, takenNames: Set): strin const fieldValueError = (field: ColumnField, value: string): string | null => { const isJson = field.dataType === 'json' || field.key === 'output_format'; if (isJson) { + let parsed: unknown; try { - JSON.parse(value); + parsed = JSON.parse(value); } catch { return `${field.label} must be valid JSON.`; } + if (field.key === 'scores' && !Array.isArray(parsed)) { + return `${field.label} must be a JSON array.`; + } return null; } if (field.dataType === 'number' && !Number.isFinite(Number(value))) { diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.test.ts b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.test.ts index fb79d190336..e09243effa0 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.test.ts +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.test.ts @@ -21,8 +21,8 @@ import { const model = (overrides: Partial = {}): BuilderModel => ({ id: 'model-0', alias: 'default', - model: 'openai/gpt-4o-mini', - provider: 'openai', + model: 'nvidia/llama-3.3-nemotron-super-49b-v1', + provider: 'nvidia', inferenceParams: {}, ...overrides, }); @@ -39,7 +39,7 @@ describe('providerForModel', () => { { workspace: 'steramae', models: [ - { workspace: 'steramae', name: 'gpt-oss', model_providers: ['steramae/build'] }, + { workspace: 'steramae', name: 'nemotron-oss', model_providers: ['steramae/build'] }, { workspace: 'steramae', name: 'nvidia-llama-3-3-nemotron-super-49b-v1-5', @@ -51,7 +51,7 @@ describe('providerForModel', () => { ] as unknown as ModelWorkspaceGroup[]; it('returns the model’s first provider ref', () => { - expect(providerForModel(groups, 'steramae/gpt-oss')).toBe('steramae/build'); + expect(providerForModel(groups, 'steramae/nemotron-oss')).toBe('steramae/build'); }); it('returns empty string when the model or its provider is missing', () => { @@ -61,7 +61,7 @@ describe('providerForModel', () => { it('firstAvailableModel picks the first model and its provider', () => { expect(firstAvailableModel(groups)).toEqual({ - model: 'steramae/gpt-oss', + model: 'steramae/nemotron-oss', provider: 'steramae/build', }); expect(firstAvailableModel([])).toBeNull(); @@ -75,15 +75,15 @@ describe('providerForModel', () => { }); it('resolveTemplateModel matches a full URN too', () => { - expect(resolveTemplateModel(groups, 'steramae/gpt-oss')).toEqual({ - model: 'steramae/gpt-oss', + expect(resolveTemplateModel(groups, 'steramae/nemotron-oss')).toEqual({ + model: 'steramae/nemotron-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', + model: 'steramae/nemotron-oss', provider: 'steramae/build', }); expect(resolveTemplateModel([], 'anything')).toBeNull(); @@ -100,7 +100,7 @@ describe('buildServedModelNames / modelIdForModel', () => { model_entity_id: 'steramae/nvidia-llama-3-3-nemotron-super-49b-v1-5', served_model_name: 'nvidia/llama-3.3-nemotron-super-49b-v1.5', }, - { model_entity_id: 'steramae/gpt-oss', served_model_name: 'openai/gpt-oss-120b' }, + { model_entity_id: 'steramae/nemotron-oss', served_model_name: 'nvidia/nemotron-oss-120b' }, ], }, { @@ -121,7 +121,7 @@ describe('buildServedModelNames / modelIdForModel', () => { expect(names.get('steramae/nvidia-llama-3-3-nemotron-super-49b-v1-5')).toBe( 'nvidia/llama-3.3-nemotron-super-49b-v1.5' ); - expect(names.get('steramae/gpt-oss')).toBe('openai/gpt-oss-120b'); + expect(names.get('steramae/nemotron-oss')).toBe('nvidia/nemotron-oss-120b'); }); it('modelIdForModel resolves the URN to the served model name', () => { @@ -166,14 +166,14 @@ describe('builderModelFromSelection', () => { expect( builderModelFromSelection( 'model-5', - { model: 'openai/gpt-4o-mini' }, + { model: 'nvidia/llama-3.3-nemotron-super-49b-v1' }, 'default/nvidia-build', new Set(['model_1']) ) ).toEqual({ id: 'model-5', alias: 'model_2', - model: 'openai/gpt-4o-mini', + model: 'nvidia/llama-3.3-nemotron-super-49b-v1', provider: 'default/nvidia-build', inferenceParams: {}, }); @@ -221,7 +221,7 @@ describe('buildModelConfigs', () => { expect(buildModelConfigs([model({ provider: '' })])).toEqual([ { alias: 'default', - model: 'openai/gpt-4o-mini', + model: 'nvidia/llama-3.3-nemotron-super-49b-v1', provider: '', inference_parameters: { generation_type: 'chat-completion', @@ -264,8 +264,8 @@ describe('buildModelConfigs', () => { ).toEqual([ { alias: 'spaced', - model: 'openai/gpt-4o-mini', - provider: 'openai', + model: 'nvidia/llama-3.3-nemotron-super-49b-v1', + provider: 'nvidia', inference_parameters: { generation_type: 'chat-completion', temperature: 0.7, @@ -275,4 +275,32 @@ describe('buildModelConfigs', () => { }, ]); }); + + it('emits embedding inference parameters and forwards extra_body for embedding models', () => { + expect( + buildModelConfigs([ + model({ + alias: 'embedder', + model: 'nvidia/nv-embedqa-e5-v5', + provider: 'steramae/build', + inferenceParams: { + generation_type: 'embedding', + encoding_format: 'float', + extra_body: { input_type: 'query', truncate: 'NONE' }, + }, + }), + ]) + ).toEqual([ + { + alias: 'embedder', + model: 'nvidia/nv-embedqa-e5-v5', + provider: 'steramae/build', + inference_parameters: { + generation_type: 'embedding', + encoding_format: 'float', + extra_body: { input_type: 'query', truncate: 'NONE' }, + }, + }, + ]); + }); }); diff --git a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.ts b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.ts index 9d2c85d9e98..3b09565c199 100644 --- a/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.ts +++ b/web/packages/studio/src/routes/DataDesignerJobBuildRoute/models.ts @@ -7,6 +7,8 @@ import { MAX_COMPLETION_TOKENS_DEFAULT } from '@nemo/common/src/constants/infere import { getURNFromNamedEntityRef } from '@nemo/common/src/namedEntity'; import type { ChatCompletionInferenceParams, + EmbeddingInferenceParams, + EmbeddingInferenceParamsExtraBody, ModelConfig, } from '@nemo/sdk/generated/data-designer/schema'; import type { InferenceParams, ModelProvider } from '@nemo/sdk/generated/platform/schema'; @@ -155,22 +157,47 @@ export const validateModels = (models: BuilderModel[]): string[] => { return errors; }; -const toModelConfig = (model: BuilderModel, servedModelNames: Map): ModelConfig => { - const config: ModelConfig = { - alias: model.alias.trim(), - model: modelIdForModel(servedModelNames, model.model.trim()), - provider: model.provider.trim(), - }; - if (model.provider.trim()) config.provider = model.provider.trim(); +/** + * Maps a builder model's params to the SDK's discriminated inference-parameter union. A + * `generation_type: 'embedding'` marker (set by embedding-model templates) routes to + * {@link EmbeddingInferenceParams} so embedding-only fields — notably `extra_body`, which + * carries the `input_type` that NVIDIA retrieval-QA embedders require — reach the request. + * Everything else defaults to chat-completion. + */ +const toInferenceParameters = ( + params: Partial +): ChatCompletionInferenceParams | EmbeddingInferenceParams => { + if (params.generation_type === 'embedding') { + const inference: EmbeddingInferenceParams = { generation_type: 'embedding' }; + const { encoding_format, extra_body, dimensions } = params; + if (encoding_format === 'float' || encoding_format === 'base64') { + inference.encoding_format = encoding_format; + } + if (extra_body && typeof extra_body === 'object') { + inference.extra_body = extra_body as EmbeddingInferenceParamsExtraBody; + } + if (typeof dimensions === 'number') inference.dimensions = dimensions; + return inference; + } - const { temperature, top_p, max_tokens } = model.inferenceParams; + const { temperature, top_p, max_tokens } = params; const inference: ChatCompletionInferenceParams = { generation_type: 'chat-completion', max_tokens: max_tokens ?? MAX_COMPLETION_TOKENS_DEFAULT, }; if (temperature !== undefined) inference.temperature = temperature; if (top_p !== undefined) inference.top_p = top_p; - config.inference_parameters = inference; + return inference; +}; + +const toModelConfig = (model: BuilderModel, servedModelNames: Map): ModelConfig => { + const config: ModelConfig = { + alias: model.alias.trim(), + model: modelIdForModel(servedModelNames, model.model.trim()), + provider: model.provider.trim(), + }; + if (model.provider.trim()) config.provider = model.provider.trim(); + config.inference_parameters = toInferenceParameters(model.inferenceParams); return config; }; diff --git a/web/packages/studio/src/routes/SafeSynthesizerJobDetailsRoute/components/JobDetailsPanel.test.tsx b/web/packages/studio/src/routes/SafeSynthesizerJobDetailsRoute/components/JobDetailsPanel.test.tsx index 519cb82db37..cd20d4aa3f0 100644 --- a/web/packages/studio/src/routes/SafeSynthesizerJobDetailsRoute/components/JobDetailsPanel.test.tsx +++ b/web/packages/studio/src/routes/SafeSynthesizerJobDetailsRoute/components/JobDetailsPanel.test.tsx @@ -128,6 +128,7 @@ vi.mock('lucide-react', () => ({ File: () => , Cog: () => , Copy: () => , + Palette: () => , })); // Test wrapper diff --git a/web/packages/studio/src/routes/WorkspaceLayout/WorkspaceSideNav.tsx b/web/packages/studio/src/routes/WorkspaceLayout/WorkspaceSideNav.tsx index febb9a77e95..e0259cf788e 100644 --- a/web/packages/studio/src/routes/WorkspaceLayout/WorkspaceSideNav.tsx +++ b/web/packages/studio/src/routes/WorkspaceLayout/WorkspaceSideNav.tsx @@ -3,6 +3,7 @@ import SafeSynthesizerLogo from '@nemo/common/src/svgs/safe_synthesizer_logo.svg?react'; import { NavigationDrawer } from '@studio/components/Layouts/NavigationDrawer'; +import { DataDesignerIconFc } from '@studio/constants/constants'; import { AGENTS_ENABLED, BASE_MODELS_ENABLED, @@ -50,7 +51,6 @@ import { ChartBar, Database, HatGlasses, - LayoutList, ListChecks, Home, ShieldCheck, @@ -150,7 +150,7 @@ export const WorkspaceSideNav = ({ collapsed }: { collapsed?: boolean }) => { ? [ { id: 'data-designer', - slotIcon: , + slotIcon: , slotLabel: 'Data Designer', href: getDataDesignerJobListRoute(workspace), },