Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web/packages/studio/src/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
10 changes: 10 additions & 0 deletions web/packages/studio/src/mocks/handlers/guardrails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GuardrailConfig>;
Object.assign(config, body);
return HttpResponse.json(config);
}
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
http.delete(
`${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs/:name`,
() => new HttpResponse(null, { status: 200 })
Expand Down
18 changes: 17 additions & 1 deletion web/packages/studio/src/routes/groups/guardrailsRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand All @@ -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,
Expand All @@ -29,5 +35,15 @@ export const guardrailsRoutes: RouteObject[] = gateGuardrailsRoutes([
path: ROUTES.workspace.guardrailDetail,
element: <GuardrailDetailRoute />,
errorElement: <ErrorPanel title="Guardrails" />,
children: [
{
index: true,
element: <Navigate to="config" replace />,
},
{
path: ROUTES.workspace.guardrailConfig,
element: <GuardrailConfigTab />,
},
],
},
]);
Original file line number Diff line number Diff line change
@@ -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 (
<Panel
slotHeading="Behavior &amp; operations"
slotIcon={<SlidersHorizontal />}
elevation="high"
density="compact"
>
<Stack gap="density-md">
<FieldList fields={behaviorFields(data)} />
<FieldList fields={tracingFields(data)} />
{captureEnabled ? (
<Flex align="center" gap="density-sm">
<Badge color="yellow" kind="solid">
Content capture on
</Badge>
<Text kind="body/regular/xs" className="text-text-secondary">
Prompts and responses are recorded in traces and may include PII.
</Text>
</Flex>
) : null}
</Stack>
</Panel>
);
};
Original file line number Diff line number Diff line change
@@ -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 (
<Panel slotHeading="Detectors" slotIcon={<ScanSearch />} elevation="high" density="compact">
{detectors.length === 0 ? (
<EmptyText>No detectors configured.</EmptyText>
) : (
<AccordionRoot multiple>
{detectors.map((key) => {
const meta = detectorMeta(key);
const scopes = deriveScopes(rails, key);
const fields = summarizeDetector(config[key as DetectorKey]);
return (
<AccordionItem key={key} value={key}>
<AccordionTrigger>
<Flex align="center" justify="between" gap="density-md" className="w-full">
<Flex align="center" gap="density-sm" wrap="wrap">
<Text kind="label/bold/md">{meta.label}</Text>
<Badge color={meta.firstParty ? 'green' : 'gray'} kind="outline">
{meta.firstParty ? 'NVIDIA' : 'Third-party'}
</Badge>
</Flex>
<ScopeBadges scopes={scopes} />
</Flex>
</AccordionTrigger>
<AccordionContent>
{fields.length ? (
<FieldList fields={fields} />
) : (
<EmptyText>Enabled with default settings.</EmptyText>
)}
</AccordionContent>
</AccordionItem>
);
})}
</AccordionRoot>
)}
</Panel>
);
};
Original file line number Diff line number Diff line change
@@ -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<GuardrailFormValues>();

return (
<Panel
slotHeading="General"
slotIcon={<MessageSquareText />}
elevation="high"
density="compact"
>
<Stack gap="density-lg">
{FIELDS.map((field, index) => (
<Fragment key={field.name}>
{index > 0 ? <Divider /> : null}
<FormField
slotLabel={field.label}
slotHelp={field.description}
status={errors[field.name] ? 'error' : undefined}
slotError={errors[field.name]?.message}
>
{/*
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.
*/}
<TextArea
resizeable="auto"
className="w-full"
placeholder={field.placeholder}
{...register(field.name)}
/>
</FormField>
</Fragment>
))}
</Stack>
</Panel>
);
};
Loading