diff --git a/web/packages/common/src/constants/query.ts b/web/packages/common/src/constants/query.ts index 8fd8a57f1d..4ea46440b5 100644 --- a/web/packages/common/src/constants/query.ts +++ b/web/packages/common/src/constants/query.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { JobStatus as IJobStatus, PlatformJobStatus } from '@nemo/sdk/generated/platform/schema'; +import { PlatformJobStatus } from '@nemo/sdk/generated/platform/schema'; // Customizer uses Platform SDK status export const CJobCancellableStatuses: PlatformJobStatus[] = [ @@ -16,11 +16,6 @@ export const CJobTerminalStatuses: PlatformJobStatus[] = [ PlatformJobStatus.error, // was 'failed' PlatformJobStatus.cancelled, ]; -export const IJobTerminalStatuses: IJobStatus[] = [ - IJobStatus.completed, - IJobStatus.failed, - IJobStatus.cancelled, -]; export const PlatformJobTerminalStatuses: PlatformJobStatus[] = [ PlatformJobStatus.completed, PlatformJobStatus.cancelled, diff --git a/web/packages/common/src/utils/chat.ts b/web/packages/common/src/utils/chat.ts index 0d75bed77a..35c95043b3 100644 --- a/web/packages/common/src/utils/chat.ts +++ b/web/packages/common/src/utils/chat.ts @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { FlexibleMessage } from '@nemo/sdk/generated/platform/schema'; import { ChatCompletion, ChatCompletionChunk, @@ -71,70 +70,3 @@ export const maybeInsertSystemMessage = ( ...parsedMessages, ]; }; - -/** - * Safely extracts string content from a FlexibleMessage. - * Returns empty string if content is undefined, null, or not a string. - */ -const getStringContent = (content: unknown): string => { - if (typeof content === 'string') return content; - if (content === null || content === undefined) return ''; - // Handle array content (OpenAI multi-part messages) by joining text parts - if (Array.isArray(content)) { - return content - .filter( - (part): part is { type: 'text'; text: string } => - typeof part === 'object' && part?.type === 'text' && typeof part?.text === 'string' - ) - .map((part) => part.text) - .join(' '); - } - return ''; -}; - -/** - * Converts a FlexibleMessage (intake API) to ChatCompletionMessageParam (OpenAI). - * FlexibleMessage is provider-agnostic; this maps it to the OpenAI standard. - */ -export const toOpenAIMessage = (message: FlexibleMessage): ChatCompletionMessageParam => { - const { role, content, name, tool_calls, tool_call_id } = message; - const stringContent = getStringContent(content); - - switch (role) { - case 'user': - return { - role: 'user', - content: stringContent, - ...(typeof name === 'string' && { name }), - }; - case 'assistant': - return { - role: 'assistant', - content: stringContent || null, - ...(Array.isArray(tool_calls) && { tool_calls }), - }; - case 'system': - return { - role: 'system', - content: stringContent, - ...(typeof name === 'string' && { name }), - }; - case 'tool': - return { - role: 'tool', - content: stringContent, - tool_call_id: typeof tool_call_id === 'string' ? tool_call_id : '', - }; - case 'function': - // Map legacy 'function' role to 'tool' for OpenAI compatibility - return { - role: 'tool', - content: stringContent, - tool_call_id: typeof tool_call_id === 'string' ? tool_call_id : '', - }; - case 'developer': - return { role: 'developer', content: stringContent }; - default: - return { role: 'user', content: stringContent }; - } -}; diff --git a/web/packages/common/src/utils/query.ts b/web/packages/common/src/utils/query.ts index d5d57e8759..dfa8587ef9 100644 --- a/web/packages/common/src/utils/query.ts +++ b/web/packages/common/src/utils/query.ts @@ -1,18 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { JobStatus as IJobStatus, PlatformJobStatus } from '@nemo/sdk/generated/platform/schema'; +import { CJobTerminalStatuses } from '@nemo/common/src/constants/query'; +import type { PlatformJobStatus } from '@nemo/sdk/generated/platform/schema'; import * as DataView from '../components/DataView/internal'; import { JOB_POLLING_INTERVAL_MS } from '../constants'; -import { CJobTerminalStatuses, IJobTerminalStatuses } from '../constants/query'; -export const getJobRefetchInterval = (status?: PlatformJobStatus | IJobStatus) => { - if ( - !status || - (!CJobTerminalStatuses.includes(status as PlatformJobStatus) && - !IJobTerminalStatuses.includes(status as IJobStatus)) - ) { +export const getJobRefetchInterval = (status?: PlatformJobStatus): number | false => { + if (!status || !CJobTerminalStatuses.includes(status)) { return JOB_POLLING_INTERVAL_MS; } return false; diff --git a/web/packages/studio/package.json b/web/packages/studio/package.json index e89a8bf2ff..11bd457af4 100644 --- a/web/packages/studio/package.json +++ b/web/packages/studio/package.json @@ -77,7 +77,6 @@ "react-router-dom": "catalog:", "recharts": "catalog:", "seedrandom": "^3.0.5", - "unique-names-generator": "^4.7.1", "use-debounce": "catalog:", "vite": "catalog:", "vite-plugin-mkcert": "catalog:", diff --git a/web/packages/studio/src/api/intake/constants.ts b/web/packages/studio/src/api/intake/constants.ts deleted file mode 100644 index 9498b1990e..0000000000 --- a/web/packages/studio/src/api/intake/constants.ts +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -export const FEEDBACK_CATEGORY_KEY = 'studio_feedback'; -export const FEEDBACK_CATEGORIES = [ - 'Too much information', - 'Not enough information', - 'Not factually correct', - "Didn't fully follow instructions", -]; - -export enum FeedbackAddToDatasetFileSource { - New = 'Create new', - Existing = 'Add to existing', -} diff --git a/web/packages/studio/src/api/intake/utils.ts b/web/packages/studio/src/api/intake/utils.ts deleted file mode 100644 index 0793408a42..0000000000 --- a/web/packages/studio/src/api/intake/utils.ts +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { EntryFilter } from '@nemo/sdk/generated/platform/schema'; -import { QUERY_PARAMETERS } from '@studio/routes/constants'; -import { ChatCompletionMessageParam } from 'openai/resources/index.mjs'; - -/** - * Recursively processes a filter object and adds its properties to URLSearchParams. - * Handles nested objects, arrays with operators (like 'in'), and primitive values. - * - * @param obj - The object to process - * @param params - The URLSearchParams instance to populate - * @param prefix - The current key prefix for nested properties (e.g., 'context', 'created_at') - */ -const processFilterObject = (obj: EntryFilter, params: URLSearchParams, prefix = ''): void => { - for (const [key, value] of Object.entries(obj)) { - // Skip undefined and null values - if (value === undefined || value === null) { - continue; - } - - // Build the parameter key (e.g., 'context.app', 'created_at.gte') - const paramKey = prefix ? `${prefix}.${key}` : key; - - // Handle array values with operators (e.g., {in: ['id1', 'id2']}) - if (Array.isArray(value)) { - value.forEach((item) => { - if (item !== undefined && item !== null) { - params.append(paramKey, String(item)); - } - }); - } - // Handle nested objects recursively - else if (typeof value === 'object') { - processFilterObject(value, params, paramKey); - } - // Handle primitive values (string, number, boolean) - else { - params.set(paramKey, String(value)); - } - } -}; - -export const generateFilterParam = (filter?: EntryFilter): string => { - if (!filter) { - return ''; - } - const params = new URLSearchParams(); - - // Special handling for 'project' field to use QUERY_PARAMETERS constant - if (filter.project && typeof filter.project === 'string') { - params.set(QUERY_PARAMETERS.project, filter.project); - const withoutProject = { ...filter, project: undefined }; - processFilterObject(withoutProject, params); - } else { - processFilterObject(filter, params); - } - - return params.toString(); -}; - -export const isToolCallMessage = (message: ChatCompletionMessageParam) => { - return ( - message.role === 'tool' || - ('tool_calls' in message && Array.isArray(message.tool_calls) && message.tool_calls.length > 0) - ); -}; diff --git a/web/packages/studio/src/components/DatasetFileSelect/constants.ts b/web/packages/studio/src/components/DatasetFileSelect/constants.ts index 2edfa8ec15..d646599b16 100644 --- a/web/packages/studio/src/components/DatasetFileSelect/constants.ts +++ b/web/packages/studio/src/components/DatasetFileSelect/constants.ts @@ -1,10 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { MultiselectOption } from '@studio/constants/mutliselect'; +import type { MultiselectOption } from '@studio/constants/mutliselect'; export const LOADING_FILES_OPTION: MultiselectOption = { label: 'Loading files...', value: 'loading', isDisabled: true, }; + +export enum FeedbackAddToDatasetFileSource { + New = 'Create new', + Existing = 'Add to existing', +} diff --git a/web/packages/studio/src/components/DatasetFileSelect/index.tsx b/web/packages/studio/src/components/DatasetFileSelect/index.tsx index 643eaa3e5d..0ed036ab0b 100644 --- a/web/packages/studio/src/components/DatasetFileSelect/index.tsx +++ b/web/packages/studio/src/components/DatasetFileSelect/index.tsx @@ -15,8 +15,10 @@ import { SelectRoot, SelectTrigger, } from '@nvidia/foundations-react-core'; -import { FeedbackAddToDatasetFileSource } from '@studio/api/intake/constants'; -import { LOADING_FILES_OPTION } from '@studio/components/DatasetFileSelect/constants'; +import { + FeedbackAddToDatasetFileSource, + LOADING_FILES_OPTION, +} from '@studio/components/DatasetFileSelect/constants'; import { MultiselectOption } from '@studio/constants/mutliselect'; import { Plus } from 'lucide-react'; import { FC, ReactNode, useMemo } from 'react'; diff --git a/web/packages/studio/src/components/IntakeAnnotationPanel/index.tsx b/web/packages/studio/src/components/IntakeAnnotationPanel/index.tsx deleted file mode 100644 index 19383f7d84..0000000000 --- a/web/packages/studio/src/components/IntakeAnnotationPanel/index.tsx +++ /dev/null @@ -1,90 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { zodResolver } from '@hookform/resolvers/zod'; -import { LoadingButton } from '@nemo/common/src/components/LoadingButton'; -import { useToast } from '@nemo/common/src/providers/toast/useToast'; -import { Entry } from '@nemo/sdk/generated/platform/schema'; -import { Flex, Panel, Stack } from '@nvidia/foundations-react-core'; -import { AnnotationForm } from '@studio/components/form/AnnotationForm'; -import { AnnotationErrorMessage } from '@studio/components/form/AnnotationForm/AnnotationErrorMessage'; -import { annotationFormFields } from '@studio/components/form/AnnotationForm/constants'; -import { useCreateReviewerAnnotation } from '@studio/components/form/AnnotationForm/useCreateReviewerAnnotation'; -import { formToReviewerAnnotationEvent } from '@studio/components/form/AnnotationForm/utils'; -import { getEntryResponseContent } from '@studio/components/IntakeEntriesTable/utils'; -import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; -import { handleFormErrorsGeneric } from '@studio/util/forms/error'; -import { ComponentProps, FC } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; -import { z } from 'zod'; - -interface Props { - entry: Entry; - attributes?: { - Panel?: ComponentProps; - }; -} - -export const IntakeAnnotationPanel: FC = ({ entry, attributes }) => { - const toast = useToast(); - const workspace = useWorkspaceFromPath(); - const form = useForm>({ - resolver: zodResolver(annotationFormFields), - mode: 'onChange', - values: { - modelResponse: getEntryResponseContent(entry), - }, - }); - - const { mutateAsync: createAnnotation, isPending: isCreatingAnnotation } = - useCreateReviewerAnnotation({ workspace, entryId: entry.id }); - const onSubmit = async (data: z.infer) => { - if (!entry.id) { - toast.error('Cannot create annotation without an existing entry.'); - return; - } - await createAnnotation({ - workspace, - name: entry.id, - data: { events: [formToReviewerAnnotationEvent(data)] }, - }); - }; - - const errorMessage = form.formState.errors.hasChanges?.message ? ( - - ) : undefined; - const flexJustify = errorMessage ? 'between' : 'end'; - - return ( - - - -
- - {errorMessage} - - Submit - - - } - /> - -
-
-
- ); -}; diff --git a/web/packages/studio/src/components/IntakeAnnotationsPanel/index.spec.tsx b/web/packages/studio/src/components/IntakeAnnotationsPanel/index.spec.tsx new file mode 100644 index 0000000000..8512db0280 --- /dev/null +++ b/web/packages/studio/src/components/IntakeAnnotationsPanel/index.spec.tsx @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { IntakeAnnotationsPanel } from '@studio/components/IntakeAnnotationsPanel'; +import { resetMockAnnotations } from '@studio/mocks/intake/telemetry'; +import { renderRoute, screen, waitFor, within } from '@studio/tests/util/render'; +import userEvent from '@testing-library/user-event'; + +const SPAN_ID = 'span-root-001'; +const SESSION_ID = 'session-agent-run-001'; + +describe('IntakeAnnotationsPanel', () => { + beforeEach(() => { + resetMockAnnotations(); + }); + + it('lists and creates span annotations through the generated client', async () => { + const user = userEvent.setup(); + + renderRoute( + , + { history: '/workspaces/default/intake/spans/span-root-001' } + ); + + expect( + await screen.findByText('Good final response, but verify policy citations.') + ).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /Negative/i })); + expect(await screen.findByText('Negative feedback')).toBeInTheDocument(); + + await user.type(screen.getByPlaceholderText('Add a note about this span.'), 'Needs review.'); + await user.click(screen.getByRole('button', { name: /Add Note/i })); + + expect(await screen.findByText('Needs review.')).toBeInTheDocument(); + }); + + it('deletes span annotations through the generated client', async () => { + const user = userEvent.setup(); + + renderRoute( + , + { history: '/workspaces/default/intake/spans/span-root-001' } + ); + + const note = await screen.findByRole('article', { name: 'Note annotation' }); + await user.click(within(note).getByRole('button', { name: /Delete/i })); + + await waitFor(() => { + expect( + screen.queryByText('Good final response, but verify policy citations.') + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/web/packages/studio/src/components/IntakeAnnotationsPanel/index.tsx b/web/packages/studio/src/components/IntakeAnnotationsPanel/index.tsx new file mode 100644 index 0000000000..8ddaca6bf3 --- /dev/null +++ b/web/packages/studio/src/components/IntakeAnnotationsPanel/index.tsx @@ -0,0 +1,319 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { zodResolver } from '@hookform/resolvers/zod'; +import { formatAbsoluteTimestamp } from '@nemo/common/src/components/RelativeTime/util'; +import { + getListAnnotationsQueryKey, + useCreateAnnotation, + useDeleteAnnotation, + useListAnnotations, +} from '@nemo/sdk/generated/platform/api'; +import { + AnnotationSortField, + FeedbackAnnotationInputKind, + FeedbackAnnotationInputValue, + NoteAnnotationInputKind, + type Annotation, + type FeedbackAnnotationInputValue as FeedbackAnnotationInputValueType, +} from '@nemo/sdk/generated/platform/schema'; +import { + Button, + CodeSnippet, + Flex, + FormField, + Panel, + Stack, + Text, + TextArea, +} from '@nvidia/foundations-react-core'; +import { getErrorMessage } from '@studio/api/common/utils'; +import { ThumbButton } from '@studio/components/buttons/ThumbButton'; +import { useQueryClient } from '@tanstack/react-query'; +import { MessageSquarePlus, NotebookPen, Trash2 } from 'lucide-react'; +import { type FC, useMemo, useState } from 'react'; +import { type SubmitHandler, useForm } from 'react-hook-form'; +import { z } from 'zod'; + +const noteSchema = z.object({ + text: z.string().trim().min(1, 'Note is required.'), +}); + +type NoteFormValues = z.infer; + +const getAnnotationErrorMessage = (error: unknown, fallback: string): string => + error instanceof Error ? getErrorMessage(error, fallback) : fallback; + +const formatAnnotationTitle = (annotation: Annotation): string => { + switch (annotation.kind) { + case 'feedback': + return annotation.value === 'positive' ? 'Positive feedback' : 'Negative feedback'; + case 'note': + return 'Note'; + case 'label': + return annotation.name ? `Label: ${annotation.name}` : 'Label'; + case 'metadata': + return 'Metadata'; + } +}; + +const renderAnnotationBody = (annotation: Annotation) => { + switch (annotation.kind) { + case 'feedback': + return ( + + {annotation.value} + + ); + case 'note': + return ( + + {annotation.text} + + ); + case 'label': + return ( + + {String(annotation.value)} + + ); + case 'metadata': + return ( + + ); + } +}; + +export interface IntakeAnnotationsPanelProps { + workspace: string; + spanId: string; + sessionId: string; +} + +export const IntakeAnnotationsPanel: FC = ({ + workspace, + spanId, + sessionId, +}) => { + const queryClient = useQueryClient(); + const [mutationError, setMutationError] = useState(); + const { + register, + handleSubmit, + reset, + watch, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(noteSchema), + defaultValues: { text: '' }, + mode: 'onChange', + }); + + const listParams = useMemo( + () => ({ + page: 1, + page_size: 100, + sort: AnnotationSortField['-created_at'], + filter: { + span_id: spanId, + }, + }), + [spanId] + ); + + const { + data: annotationsResponse, + error: listError, + isLoading, + } = useListAnnotations(workspace, listParams); + const createAnnotation = useCreateAnnotation(); + const deleteAnnotation = useDeleteAnnotation(); + + const noteText = watch('text'); + const annotations = annotationsResponse?.data ?? []; + const activeFeedback = annotations.find((annotation) => annotation.kind === 'feedback'); + const isMutating = createAnnotation.isPending || deleteAnnotation.isPending; + + const refreshAnnotations = async (): Promise => { + await queryClient.invalidateQueries({ + queryKey: getListAnnotationsQueryKey(workspace), + }); + }; + + const handleFeedback = async (value: FeedbackAnnotationInputValueType): Promise => { + setMutationError(undefined); + try { + await createAnnotation.mutateAsync({ + workspace, + data: { + kind: FeedbackAnnotationInputKind.feedback, + value, + session_id: sessionId, + span_id: spanId, + }, + }); + await refreshAnnotations(); + } catch (error) { + setMutationError(getAnnotationErrorMessage(error, 'Failed to save feedback.')); + } + }; + + const handleNoteSubmit: SubmitHandler = async ({ text }) => { + setMutationError(undefined); + try { + await createAnnotation.mutateAsync({ + workspace, + data: { + kind: NoteAnnotationInputKind.note, + text, + session_id: sessionId, + span_id: spanId, + }, + }); + reset(); + await refreshAnnotations(); + } catch (error) { + setMutationError(getAnnotationErrorMessage(error, 'Failed to save note.')); + } + }; + + const handleDelete = async (annotationId: string): Promise => { + setMutationError(undefined); + try { + await deleteAnnotation.mutateAsync({ + workspace, + annotationId, + }); + await refreshAnnotations(); + } catch (error) { + setMutationError(getAnnotationErrorMessage(error, 'Failed to delete annotation.')); + } + }; + + return ( + } + slotHeading="Annotations" + className="min-w-0 overflow-hidden" + > + + + Feedback + + void handleFeedback(FeedbackAnnotationInputValue.positive)} + > + Positive + + void handleFeedback(FeedbackAnnotationInputValue.negative)} + > + Negative + + + + +
void handleSubmit(handleNoteSubmit)(event)}> + + +