diff --git a/web/packages/common/src/components/ChatCompletionInput/index.tsx b/web/packages/common/src/components/ChatCompletionInput/index.tsx index 84fbab3b1d..45f1c9c4b6 100644 --- a/web/packages/common/src/components/ChatCompletionInput/index.tsx +++ b/web/packages/common/src/components/ChatCompletionInput/index.tsx @@ -206,6 +206,9 @@ export function ChatCompletionInput { + if (typeof content === 'string') { + return content; + } + if (Array.isArray(content)) { + return content + .map((part) => + part && typeof part === 'object' && 'text' in part + ? String((part as { text?: unknown }).text ?? '') + : '' + ) + .join(' ') + .trim(); + } + return ''; +}; + +/** First user message text — the check's "Input" (single-turn). */ +export const getCheckInputText = (messages: GuardrailCheckMessage[]): string => { + const userMsg = messages.find((m) => m.role === 'user'); + return userMsg ? textFromContent(userMsg.content) : ''; +}; + +/** First assistant message text — the check's "Output" (single-turn). */ +export const getCheckOutputText = (messages: GuardrailCheckMessage[]): string => { + const assistantMsg = messages.find((m) => m.role === 'assistant'); + return assistantMsg ? textFromContent(assistantMsg.content) : ''; +}; diff --git a/web/packages/studio/src/components/dataViews/GuardrailChecksDataView/checkStatus.ts b/web/packages/studio/src/components/dataViews/GuardrailChecksDataView/checkStatus.ts new file mode 100644 index 0000000000..1f683886fc --- /dev/null +++ b/web/packages/studio/src/components/dataViews/GuardrailChecksDataView/checkStatus.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { GuardrailCheckEntity, Verdict } from '@studio/api/guardrail-checks/types'; + +/** + * Overall status of a check's most recent run (the `status` returned by the + * /checks endpoint). `undefined` means the check has never been run. + */ +export const getLatestRunStatus = (check: GuardrailCheckEntity): Verdict | undefined => { + const { runs } = check.data; + return runs.length ? runs[runs.length - 1].status : undefined; +}; diff --git a/web/packages/studio/src/constants/routes.ts b/web/packages/studio/src/constants/routes.ts index 0966b2a26d..d32e63bc63 100644 --- a/web/packages/studio/src/constants/routes.ts +++ b/web/packages/studio/src/constants/routes.ts @@ -119,6 +119,7 @@ export const ROUTES = { optimizerInsight: `/workspaces/:${P.workspace}/optimizer/:${P.insightId}`, guardrailDetail: `/workspaces/:${P.workspace}/guardrails/:${P.guardrailConfigName}`, guardrailConfig: `/workspaces/:${P.workspace}/guardrails/:${P.guardrailConfigName}/config`, + guardrailChecks: `/workspaces/:${P.workspace}/guardrails/:${P.guardrailConfigName}/checks`, 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 6f81e0e504..2ba97b9137 100644 --- a/web/packages/studio/src/mocks/handlers/guardrails.ts +++ b/web/packages/studio/src/mocks/handlers/guardrails.ts @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import type { GuardrailConfig } from '@nemo/sdk/generated/platform/schema'; +import { + GUARDRAIL_CHECKS_ENTITY_TYPE, + type GuardrailCheckEntity, +} from '@studio/api/guardrail-checks/types'; import { PLATFORM_BASE_URL } from '@studio/constants/environment'; import { http, HttpResponse } from 'msw'; @@ -51,7 +55,64 @@ export const mockGuardrailConfigs: GuardrailConfig[] = [ }, ]; +/** Guardrail checks are entity-store children of a config; these hang off `cfg-1` (pii-filter). */ +export const mockGuardrailChecks: GuardrailCheckEntity[] = [ + { + entity_type: GUARDRAIL_CHECKS_ENTITY_TYPE, + id: 'chk-1', + parent: 'cfg-1', + db_version: 1, + name: 'leaks-ssn', + workspace: 'default', + created_at: '2026-04-12T11:00:00.000Z', + created_by: 'user@example.com', + updated_at: '2026-04-12T11:00:00.000Z', + updated_by: 'user@example.com', + data: { + messages: [{ role: 'user', content: 'My SSN is 123-45-6789' }], + runs: [ + { + run_at: '2026-04-12T11:05:00.000Z', + status: 'blocked', + rails_status: { 'check pii': { status: 'blocked' } }, + config_version: 1, + }, + ], + }, + }, + { + entity_type: GUARDRAIL_CHECKS_ENTITY_TYPE, + id: 'chk-2', + parent: 'cfg-1', + db_version: 1, + name: 'benign-greeting', + workspace: 'default', + created_at: '2026-04-12T11:00:00.000Z', + created_by: 'user@example.com', + updated_at: '2026-04-12T11:00:00.000Z', + updated_by: 'user@example.com', + data: { + messages: [{ role: 'user', content: 'Hello there' }], + runs: [], + }, + }, +]; + export const guardrailsHandlers = [ + http.get( + `${PLATFORM_BASE_URL}/apis/entities/v2/workspaces/:workspace/entities/${GUARDRAIL_CHECKS_ENTITY_TYPE}`, + () => + HttpResponse.json({ + data: mockGuardrailChecks, + pagination: { + page: 1, + page_size: 1000, + current_page_size: mockGuardrailChecks.length, + total_pages: 1, + total_results: mockGuardrailChecks.length, + }, + }) + ), http.get(`${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs`, () => HttpResponse.json({ data: mockGuardrailConfigs, diff --git a/web/packages/studio/src/routes/groups/guardrailsRoutes.tsx b/web/packages/studio/src/routes/groups/guardrailsRoutes.tsx index 9825f7217d..5a41911b49 100644 --- a/web/packages/studio/src/routes/groups/guardrailsRoutes.tsx +++ b/web/packages/studio/src/routes/groups/guardrailsRoutes.tsx @@ -25,6 +25,12 @@ const GuardrailConfigTab = lazy(() => })) ); +const GuardrailChecksTab = lazy(() => + import('@studio/routes/guardrails/GuardrailChecksTab').then((m) => ({ + default: m.GuardrailChecksTab, + })) +); + export const guardrailsRoutes: RouteObject[] = gateGuardrailsRoutes([ { path: ROUTES.workspace.guardrails, @@ -44,6 +50,10 @@ export const guardrailsRoutes: RouteObject[] = gateGuardrailsRoutes([ path: ROUTES.workspace.guardrailConfig, element: , }, + { + path: ROUTES.workspace.guardrailChecks, + element: , + }, ], }, ]); diff --git a/web/packages/studio/src/routes/guardrails/GuardrailChecksTab/GuardrailMessageRow.tsx b/web/packages/studio/src/routes/guardrails/GuardrailChecksTab/GuardrailMessageRow.tsx new file mode 100644 index 0000000000..9bea0de1de --- /dev/null +++ b/web/packages/studio/src/routes/guardrails/GuardrailChecksTab/GuardrailMessageRow.tsx @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ControlledSelect } from '@nemo/common/src/components/form/ControlledSelect'; +import { Button, Flex, Stack, Text, Tooltip } from '@nvidia/foundations-react-core'; +import type { GuardrailCheckMessage } from '@studio/api/guardrail-checks/types'; +import cn from 'classnames'; +import { ArrowDown, ArrowUp, Copy, type LucideIcon, Trash2 } from 'lucide-react'; +import { type FC, useRef } from 'react'; +import { type Control, type Path, useController } from 'react-hook-form'; + +/** RHF row shape for a single guardrail-check message. No collapse state — the body is always shown. */ +export interface GuardrailMessageFormRow { + role: GuardrailCheckMessage['role']; + content: string; +} + +export interface GuardrailCheckFormValues { + messages: GuardrailMessageFormRow[]; +} + +/** Roles offered in the row's role dropdown (subset of the chat-completion roles). */ +const ROLE_ITEMS: { value: string; children: string }[] = [ + { value: 'user', children: 'user' }, + { value: 'assistant', children: 'assistant' }, + { value: 'system', children: 'system' }, +]; + +/** Plain-text role label (mock shows e.g. "user ⌄", not a colored badge). */ +const renderRoleValue = (value: string) => {value}; + +/** A single icon action in the row header. */ +const RowIconButton: FC<{ + label: string; + icon: LucideIcon; + onClick: () => void; + disabled?: boolean; +}> = ({ label, icon: Icon, onClick, disabled }) => ( + + + +); + +export interface GuardrailMessageRowProps { + control: Control; + /** Path to this message row in the form, e.g. `messages.0`. */ + name: string; + onMoveUp?: () => void; + onMoveDown?: () => void; + onDuplicate: () => void; + onRemove: () => void; + /** When false, the delete action is disabled (never remove the last message). */ + allowRemove: boolean; + dataTestId?: string; +} + +/** + * One guardrail-check message: a role dropdown + an always-visible, manually resizable + * text body, with reorder / duplicate / delete actions. Purpose-built for the guardrails + * Tests tab (replaces the shared ChatCompletionInput). + * + * The row is a single bordered card with a native textarea — no nested input shell — so the + * body has exactly one border (which highlights on focus) rather than a doubled one. + */ +export const GuardrailMessageRow: FC = ({ + control, + name, + onMoveUp, + onMoveDown, + onDuplicate, + onRemove, + allowRemove, + dataTestId, +}) => { + const rolePath = `${name}.role` as Path; + const contentPath = `${name}.content` as Path; + const { field: content } = useController({ control, name: contentPath }); + const bodyRef = useRef(null); + + // After a role is chosen the Select closes and restores focus to its trigger at an unknown tick. + // Re-focus the message body across a short window so it wins whenever that restoration fires, + // keeping the user in the typing flow. + const focusBody = () => { + let ticks = 0; + const id = setInterval(() => { + bodyRef.current?.focus(); + if (++ticks >= 8) clearInterval(id); + }, 25); + }; + + return ( + + + {/* Role dropdown: plain text + chevron, sized to the selected role (not the widest). */} +
+ +
+ + + {onMoveUp ? : null} + {onMoveDown ? ( + + ) : null} + + + +
+ +