Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
Loading