diff --git a/web/packages/studio/src/constants/routes.ts b/web/packages/studio/src/constants/routes.ts index 530be044c8..1e7bd2a2e9 100644 --- a/web/packages/studio/src/constants/routes.ts +++ b/web/packages/studio/src/constants/routes.ts @@ -111,6 +111,7 @@ export const ROUTES = { secrets: `/workspaces/:${P.workspace}/secrets`, guardrails: `/workspaces/:${P.workspace}/guardrails`, guardrailDetail: `/workspaces/:${P.workspace}/guardrails/:${P.guardrailConfigName}`, + guardrailConfig: `/workspaces/:${P.workspace}/guardrails/:${P.guardrailConfigName}/config`, settings: `/workspaces/:${P.workspace}/settings`, /** Workspace members and role-based access (Entities role bindings) */ members: `/workspaces/:${P.workspace}/members`, diff --git a/web/packages/studio/src/mocks/handlers/guardrails.ts b/web/packages/studio/src/mocks/handlers/guardrails.ts index 4e98f09706..c1ffe47607 100644 --- a/web/packages/studio/src/mocks/handlers/guardrails.ts +++ b/web/packages/studio/src/mocks/handlers/guardrails.ts @@ -70,6 +70,16 @@ export const guardrailsHandlers = [ return HttpResponse.json(config); } ), + http.patch( + `${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs/:name`, + async ({ params, request }) => { + const config = mockGuardrailConfigs.find((c) => c.name === params.name); + if (!config) return new HttpResponse(null, { status: 404 }); + const body = (await request.json()) as Partial; + Object.assign(config, body); + return HttpResponse.json(config); + } + ), http.delete( `${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs/:name`, () => new HttpResponse(null, { status: 200 }) diff --git a/web/packages/studio/src/routes/groups/guardrailsRoutes.tsx b/web/packages/studio/src/routes/groups/guardrailsRoutes.tsx index fc9f89d772..9825f7217d 100644 --- a/web/packages/studio/src/routes/groups/guardrailsRoutes.tsx +++ b/web/packages/studio/src/routes/groups/guardrailsRoutes.tsx @@ -5,7 +5,7 @@ import { ErrorPanel } from '@studio/components/ErrorPanel'; import { ROUTES } from '@studio/constants/routes'; import { gateGuardrailsRoutes } from '@studio/routes/utils'; import { lazy } from 'react'; -import type { RouteObject } from 'react-router-dom'; +import { Navigate, type RouteObject } from 'react-router-dom'; const GuardrailsRoute = lazy(() => import('@studio/routes/guardrails/GuardrailsRoute').then((m) => ({ @@ -19,6 +19,12 @@ const GuardrailDetailRoute = lazy(() => })) ); +const GuardrailConfigTab = lazy(() => + import('@studio/routes/guardrails/GuardrailConfigTab').then((m) => ({ + default: m.GuardrailConfigTab, + })) +); + export const guardrailsRoutes: RouteObject[] = gateGuardrailsRoutes([ { path: ROUTES.workspace.guardrails, @@ -29,5 +35,15 @@ export const guardrailsRoutes: RouteObject[] = gateGuardrailsRoutes([ path: ROUTES.workspace.guardrailDetail, element: , errorElement: , + children: [ + { + index: true, + element: , + }, + { + path: ROUTES.workspace.guardrailConfig, + element: , + }, + ], }, ]); diff --git a/web/packages/studio/src/routes/guardrails/GuardrailConfigTab/BehaviorSection.tsx b/web/packages/studio/src/routes/guardrails/GuardrailConfigTab/BehaviorSection.tsx new file mode 100644 index 0000000000..8f28a6804e --- /dev/null +++ b/web/packages/studio/src/routes/guardrails/GuardrailConfigTab/BehaviorSection.tsx @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { RailsConfigOutput } from '@nemo/sdk/generated/platform/schema'; +import { Badge, Flex, Panel, Stack, Text } from '@nvidia/foundations-react-core'; +import { FieldList } from '@studio/routes/guardrails/GuardrailConfigTab/configPrimitives'; +import type { Field } from '@studio/routes/guardrails/GuardrailConfigTab/types'; +import { SlidersHorizontal } from 'lucide-react'; +import type { FC } from 'react'; + +const behaviorFields = (data: RailsConfigOutput | undefined): Field[] => { + const fields: Field[] = []; + if (data?.passthrough != null) { + fields.push({ label: 'Passthrough', value: data.passthrough ? 'On' : 'Off' }); + } + if (data?.enable_rails_exceptions != null) { + fields.push({ + label: 'Rails exceptions', + value: data.enable_rails_exceptions ? 'Raised' : 'Return messages', + }); + } + if (data?.colang_version) fields.push({ label: 'Colang version', value: data.colang_version }); + if (data?.actions_server_url) { + fields.push({ label: 'Actions server', value: data.actions_server_url }); + } + return fields; +}; + +const tracingFields = (data: RailsConfigOutput | undefined): Field[] => { + const tracing = data?.tracing; + if (!tracing) return []; + const fields: Field[] = []; + if (tracing.enabled != null) { + fields.push({ label: 'Tracing', value: tracing.enabled ? 'Enabled' : 'Disabled' }); + } + if (tracing.span_format) fields.push({ label: 'Span format', value: tracing.span_format }); + if (tracing.adapters?.length) { + fields.push({ + label: 'Adapters', + value: tracing.adapters.map((adapter) => adapter.name ?? 'unnamed').join(', '), + }); + } + return fields; +}; + +const captureIsEnabled = (data: RailsConfigOutput | undefined): boolean => + data?.tracing?.enable_content_capture === true; + +const hasBehaviorContent = (data: RailsConfigOutput | undefined): boolean => + behaviorFields(data).length > 0 || tracingFields(data).length > 0 || captureIsEnabled(data); + +export const BehaviorSection: FC<{ data: RailsConfigOutput | undefined }> = ({ data }) => { + if (!hasBehaviorContent(data)) return null; + + const captureEnabled = captureIsEnabled(data); + + return ( + } + elevation="high" + density="compact" + > + + + + {captureEnabled ? ( + + + Content capture on + + + Prompts and responses are recorded in traces and may include PII. + + + ) : null} + + + ); +}; diff --git a/web/packages/studio/src/routes/guardrails/GuardrailConfigTab/DetectorsSection.tsx b/web/packages/studio/src/routes/guardrails/GuardrailConfigTab/DetectorsSection.tsx new file mode 100644 index 0000000000..974a8b3ee6 --- /dev/null +++ b/web/packages/studio/src/routes/guardrails/GuardrailConfigTab/DetectorsSection.tsx @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { RailsConfigDataOutput, RailsOutput } from '@nemo/sdk/generated/platform/schema'; +import { + AccordionContent, + AccordionItem, + AccordionRoot, + AccordionTrigger, + Badge, + Flex, + Panel, + Text, +} from '@nvidia/foundations-react-core'; +import { + EmptyText, + FieldList, + ScopeBadges, +} from '@studio/routes/guardrails/GuardrailConfigTab/configPrimitives'; +import { + detectorMeta, + deriveScopes, + listConfiguredDetectors, + summarizeDetector, +} from '@studio/routes/guardrails/GuardrailConfigTab/detectors'; +import type { DetectorKey } from '@studio/routes/guardrails/GuardrailConfigTab/types'; +import { ScanSearch } from 'lucide-react'; +import type { FC } from 'react'; + +export const DetectorsSection: FC<{ rails: RailsOutput | undefined }> = ({ rails }) => { + const detectors = listConfiguredDetectors(rails); + const config: RailsConfigDataOutput = rails?.config ?? {}; + + return ( + } elevation="high" density="compact"> + {detectors.length === 0 ? ( + No detectors configured. + ) : ( + + {detectors.map((key) => { + const meta = detectorMeta(key); + const scopes = deriveScopes(rails, key); + const fields = summarizeDetector(config[key as DetectorKey]); + return ( + + + + + {meta.label} + + {meta.firstParty ? 'NVIDIA' : 'Third-party'} + + + + + + + {fields.length ? ( + + ) : ( + Enabled with default settings. + )} + + + ); + })} + + )} + + ); +}; diff --git a/web/packages/studio/src/routes/guardrails/GuardrailConfigTab/GeneralSection.tsx b/web/packages/studio/src/routes/guardrails/GuardrailConfigTab/GeneralSection.tsx new file mode 100644 index 0000000000..faa88b4229 --- /dev/null +++ b/web/packages/studio/src/routes/guardrails/GuardrailConfigTab/GeneralSection.tsx @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Divider, FormField, Panel, Stack, TextArea } from '@nvidia/foundations-react-core'; +import type { GuardrailFormValues } from '@studio/routes/guardrails/GuardrailForm/formModel'; +import { MessageSquareText } from 'lucide-react'; +import { type FC, Fragment } from 'react'; +import { useFormContext } from 'react-hook-form'; + +interface GeneralField { + name: keyof GuardrailFormValues; + label: string; + description: string; + placeholder: string; +} + +const FIELDS: GeneralField[] = [ + { + name: 'generalInstruction', + label: 'General instruction', + description: + 'Plain-language guidance the assistant follows. Injected into the guardrail prompts as the base instruction.', + placeholder: + 'e.g. The assistant is a helpful support agent for NVIDIA products. It is polite and never discusses competitors.', + }, + { + name: 'sampleConversation', + label: 'Sample conversation', + description: + 'An example dialogue included in the guardrail prompts to demonstrate the desired tone and format.', + placeholder: 'user: Hi!\nassistant: Hello! How can I help you today?', + }, +]; + +/** + * The "General" panel: free-text fields that shape the guardrail prompts, bound + * to the RHF form model. Their mapping to/from the config schema (e.g. the + * instruction ↔ first `general` entry in `instructions[]`) lives in formModel. + */ +export const GeneralSection: FC = () => { + const { + register, + formState: { errors }, + } = useFormContext(); + + return ( + } + elevation="high" + density="compact" + > + + {FIELDS.map((field, index) => ( + + {index > 0 ? : null} + + {/* + No `rows`: it fights `field-sizing: content` (from resizeable="auto"), + which clips the top and offsets the scrollbar on long values. The base + style's `min-height: 3lh` provides the floor; --max-auto-height the cap. + */} +