diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index ceace0a5ad..60d96adc2f 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -164,6 +164,17 @@ For `AzureMLChatTarget`, additional fields are available: **Max New Tokens**, ** Targets can also be auto-populated by adding the `target` initializer to your `~/.pyrit/.pyrit_conf` file. This reads endpoints from your `.env` and `.env.local` files. See [.pyrit_conf_example](https://github.com/microsoft/PyRIT/blob/main/.pyrit_conf_example) for details. +### Initializers + +The **Initializers** page (in the left navigation) lets you review and extend how PyRIT sets itself up at startup — for example, the `target` initializer's `tags` and `auto_group` settings. + +The page has two sections: + +- **Baseline initializers** are read-only. They come from your active configuration file (`~/.pyrit/.pyrit_conf`) and run first, in order. +- **Additional initializers** are added in the GUI and saved to the memory database. They run after the baseline, in the order shown. You can add more than one initializer of the same type — each is its own invocation. + +Use **Apply now** to re-run a single initializer immediately against the running backend — handy for picking up an environment or setting change without a restart. Saved additional initializers and `.pyrit_conf` edits otherwise take effect the next time the backend starts. + --- ## Connection Health diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index b9ef72bc00..d9d1351901 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -10,6 +10,7 @@ const config: Config = { "\\.(css|less|scss|sass)$": "identity-obj-proxy", }, setupFilesAfterEnv: ["/src/setupTests.ts"], + testTimeout: 15000, collectCoverageFrom: [ "src/**/*.{ts,tsx}", "!src/**/*.d.ts", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ae9a572dee..a099e5fd38 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import ChatWindow from './components/Chat/ChatWindow' import AttackNotFound from './components/Chat/AttackNotFound' import Home from './components/Home/Home' import TargetConfig from './components/Config/TargetConfig' +import Initializers from './components/Initializers/Initializers' import AttackHistory from './components/History/AttackHistory' import FeedbackDialog from './components/Feedback/FeedbackDialog' import type { HistoryFilters } from './components/History/historyFilters' @@ -36,6 +37,7 @@ const VIEW_PATHS: Record = { chat: '/chat', history: '/history', config: '/config', + initializers: '/initializers', } /** Resolves the active view from a URL path, defaulting to home for unknown paths. */ @@ -392,6 +394,7 @@ function App() { /> } /> + } /> { await userEvent.click(screen.getByTestId("dialog-close")); expect(screen.queryByTestId("create-dialog")).not.toBeInTheDocument(); }); + }); diff --git a/frontend/src/components/Initializers/AdditionalInitializers.styles.ts b/frontend/src/components/Initializers/AdditionalInitializers.styles.ts new file mode 100644 index 0000000000..04b43a0acf --- /dev/null +++ b/frontend/src/components/Initializers/AdditionalInitializers.styles.ts @@ -0,0 +1,88 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +export const useAdditionalInitializersStyles = makeStyles({ + list: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalL, + width: '100%', + }, + card: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + padding: tokens.spacingVerticalL, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground1, + }, + cardHeader: { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalM, + }, + titleGroup: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + }, + parameterList: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + marginBottom: tokens.spacingVerticalS, + }, + parameterHint: { + color: tokens.colorNeutralForeground3, + }, + parametersEditor: { + fontFamily: 'Consolas, "Courier New", monospace', + minHeight: '10rem', + width: '100%', + }, + parametersBlock: { + margin: 0, + marginTop: tokens.spacingVerticalXS, + padding: tokens.spacingVerticalM, + borderRadius: tokens.borderRadiusMedium, + backgroundColor: tokens.colorNeutralBackground3, + overflowX: 'auto', + fontFamily: 'Consolas, "Courier New", monospace', + }, + dialogContent: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + }, + actionsRow: { + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalS, + }, + errorText: { + color: tokens.colorPaletteRedForeground1, + marginTop: tokens.spacingVerticalXS, + }, + envVarText: { + color: tokens.colorNeutralForeground3, + display: 'block', + marginTop: tokens.spacingVerticalXXS, + }, + parameterFields: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + }, + fieldHint: { + color: tokens.colorNeutralForeground3, + marginTop: tokens.spacingVerticalXXS, + }, + checkboxGroup: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + }, +}) diff --git a/frontend/src/components/Initializers/AdditionalInitializers.test.tsx b/frontend/src/components/Initializers/AdditionalInitializers.test.tsx new file mode 100644 index 0000000000..47644d0a16 --- /dev/null +++ b/frontend/src/components/Initializers/AdditionalInitializers.test.tsx @@ -0,0 +1,345 @@ +import { fireEvent, render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import type { AdditionalInitializerSetting, RegisteredInitializer } from '@/types' + +import AdditionalInitializers from './AdditionalInitializers' + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +const targetInitializer: RegisteredInitializer = { + initializer_name: 'target', + initializer_type: 'TargetInitializer', + description: 'Registers targets.', + required_env_vars: ['AZURE_OPENAI_ENDPOINT'], + supported_parameters: [ + { + name: 'tags', + type_name: 'list[str]', + required: false, + default: null, + choices: null, + is_list: true, + description: 'Target tags.', + }, + ], +} + +const scorerInitializer: RegisteredInitializer = { + initializer_name: 'scorer', + initializer_type: 'ScorerInitializer', + description: 'Registers scorers.', + required_env_vars: [], + supported_parameters: [ + { + name: 'mode', + type_name: 'str', + required: false, + default: null, + choices: null, + is_list: false, + description: 'Scorer mode.', + }, + ], +} + +const noParamInitializer: RegisteredInitializer = { + initializer_name: 'load_default_datasets', + initializer_type: 'DatasetInitializer', + description: 'Loads default datasets.', + required_env_vars: [], + supported_parameters: [], +} + +const taggedTargetInitializer: RegisteredInitializer = { + initializer_name: 'tagged_target', + initializer_type: 'TargetInitializer', + description: 'Registers targets with tags.', + required_env_vars: [], + supported_parameters: [ + { + name: 'tags', + type_name: 'list[str]', + required: false, + default: null, + choices: ['default', 'scorer', 'all'], + is_list: true, + description: 'Target tags.', + }, + ], +} + +const requiredParamInitializer: RegisteredInitializer = { + initializer_name: 'required_param', + initializer_type: 'DatasetInitializer', + description: 'Requires a label.', + required_env_vars: [], + supported_parameters: [ + { + name: 'label', + type_name: 'str', + required: true, + default: null, + choices: null, + is_list: false, + description: 'A required label.', + }, + ], +} + +const refreshInitializer: RegisteredInitializer = { + initializer_name: 'refresh_datasets', + initializer_type: 'DatasetInitializer', + description: 'Refreshes datasets.', + required_env_vars: [], + supported_parameters: [ + { + name: 'days', + type_name: 'int', + required: false, + default: null, + choices: null, + is_list: false, + description: 'Number of days.', + }, + { + name: 'dataset_names', + type_name: 'list[str]', + required: false, + default: null, + choices: null, + is_list: true, + description: 'Dataset names.', + }, + ], +} + +const sampleItems: AdditionalInitializerSetting[] = [ + { + id: 'additional-1', + initializer_name: 'target', + parameters: { tags: ['default'] }, + order_index: 2, + }, + { + id: 'additional-2', + initializer_name: 'scorer', + parameters: null, + order_index: null, + }, +] + +describe('AdditionalInitializers', () => { + const defaultProps = { + items: sampleItems, + registeredInitializers: [targetInitializer, scorerInitializer], + creating: false, + onAdd: jest.fn().mockResolvedValue(true), + onSave: jest.fn().mockResolvedValue(undefined), + onApply: jest.fn().mockResolvedValue(undefined), + onRemove: jest.fn().mockResolvedValue(undefined), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render additional initializer rows and metadata', () => { + render( + + + , + ) + + expect(screen.getByRole('list', { name: 'Additional initializers' })).toBeInTheDocument() + expect(screen.getByTestId('initializer-row-additional-1')).toHaveTextContent('target') + expect(screen.getByText('Required env vars: AZURE_OPENAI_ENDPOINT')).toBeInTheDocument() + expect(screen.getByText('tags (list[str], optional)')).toBeInTheDocument() + }) + + it('should show the saved parameters read-only without an inline editor', () => { + render( + + + , + ) + + const row = screen.getByTestId('initializer-row-additional-1') + expect(within(row).getByText(/"tags"/)).toBeInTheDocument() + expect(within(row).queryByRole('textbox', { name: 'Parameters JSON' })).not.toBeInTheDocument() + }) + + it('should show the description as hover text on the initializer name', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + + await user.hover(within(screen.getByTestId('initializer-row-additional-1')).getByText('target')) + + expect(await screen.findByRole('tooltip')).toHaveTextContent('Registers targets.') + }) + + it('should call onSave from the edit dialog, preserving the existing order_index', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + const row = screen.getByTestId('initializer-row-additional-1') + fireEvent.click(within(row).getByRole('button', { name: 'Edit' })) + + const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) + await within(dialog).findByText('Edit target initializer') + const editor = within(dialog).getByTestId('param-tags') + fireEvent.change(editor, { target: { value: 'extra' } }) + await user.click(await within(dialog).findByRole('button', { name: 'Save', hidden: true })) + + expect(defaultProps.onSave).toHaveBeenCalledWith('additional-1', { + parameters: { tags: ['extra'] }, + order_index: 2, + }) + }) + + it('should call onApply with the saved parameters', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + const row = screen.getByTestId('initializer-row-additional-1') + await user.click(within(row).getByRole('button', { name: 'Apply now' })) + + expect(defaultProps.onApply).toHaveBeenCalledWith('additional-1', 'target', { tags: ['default'] }) + }) + + it('should call onRemove with the additional initializer id', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + await user.click(within(screen.getByTestId('initializer-row-additional-1')).getByRole('button', { name: 'Remove' })) + + expect(defaultProps.onRemove).toHaveBeenCalledWith('additional-1') + }) + + it('should show a validation error when a required parameter is missing', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + fireEvent.change(screen.getByRole('combobox', { name: 'Initializer to add' }), { + target: { value: 'required_param' }, + }) + await user.click(screen.getByRole('button', { name: 'Add initializer' })) + + const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) + await within(dialog).findByText('Add required_param initializer') + await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) + + expect(await within(dialog).findByRole('alert', { hidden: true })).toHaveTextContent( + 'label is required.', + ) + expect(defaultProps.onAdd).not.toHaveBeenCalled() + }) + + it('should submit typed number and list parameters from the add dialog', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + fireEvent.change(screen.getByRole('combobox', { name: 'Initializer to add' }), { + target: { value: 'refresh_datasets' }, + }) + await user.click(screen.getByRole('button', { name: 'Add initializer' })) + + const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) + await within(dialog).findByText('Add refresh_datasets initializer') + fireEvent.change(within(dialog).getByTestId('param-days'), { target: { value: '7' } }) + fireEvent.change(within(dialog).getByTestId('param-dataset_names'), { target: { value: 'harmbench, xstest' } }) + await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) + + expect(defaultProps.onAdd).toHaveBeenCalledWith('refresh_datasets', { + days: 7, + dataset_names: ['harmbench', 'xstest'], + }) + }) + + it('should submit selected choices from a multiselect parameter', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + fireEvent.change(screen.getByRole('combobox', { name: 'Initializer to add' }), { + target: { value: 'tagged_target' }, + }) + await user.click(screen.getByRole('button', { name: 'Add initializer' })) + + const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) + await within(dialog).findByText('Add tagged_target initializer') + await user.click(within(dialog).getByTestId('param-tags-default')) + await user.click(within(dialog).getByTestId('param-tags-scorer')) + await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) + + expect(defaultProps.onAdd).toHaveBeenCalledWith('tagged_target', { tags: ['default', 'scorer'] }) + }) + + it('should hide the parameters editor and submit null for a no-parameter initializer', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + fireEvent.change(screen.getByRole('combobox', { name: 'Initializer to add' }), { + target: { value: 'load_default_datasets' }, + }) + await user.click(screen.getByRole('button', { name: 'Add initializer' })) + + const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) + await within(dialog).findByText('Add load_default_datasets initializer') + expect(within(dialog).getByText('This initializer takes no parameters.')).toBeInTheDocument() + expect( + within(dialog).queryByRole('textbox', { name: 'Parameters JSON', hidden: true }), + ).not.toBeInTheDocument() + + await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) + + expect(defaultProps.onAdd).toHaveBeenCalledWith('load_default_datasets', null) + }) +}) diff --git a/frontend/src/components/Initializers/AdditionalInitializers.tsx b/frontend/src/components/Initializers/AdditionalInitializers.tsx new file mode 100644 index 0000000000..d9a69d781b --- /dev/null +++ b/frontend/src/components/Initializers/AdditionalInitializers.tsx @@ -0,0 +1,220 @@ +import { useState } from 'react' + +import { Button, Select, Text, Tooltip } from '@fluentui/react-components' +import { AddRegular } from '@fluentui/react-icons' + +import type { + AdditionalInitializerSetting, + RegisteredInitializer, + UpdateAdditionalInitializerRequest, +} from '@/types' + +import { useAdditionalInitializersStyles } from './AdditionalInitializers.styles' +import { formatInitializerParameters, formatSupportedParameterSummary } from './initializerFormatting' +import { resolveRegisteredInitializer } from './initializerLookup' +import InitializerParametersDialog from './InitializerParametersDialog' +import { useInitializersStyles } from './Initializers.styles' + +interface AdditionalInitializersProps { + items: AdditionalInitializerSetting[] + registeredInitializers: RegisteredInitializer[] + creating: boolean + savingInitializerId?: string | null + applyingInitializerId?: string | null + deletingInitializerId?: string | null + onAdd: (initializerName: string, parameters: Record | null) => Promise + onSave: (id: string, request: UpdateAdditionalInitializerRequest) => Promise + onApply: (id: string, initializerName: string, parameters?: Record | null) => Promise + onRemove: (id: string) => Promise +} + +interface AdditionalInitializerCardProps { + item: AdditionalInitializerSetting + initializer: RegisteredInitializer + isSaving: boolean + isApplying: boolean + isDeleting: boolean + onSave: (id: string, request: UpdateAdditionalInitializerRequest) => Promise + onApply: (id: string, initializerName: string, parameters?: Record | null) => Promise + onRemove: (id: string) => Promise +} + +function AdditionalInitializerCard({ + item, + initializer, + isSaving, + isApplying, + isDeleting, + onSave, + onApply, + onRemove, +}: AdditionalInitializerCardProps) { + const styles = useAdditionalInitializersStyles() + const [editOpen, setEditOpen] = useState(false) + const isBusy = isSaving || isApplying || isDeleting + + const handleEditSubmit = async (parameters: Record | null): Promise => { + await onSave(item.id, { parameters, order_index: item.order_index ?? null }) + setEditOpen(false) + } + + return ( +
+
+
+ + {item.initializer_name} + + {initializer.required_env_vars.length > 0 && ( + + Required env vars: {initializer.required_env_vars.join(', ')} + + )} +
+
+ +
+ {formatSupportedParameterSummary(initializer).map((summary: string) => ( + + {summary} + + ))} +
+ +
+ Parameters +
{formatInitializerParameters(item.parameters)}
+
+ +
+ + + +
+ + {editOpen && ( + + )} +
+ ) +} + +export default function AdditionalInitializers({ + items, + registeredInitializers, + creating, + savingInitializerId = null, + applyingInitializerId = null, + deletingInitializerId = null, + onAdd, + onSave, + onApply, + onRemove, +}: AdditionalInitializersProps) { + const pageStyles = useInitializersStyles() + const listStyles = useAdditionalInitializersStyles() + const [selectedInitializerName, setSelectedInitializerName] = useState('') + const [addDialogOpen, setAddDialogOpen] = useState(false) + const initializerName = selectedInitializerName || registeredInitializers[0]?.initializer_name || '' + const selectedInitializer = registeredInitializers.find( + (initializer) => initializer.initializer_name === initializerName, + ) ?? null + + const handleAdd = async (parameters: Record | null): Promise => { + if (!initializerName) { + return + } + const added = await onAdd(initializerName, parameters) + if (added) { + setAddDialogOpen(false) + } + } + + return ( +
+
+ + Additional initializers + + + Add and edit initializer invocations that run after the baseline. + +
+ +
+ + +
+ + {items.length === 0 ? ( + No additional initializers are configured. + ) : ( +
+ {items.map((item: AdditionalInitializerSetting) => ( + + ))} +
+ )} + + {addDialogOpen && ( + + )} +
+ ) +} diff --git a/frontend/src/components/Initializers/AvailableInitializersDialog.test.tsx b/frontend/src/components/Initializers/AvailableInitializersDialog.test.tsx new file mode 100644 index 0000000000..cafe49c3e2 --- /dev/null +++ b/frontend/src/components/Initializers/AvailableInitializersDialog.test.tsx @@ -0,0 +1,77 @@ +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import type { RegisteredInitializer } from '@/types' + +import AvailableInitializersDialog from './AvailableInitializersDialog' + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +const initializers: RegisteredInitializer[] = [ + { + initializer_name: 'refresh_datasets', + initializer_type: 'DatasetInitializer', + description: 'Refreshes datasets.', + required_env_vars: ['HF_TOKEN'], + supported_parameters: [ + { name: 'days', type_name: 'int', required: true, default: null, choices: null, is_list: false }, + ], + }, + { + initializer_name: 'scorer', + initializer_type: 'ScorerInitializer', + description: null, + required_env_vars: [], + supported_parameters: [], + }, +] + +describe('AvailableInitializersDialog', () => { + it('disables the trigger button when disabled is set', () => { + render( + + + , + ) + + expect(screen.getByRole('button', { name: /browse available initializers/i })).toBeDisabled() + }) + + it('opens the dialog and lists each initializer with its parameter summary', async () => { + const user = userEvent.setup() + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: /browse available initializers/i })) + + const dialog = await screen.findByRole('dialog') + const refreshRow = within(dialog).getByTestId('available-initializer-row-refresh_datasets') + expect(within(refreshRow).getByText('Refreshes datasets.')).toBeInTheDocument() + expect(within(refreshRow).getByText(/HF_TOKEN/)).toBeInTheDocument() + expect(within(refreshRow).getByText('days (int, required)')).toBeInTheDocument() + + const scorerRow = within(dialog).getByTestId('available-initializer-row-scorer') + expect(within(scorerRow).getByText('No description available.')).toBeInTheDocument() + expect(within(scorerRow).getByText('No declared parameters.')).toBeInTheDocument() + }) + + it('shows an empty state when no initializers are registered', async () => { + const user = userEvent.setup() + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: /browse available initializers/i })) + + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByText('No registered initializers were found.')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Initializers/AvailableInitializersDialog.tsx b/frontend/src/components/Initializers/AvailableInitializersDialog.tsx new file mode 100644 index 0000000000..82255129a9 --- /dev/null +++ b/frontend/src/components/Initializers/AvailableInitializersDialog.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react' + +import { + Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + DialogTrigger, + Text, +} from '@fluentui/react-components' +import { AppsListRegular } from '@fluentui/react-icons' + +import type { RegisteredInitializer } from '@/types' + +import { formatSupportedParameterSummary } from './initializerFormatting' +import { useInitializersStyles } from './Initializers.styles' + +interface AvailableInitializersDialogProps { + registeredInitializers: RegisteredInitializer[] + disabled?: boolean +} + +export default function AvailableInitializersDialog({ + registeredInitializers, + disabled = false, +}: AvailableInitializersDialogProps) { + const styles = useInitializersStyles() + const [open, setOpen] = useState(false) + + return ( + setOpen(data.open)}> + + + + + + Available initializers + + + Every initializer registered with PyRIT. This is a read-only reference of what exists and the + parameters each one accepts. + + {registeredInitializers.length === 0 ? ( + No registered initializers were found. + ) : ( +
+ {registeredInitializers.map((initializer: RegisteredInitializer) => ( +
+
+ {initializer.initializer_name} + {initializer.description || 'No description available.'} + + Required env vars: {initializer.required_env_vars.length > 0 + ? initializer.required_env_vars.join(', ') + : 'None'} + +
+
+ Parameters +
+ {formatSupportedParameterSummary(initializer).map((summary: string) => ( + + {summary} + + ))} +
+
+
+ ))} +
+ )} +
+ + + + + +
+
+
+ ) +} diff --git a/frontend/src/components/Initializers/BaselineInitializers.test.tsx b/frontend/src/components/Initializers/BaselineInitializers.test.tsx new file mode 100644 index 0000000000..e13b780670 --- /dev/null +++ b/frontend/src/components/Initializers/BaselineInitializers.test.tsx @@ -0,0 +1,68 @@ +import { render, screen, within } from '@testing-library/react' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import type { BaselineInitializerSetting, RegisteredInitializer } from '@/types' + +import BaselineInitializers from './BaselineInitializers' + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +const registeredInitializers: RegisteredInitializer[] = [ + { + initializer_name: 'target', + initializer_type: 'TargetInitializer', + description: 'Registers targets.', + required_env_vars: ['AZURE_OPENAI_ENDPOINT', 'AZURE_OPENAI_KEY'], + supported_parameters: [], + }, +] + +describe('BaselineInitializers', () => { + it('renders the empty state when there are no baseline initializers', () => { + render( + + + , + ) + + expect(screen.getByText('No baseline initializers are configured.')).toBeInTheDocument() + expect(screen.queryByRole('list', { name: 'Baseline initializers' })).not.toBeInTheDocument() + }) + + it('renders each baseline row with description, env vars, order, and parameters', () => { + const items: BaselineInitializerSetting[] = [ + { initializer_name: 'target', parameters: { tags: ['default'] }, order_index: 0 }, + ] + + render( + + + , + ) + + const row = screen.getByTestId('baseline-initializer-row-target') + expect(within(row).getByText('target')).toBeInTheDocument() + expect(within(row).getByText('Registers targets.')).toBeInTheDocument() + expect(within(row).getByText(/AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_KEY/)).toBeInTheDocument() + expect(within(row).getByText('Order: 0')).toBeInTheDocument() + expect(within(row).getByText(/"tags"/)).toBeInTheDocument() + }) + + it('falls back to a placeholder for a name that is no longer registered', () => { + const items: BaselineInitializerSetting[] = [ + { initializer_name: 'ghost', parameters: null, order_index: 1 }, + ] + + render( + + + , + ) + + const row = screen.getByTestId('baseline-initializer-row-ghost') + expect(within(row).getByText('Initializer is no longer registered.')).toBeInTheDocument() + expect(within(row).getByText(/Required env vars: None/)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Initializers/BaselineInitializers.tsx b/frontend/src/components/Initializers/BaselineInitializers.tsx new file mode 100644 index 0000000000..46910182ff --- /dev/null +++ b/frontend/src/components/Initializers/BaselineInitializers.tsx @@ -0,0 +1,66 @@ +import { Text } from '@fluentui/react-components' + +import type { BaselineInitializerSetting, RegisteredInitializer } from '@/types' + +import { formatInitializerParameters } from './initializerFormatting' +import { resolveRegisteredInitializer } from './initializerLookup' +import { useInitializersStyles } from './Initializers.styles' + +interface BaselineInitializersProps { + items: BaselineInitializerSetting[] + registeredInitializers: RegisteredInitializer[] +} + +export default function BaselineInitializers({ + items, + registeredInitializers, +}: BaselineInitializersProps) { + const styles = useInitializersStyles() + + return ( +
+
+ + Baseline initializers + + + Read-only initializers from the .pyrit_conf baseline. + +
+ {items.length === 0 ? ( + No baseline initializers are configured. + ) : ( +
+ {items.map((item: BaselineInitializerSetting) => { + const initializer = resolveRegisteredInitializer(item.initializer_name, registeredInitializers) + return ( +
+
+
+ {item.initializer_name} + {initializer.description || 'No description available.'} + + Required env vars: {initializer.required_env_vars.length > 0 + ? initializer.required_env_vars.join(', ') + : 'None'} + + Order: {item.order_index} +
+
+
+ Parameters +
{formatInitializerParameters(item.parameters)}
+
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/components/Initializers/InitializerParametersDialog.test.tsx b/frontend/src/components/Initializers/InitializerParametersDialog.test.tsx new file mode 100644 index 0000000000..d0fda42461 --- /dev/null +++ b/frontend/src/components/Initializers/InitializerParametersDialog.test.tsx @@ -0,0 +1,210 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import type { RegisteredInitializer } from '@/types' + +import InitializerParametersDialog from './InitializerParametersDialog' + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +const allKindsInitializer: RegisteredInitializer = { + initializer_name: 'kitchen_sink', + initializer_type: 'DemoInitializer', + description: 'Every control kind.', + required_env_vars: ['DEMO_TOKEN'], + supported_parameters: [ + { name: 'flag', type_name: 'bool', required: false, default: null, choices: null, is_list: false }, + { name: 'level', type_name: 'str', required: false, default: null, choices: ['low', 'high'], is_list: false }, + { name: 'tags', type_name: 'list[str]', required: false, default: null, choices: ['a', 'b'], is_list: true }, + { name: 'names', type_name: 'list[str]', required: false, default: null, choices: null, is_list: true }, + { name: 'days', type_name: 'int', required: false, default: null, choices: null, is_list: false }, + { name: 'label', type_name: 'str', required: false, default: null, choices: null, is_list: false }, + ], +} + +const numericInitializer: RegisteredInitializer = { + initializer_name: 'refresh_datasets', + initializer_type: 'DatasetInitializer', + description: 'Refreshes datasets.', + required_env_vars: [], + supported_parameters: [ + { name: 'days', type_name: 'int', required: false, default: null, choices: null, is_list: false }, + { name: 'names', type_name: 'list[str]', required: false, default: null, choices: null, is_list: true }, + ], +} + +const requiredInitializer: RegisteredInitializer = { + initializer_name: 'required_param', + initializer_type: 'DemoInitializer', + description: 'Requires a label.', + required_env_vars: [], + supported_parameters: [ + { name: 'label', type_name: 'str', required: true, default: null, choices: null, is_list: false }, + ], +} + +const noParamInitializer: RegisteredInitializer = { + initializer_name: 'load_default_datasets', + initializer_type: 'DatasetInitializer', + description: 'Loads default datasets.', + required_env_vars: [], + supported_parameters: [], +} + +describe('InitializerParametersDialog', () => { + const baseProps = { + open: true, + mode: 'add' as const, + onSubmit: jest.fn().mockResolvedValue(undefined), + onOpenChange: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders one control of the right kind for each parameter', () => { + render( + + + , + ) + + expect(screen.getByText('Add kitchen_sink initializer')).toBeInTheDocument() + expect(screen.getByText(/Required env vars: DEMO_TOKEN/)).toBeInTheDocument() + expect(screen.getByTestId('param-flag')).toHaveAttribute('role', 'switch') + expect(screen.getByTestId('param-level').tagName).toBe('SELECT') + expect(screen.getByTestId('param-tags-a')).toBeInTheDocument() + expect(screen.getByTestId('param-tags-b')).toBeInTheDocument() + expect(screen.getByTestId('param-names')).toBeInTheDocument() + expect(screen.getByTestId('param-days')).toHaveAttribute('type', 'number') + expect(screen.getByTestId('param-label')).toHaveAttribute('type', 'text') + }) + + it('shows a no-parameters message and submits null for a parameterless initializer', async () => { + const user = userEvent.setup() + const onSubmit = jest.fn().mockResolvedValue(undefined) + render( + + + , + ) + + expect(screen.getByText('This initializer takes no parameters.')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Add' })) + + expect(onSubmit).toHaveBeenCalledWith(null) + }) + + it('blocks submit and shows an error when a required field is empty', async () => { + const user = userEvent.setup() + const onSubmit = jest.fn().mockResolvedValue(undefined) + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Add' })) + + expect(await screen.findByRole('alert')).toHaveTextContent('label is required.') + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('coerces typed number and comma-separated list values on submit', async () => { + const user = userEvent.setup() + const onSubmit = jest.fn().mockResolvedValue(undefined) + render( + + + , + ) + + fireEvent.change(screen.getByTestId('param-days'), { target: { value: '7' } }) + fireEvent.change(screen.getByTestId('param-names'), { target: { value: 'x, y' } }) + await user.click(screen.getByRole('button', { name: 'Add' })) + + expect(onSubmit).toHaveBeenCalledWith({ days: 7, names: ['x', 'y'] }) + }) + + it('submits toggled boolean and selected multiselect values', async () => { + const user = userEvent.setup() + const onSubmit = jest.fn().mockResolvedValue(undefined) + render( + + + , + ) + + await user.click(screen.getByTestId('param-flag')) + await user.click(screen.getByTestId('param-tags-a')) + await user.click(screen.getByRole('button', { name: 'Add' })) + + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ flag: true, tags: ['a'] })) + }) + + it('unchecks a multiselect choice and picks a select value', async () => { + const user = userEvent.setup() + const onSubmit = jest.fn().mockResolvedValue(undefined) + render( + + + , + ) + + await user.click(screen.getByTestId('param-tags-a')) + await user.click(screen.getByTestId('param-tags-a')) + fireEvent.change(screen.getByTestId('param-level'), { target: { value: 'high' } }) + await user.click(screen.getByRole('button', { name: 'Add' })) + + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ level: 'high' })) + expect(onSubmit.mock.calls[0][0]).not.toHaveProperty('tags') + }) + + it('prefills existing parameters in edit mode', () => { + render( + + + , + ) + + expect(screen.getByText('Edit refresh_datasets initializer')).toBeInTheDocument() + expect(screen.getByTestId('param-days')).toHaveValue(5) + expect(screen.getByTestId('param-names')).toHaveValue('alpha, beta') + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument() + }) + + it('calls onOpenChange(false) when cancelled', async () => { + const user = userEvent.setup() + const onOpenChange = jest.fn() + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('disables the actions and shows progress text while submitting', () => { + render( + + + , + ) + + expect(screen.getByRole('button', { name: 'Add...' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled() + }) +}) diff --git a/frontend/src/components/Initializers/InitializerParametersDialog.tsx b/frontend/src/components/Initializers/InitializerParametersDialog.tsx new file mode 100644 index 0000000000..44e8ba832d --- /dev/null +++ b/frontend/src/components/Initializers/InitializerParametersDialog.tsx @@ -0,0 +1,228 @@ +import { useState } from 'react' +import { + Button, + Checkbox, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + Field, + Input, + Select, + Switch, + Text, +} from '@fluentui/react-components' + +import type { Parameter, RegisteredInitializer } from '@/types' + +import { useAdditionalInitializersStyles } from './AdditionalInitializers.styles' +import { + buildParametersFromForm, + getInitialFormValues, + getParameterControlKind, + type ParameterFormValue, +} from './initializerParameterForm' + +interface InitializerParametersDialogProps { + open: boolean + mode: 'add' | 'edit' + initializer: RegisteredInitializer | null + initialParameters?: Record | null + submitting?: boolean + onSubmit: (parameters: Record | null) => void | Promise + onOpenChange: (open: boolean) => void +} + +export default function InitializerParametersDialog({ + open, + mode, + initializer, + initialParameters = null, + submitting = false, + onSubmit, + onOpenChange, +}: InitializerParametersDialogProps) { + const styles = useAdditionalInitializersStyles() + const parameters = initializer?.supported_parameters ?? [] + const [values, setValues] = useState>(() => + getInitialFormValues(parameters, initialParameters), + ) + const [error, setError] = useState(null) + + const acceptsParameters = parameters.length > 0 + + const updateValue = (name: string, value: ParameterFormValue): void => { + setValues((prev) => ({ ...prev, [name]: value })) + setError(null) + } + + const handleSubmit = async (): Promise => { + if (!acceptsParameters) { + setError(null) + await onSubmit(null) + return + } + + const result = buildParametersFromForm(parameters, values) + if (!result.ok) { + setError(result.error) + return + } + + setError(null) + await onSubmit(result.parameters) + } + + const initializerName = initializer?.initializer_name ?? '' + const title = mode === 'add' ? `Add ${initializerName} initializer` : `Edit ${initializerName} initializer` + const submitLabel = mode === 'add' ? 'Add' : 'Save' + + return ( + onOpenChange(data.open)}> + + + {title} + + {initializer && ( + <> + {initializer.description || 'No description available.'} + {initializer.required_env_vars.length > 0 && ( + + Required env vars: {initializer.required_env_vars.join(', ')} + + )} + + )} + {acceptsParameters ? ( +
+ {parameters.map((parameter) => ( + + ))} +
+ ) : ( + + This initializer takes no parameters. + + )} + {error && ( + + {error} + + )} +
+ + + + +
+
+
+ ) +} + +interface ParameterFieldProps { + parameter: Parameter + value: ParameterFormValue + disabled: boolean + onChange: (name: string, value: ParameterFormValue) => void +} + +function ParameterField({ parameter, value, disabled, onChange }: ParameterFieldProps) { + const styles = useAdditionalInitializersStyles() + const kind = getParameterControlKind(parameter) + const label = parameter.required ? `${parameter.name} *` : parameter.name + + if (kind === 'boolean') { + const checked = value === 'true' + return ( + + onChange(parameter.name, data.checked ? 'true' : 'false')} + data-testid={`param-${parameter.name}`} + /> + + ) + } + + if (kind === 'multiselect') { + const selected = Array.isArray(value) ? value : [] + return ( + +
+ {(parameter.choices ?? []).map((choice) => ( + { + const next = data.checked + ? [...selected, choice] + : selected.filter((entry) => entry !== choice) + onChange(parameter.name, next) + }} + data-testid={`param-${parameter.name}-${choice}`} + /> + ))} +
+
+ ) + } + + const stringValue = typeof value === 'string' ? value : '' + + if (kind === 'select') { + return ( + + + + ) + } + + const hint = + parameter.description ?? (kind === 'list' ? 'Comma-separated list of values.' : parameter.type_name) + + return ( + + onChange(parameter.name, data.value)} + data-testid={`param-${parameter.name}`} + /> + + ) +} diff --git a/frontend/src/components/Initializers/Initializers.styles.ts b/frontend/src/components/Initializers/Initializers.styles.ts new file mode 100644 index 0000000000..26489aa6d7 --- /dev/null +++ b/frontend/src/components/Initializers/Initializers.styles.ts @@ -0,0 +1,136 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +export const useInitializersStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + height: '100%', + width: '100%', + minWidth: 0, + maxWidth: '100%', + gap: tokens.spacingVerticalL, + padding: tokens.spacingVerticalXXL, + overflowX: 'hidden', + overflowY: 'auto', + backgroundColor: tokens.colorNeutralBackground2, + '@media (max-width: 600px)': { + padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`, + }, + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: tokens.spacingVerticalM, + }, + headerText: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + }, + headerActions: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalM, + }, + section: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + }, + sectionHeader: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + }, + addInitializerRow: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalM, + flexWrap: 'wrap', + }, + addInitializerSelect: { + minWidth: '220px', + }, + baselineList: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + }, + baselineGroup: { + display: 'flex', + flexDirection: 'column', + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground1, + overflow: 'hidden', + }, + baselineGroupItem: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + padding: tokens.spacingVerticalL, + ':not(:last-child)': { + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + }, + }, + dialogList: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + marginTop: tokens.spacingVerticalM, + maxHeight: '60vh', + overflowY: 'auto', + }, + baselineCard: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + padding: tokens.spacingVerticalL, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground1, + }, + baselineHeader: { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + gap: tokens.spacingHorizontalM, + flexWrap: 'wrap', + }, + titleGroup: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + }, + metadataText: { + color: tokens.colorNeutralForeground3, + }, + parametersBlock: { + margin: 0, + padding: tokens.spacingVerticalM, + borderRadius: tokens.borderRadiusMedium, + backgroundColor: tokens.colorNeutralBackground3, + overflowX: 'auto', + fontFamily: 'Consolas, "Courier New", monospace', + }, + parameterSummaryList: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + marginTop: tokens.spacingVerticalXS, + }, + loadingState: { + display: 'flex', + justifyContent: 'center', + padding: tokens.spacingVerticalXXXL, + }, + emptyState: { + padding: tokens.spacingVerticalXL, + color: tokens.colorNeutralForeground3, + }, + message: { + width: '100%', + }, +}) diff --git a/frontend/src/components/Initializers/Initializers.test.tsx b/frontend/src/components/Initializers/Initializers.test.tsx new file mode 100644 index 0000000000..08f6cd4d14 --- /dev/null +++ b/frontend/src/components/Initializers/Initializers.test.tsx @@ -0,0 +1,274 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import { initializersApi } from '@/services/api' +import type { + AdditionalInitializerSetting, + BaselineInitializerSetting, + InitializerSettingsResponse, + RegisteredInitializer, +} from '@/types' + +import Initializers from './Initializers' + +jest.mock('@/services/api', () => ({ + initializersApi: { + getSettings: jest.fn(), + listRegistered: jest.fn(), + createAdditional: jest.fn(), + updateAdditional: jest.fn(), + deleteAdditional: jest.fn(), + applyNow: jest.fn(), + }, +})) + +const mockedInitializersApi = initializersApi as jest.Mocked + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +async function openDialogByButton( + _user: ReturnType, + buttonName: RegExp | string, + dialogName: string, +): Promise { + const trigger = await screen.findByRole('button', { name: buttonName }) + await waitFor(() => expect(trigger).toBeEnabled()) + fireEvent.click(trigger) + const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) + await within(dialog).findByText(dialogName) + return dialog +} + +const targetInitializer: RegisteredInitializer = { + initializer_name: 'target', + initializer_type: 'TargetInitializer', + description: 'Registers targets.', + required_env_vars: ['AZURE_OPENAI_ENDPOINT'], + supported_parameters: [ + { + name: 'tags', + type_name: 'list[str]', + required: false, + default: null, + choices: null, + is_list: true, + description: 'Target tags.', + }, + ], +} + +const scorerInitializer: RegisteredInitializer = { + initializer_name: 'scorer', + initializer_type: 'ScorerInitializer', + description: 'Registers scorers.', + required_env_vars: [], + supported_parameters: [ + { + name: 'tags', + type_name: 'list[str]', + required: false, + default: null, + choices: null, + is_list: true, + description: 'Scorer tags.', + }, + ], +} + +const baselineItem: BaselineInitializerSetting = { + initializer_name: 'target', + parameters: { tags: ['baseline'] }, + order_index: 0, +} + +const additionalItem: AdditionalInitializerSetting = { + id: 'additional-1', + initializer_name: 'scorer', + parameters: { mode: 'strict' }, + order_index: 10, +} + +const sampleSettings: InitializerSettingsResponse = { + baseline: [baselineItem], + additional: [additionalItem], +} + +function renderInitializers(): void { + render( + + + , + ) +} + +describe('Initializers', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedInitializersApi.getSettings.mockResolvedValue(sampleSettings) + mockedInitializersApi.listRegistered.mockResolvedValue({ + items: [targetInitializer, scorerInitializer], + pagination: { limit: 200, has_more: false }, + }) + mockedInitializersApi.createAdditional.mockResolvedValue({ + id: 'additional-2', + initializer_name: 'target', + parameters: null, + order_index: null, + }) + mockedInitializersApi.updateAdditional.mockResolvedValue({ + id: 'additional-1', + initializer_name: 'scorer', + parameters: { mode: 'relaxed' }, + order_index: 11, + }) + mockedInitializersApi.deleteAdditional.mockResolvedValue() + mockedInitializersApi.applyNow.mockResolvedValue({ + initializer_name: 'scorer', + status: 'applied', + applied_parameters: { mode: 'strict' }, + }) + }) + + it('should show loading state initially', () => { + mockedInitializersApi.getSettings.mockReturnValue(new Promise(() => {})) + + renderInitializers() + + expect(screen.getByText('Loading initializer settings...')).toBeInTheDocument() + }) + + it('should render baseline and additional initializers', async () => { + renderInitializers() + + expect(await screen.findByRole('heading', { level: 1, name: 'Initializers' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 2, name: 'Baseline initializers' })).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 2, name: 'Additional initializers' })).toBeInTheDocument() + expect(screen.getByTestId('baseline-initializer-row-target')).toHaveTextContent('Registers targets.') + expect(screen.getByTestId('initializer-row-additional-1')).toHaveTextContent('scorer') + }) + + it('should refresh settings when the refresh button is clicked', async () => { + const user = userEvent.setup() + renderInitializers() + + await waitFor(() => { + expect(mockedInitializersApi.getSettings).toHaveBeenCalledTimes(1) + expect(mockedInitializersApi.listRegistered).toHaveBeenCalledTimes(1) + }) + + await user.click(screen.getByRole('button', { name: 'Refresh' })) + + await waitFor(() => { + expect(mockedInitializersApi.getSettings).toHaveBeenCalledTimes(2) + expect(mockedInitializersApi.listRegistered).toHaveBeenCalledTimes(2) + }) + }) + + it('should render a read-only catalog of all registered initializers in a dialog', async () => { + const user = userEvent.setup() + renderInitializers() + + await screen.findByRole('button', { name: 'Browse available initializers' }) + await openDialogByButton(user, 'Browse available initializers', 'Available initializers') + + const catalogTarget = screen.getByTestId('available-initializer-row-target') + expect(catalogTarget).toHaveTextContent('Registers targets.') + expect(catalogTarget).toHaveTextContent('tags') + expect(screen.getByTestId('available-initializer-row-scorer')).toBeInTheDocument() + }) + + it('should create the selected initializer and show success feedback', async () => { + const user = userEvent.setup() + renderInitializers() + + await screen.findByTestId('initializer-row-additional-1') + const dialog = await openDialogByButton(user, 'Add initializer', 'Add target initializer') + expect(dialog).toBeInTheDocument() + await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) + + await waitFor(() => { + expect(mockedInitializersApi.createAdditional).toHaveBeenCalledWith({ + initializer_name: 'target', + parameters: null, + }) + expect(screen.getByText('Added target initializer.')).toBeInTheDocument() + }) + }) + + it('should let the user choose a non-target initializer to add', async () => { + const user = userEvent.setup() + renderInitializers() + + await screen.findByTestId('initializer-row-additional-1') + const combobox = screen.getByRole('combobox', { name: 'Initializer to add' }) + await user.selectOptions(combobox, 'scorer') + await waitFor(() => expect(combobox).toHaveValue('scorer')) + const dialog = await openDialogByButton(user, /Add initializer|Adding/, 'Add scorer initializer') + expect(dialog).toBeInTheDocument() + await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) + + await waitFor(() => { + expect(mockedInitializersApi.createAdditional).toHaveBeenCalledWith({ + initializer_name: 'scorer', + parameters: null, + }) + expect(screen.getByText('Added scorer initializer.')).toBeInTheDocument() + }) + }) + + it('should save an additional initializer from the edit dialog', async () => { + const user = userEvent.setup() + renderInitializers() + + await screen.findByTestId('initializer-row-additional-1') + const dialog = await openDialogByButton(user, 'Edit', 'Edit scorer initializer') + fireEvent.change(within(dialog).getByTestId('param-tags'), { target: { value: 'relaxed' } }) + await user.click(await within(dialog).findByRole('button', { name: 'Save', hidden: true })) + + await waitFor(() => { + expect(mockedInitializersApi.updateAdditional).toHaveBeenCalledWith('additional-1', { + parameters: { tags: ['relaxed'] }, + order_index: 10, + }) + expect(screen.getByText('Saved additional initializer.')).toBeInTheDocument() + }) + }) + + it('should apply an additional initializer', async () => { + const user = userEvent.setup() + renderInitializers() + + const additionalRow = await screen.findByTestId('initializer-row-additional-1') + await user.click(within(additionalRow).getByRole('button', { name: 'Apply now' })) + + await waitFor(() => { + expect(mockedInitializersApi.applyNow).toHaveBeenCalledWith('scorer', { + parameters: { mode: 'strict' }, + }) + expect(screen.getByText('Applied scorer.')).toBeInTheDocument() + }) + }) + + it('should not render an apply button on baseline initializers', async () => { + renderInitializers() + + const baselineRow = await screen.findByTestId('baseline-initializer-row-target') + expect(within(baselineRow).queryByRole('button', { name: 'Apply now' })).not.toBeInTheDocument() + }) + + it('should remove an additional initializer and show success feedback', async () => { + const user = userEvent.setup() + renderInitializers() + + const row = await screen.findByTestId('initializer-row-additional-1') + await user.click(within(row).getByRole('button', { name: 'Remove' })) + + await waitFor(() => { + expect(mockedInitializersApi.deleteAdditional).toHaveBeenCalledWith('additional-1') + expect(screen.getByText('Removed additional initializer.')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/components/Initializers/Initializers.tsx b/frontend/src/components/Initializers/Initializers.tsx new file mode 100644 index 0000000000..bbd655dfa2 --- /dev/null +++ b/frontend/src/components/Initializers/Initializers.tsx @@ -0,0 +1,201 @@ +import { useEffect, useState } from 'react' + +import { Button, MessageBar, MessageBarBody, Spinner, Text } from '@fluentui/react-components' +import { ArrowSyncRegular } from '@fluentui/react-icons' + +import { initializersApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { InitializerSettingsResponse, RegisteredInitializer, UpdateAdditionalInitializerRequest } from '@/types' + +import AdditionalInitializers from './AdditionalInitializers' +import AvailableInitializersDialog from './AvailableInitializersDialog' +import BaselineInitializers from './BaselineInitializers' +import { useInitializersStyles } from './Initializers.styles' + +interface StatusMessage { + intent: 'success' | 'error' + text: string +} + +const EMPTY_SETTINGS: InitializerSettingsResponse = { + baseline: [], + additional: [], +} + +export default function Initializers() { + const styles = useInitializersStyles() + const [settings, setSettings] = useState(EMPTY_SETTINGS) + const [registeredInitializers, setRegisteredInitializers] = useState([]) + const [loading, setLoading] = useState(true) + const [statusMessage, setStatusMessage] = useState(null) + const [refetchCount, setRefetchCount] = useState(0) + const [creating, setCreating] = useState(false) + const [savingInitializerId, setSavingInitializerId] = useState(null) + const [applyingInitializerId, setApplyingInitializerId] = useState(null) + const [deletingInitializerId, setDeletingInitializerId] = useState(null) + + useEffect(() => { + let cancelled = false + + const loadInitializersAsync = async (): Promise => { + try { + const [settingsResponse, registeredResponse] = await Promise.all([ + initializersApi.getSettings(), + initializersApi.listRegistered(), + ]) + if (cancelled) { + return + } + setSettings(settingsResponse) + setRegisteredInitializers(registeredResponse.items) + } catch (error) { + if (cancelled) { + return + } + setStatusMessage({ intent: 'error', text: toApiError(error).detail }) + } finally { + if (!cancelled) { + setLoading(false) + } + } + } + + void loadInitializersAsync() + return () => { + cancelled = true + } + }, [refetchCount]) + + const refreshSettings = (): void => { + setLoading(true) + setStatusMessage(null) + setRefetchCount((currentCount: number) => currentCount + 1) + } + + const refetchSettingsOnly = async (): Promise => { + const response = await initializersApi.getSettings() + setSettings(response) + } + + const handleAdd = async ( + initializerName: string, + parameters: Record | null, + ): Promise => { + setCreating(true) + try { + await initializersApi.createAdditional({ initializer_name: initializerName, parameters }) + setStatusMessage({ intent: 'success', text: `Added ${initializerName} initializer.` }) + await refetchSettingsOnly() + return true + } catch (error) { + setStatusMessage({ intent: 'error', text: toApiError(error).detail }) + return false + } finally { + setCreating(false) + } + } + + const handleSave = async ( + id: string, + request: UpdateAdditionalInitializerRequest, + ): Promise => { + setSavingInitializerId(id) + try { + await initializersApi.updateAdditional(id, request) + setStatusMessage({ intent: 'success', text: 'Saved additional initializer.' }) + await refetchSettingsOnly() + } catch (error) { + setStatusMessage({ intent: 'error', text: toApiError(error).detail }) + } finally { + setSavingInitializerId(null) + } + } + + const handleApply = async ( + id: string, + initializerName: string, + parameters?: Record | null, + ): Promise => { + setApplyingInitializerId(id) + try { + await initializersApi.applyNow(initializerName, { parameters }) + setStatusMessage({ intent: 'success', text: `Applied ${initializerName}.` }) + } catch (error) { + setStatusMessage({ intent: 'error', text: toApiError(error).detail }) + } finally { + setApplyingInitializerId(null) + } + } + + const handleRemove = async (id: string): Promise => { + setDeletingInitializerId(id) + try { + await initializersApi.deleteAdditional(id) + setStatusMessage({ intent: 'success', text: 'Removed additional initializer.' }) + await refetchSettingsOnly() + } catch (error) { + setStatusMessage({ intent: 'error', text: toApiError(error).detail }) + } finally { + setDeletingInitializerId(null) + } + } + + return ( +
+
+
+ Initializers + + Browse every registered initializer, review the read-only baseline that ran at startup, and manage + additional initializer invocations that run after it. + +
+
+ + +
+
+ + {statusMessage && ( + + {statusMessage.text} + + )} + + {loading ? ( +
+ +
+ ) : ( + <> + + + + )} +
+ ) +} diff --git a/frontend/src/components/Initializers/initializerFormatting.test.ts b/frontend/src/components/Initializers/initializerFormatting.test.ts new file mode 100644 index 0000000000..cd36b5a9b0 --- /dev/null +++ b/frontend/src/components/Initializers/initializerFormatting.test.ts @@ -0,0 +1,52 @@ +import type { RegisteredInitializer } from '@/types' + +import { formatInitializerParameters, formatSupportedParameterSummary } from './initializerFormatting' + +describe('formatInitializerParameters', () => { + it('renders an empty object for null or undefined parameters', () => { + expect(formatInitializerParameters(null)).toBe('{}') + expect(formatInitializerParameters(undefined)).toBe('{}') + }) + + it('pretty-prints the provided parameters as indented JSON', () => { + expect(formatInitializerParameters({ days: 7, tags: ['a', 'b'] })).toBe( + JSON.stringify({ days: 7, tags: ['a', 'b'] }, null, 2), + ) + }) +}) + +describe('formatSupportedParameterSummary', () => { + const baseInitializer: RegisteredInitializer = { + initializer_name: 'refresh_datasets', + initializer_type: 'DatasetInitializer', + description: 'Refreshes datasets.', + required_env_vars: [], + supported_parameters: [], + } + + it('reports when an initializer declares no parameters', () => { + expect(formatSupportedParameterSummary(baseInitializer)).toEqual(['No declared parameters.']) + }) + + it('summarizes each parameter with its type and required/optional label', () => { + const initializer: RegisteredInitializer = { + ...baseInitializer, + supported_parameters: [ + { name: 'days', type_name: 'int', required: true, default: null, choices: null, is_list: false }, + { + name: 'dataset_names', + type_name: 'list[str]', + required: false, + default: null, + choices: null, + is_list: true, + }, + ], + } + + expect(formatSupportedParameterSummary(initializer)).toEqual([ + 'days (int, required)', + 'dataset_names (list[str], optional)', + ]) + }) +}) diff --git a/frontend/src/components/Initializers/initializerFormatting.ts b/frontend/src/components/Initializers/initializerFormatting.ts new file mode 100644 index 0000000000..385ef67f1d --- /dev/null +++ b/frontend/src/components/Initializers/initializerFormatting.ts @@ -0,0 +1,16 @@ +import type { RegisteredInitializer } from '@/types' + +export function formatInitializerParameters(parameters?: Record | null): string { + return JSON.stringify(parameters ?? {}, null, 2) +} + +export function formatSupportedParameterSummary(initializer: RegisteredInitializer): string[] { + if (initializer.supported_parameters.length === 0) { + return ['No declared parameters.'] + } + + return initializer.supported_parameters.map((parameter) => { + const requiredLabel = parameter.required ? 'required' : 'optional' + return `${parameter.name} (${parameter.type_name}, ${requiredLabel})` + }) +} diff --git a/frontend/src/components/Initializers/initializerLookup.test.ts b/frontend/src/components/Initializers/initializerLookup.test.ts new file mode 100644 index 0000000000..cecf5e57bc --- /dev/null +++ b/frontend/src/components/Initializers/initializerLookup.test.ts @@ -0,0 +1,47 @@ +import type { RegisteredInitializer } from '@/types' + +import { resolveRegisteredInitializer } from './initializerLookup' + +const registered: RegisteredInitializer[] = [ + { + initializer_name: 'target', + initializer_type: 'TargetInitializer', + description: 'Registers targets.', + required_env_vars: ['AZURE_OPENAI_ENDPOINT'], + supported_parameters: [], + }, + { + initializer_name: 'scorer', + initializer_type: 'ScorerInitializer', + description: 'Registers scorers.', + required_env_vars: [], + supported_parameters: [], + }, +] + +describe('resolveRegisteredInitializer', () => { + it('returns the matching catalog entry by name', () => { + const result = resolveRegisteredInitializer('scorer', registered) + + expect(result).toBe(registered[1]) + }) + + it('returns an "no longer registered" placeholder when the name is unknown', () => { + const result = resolveRegisteredInitializer('ghost', registered) + + expect(result).toEqual({ + initializer_name: 'ghost', + initializer_type: 'UnknownInitializer', + description: 'Initializer is no longer registered.', + required_env_vars: [], + supported_parameters: [], + }) + }) + + it('returns a placeholder when the catalog is empty', () => { + const result = resolveRegisteredInitializer('target', []) + + expect(result.initializer_name).toBe('target') + expect(result.initializer_type).toBe('UnknownInitializer') + }) +}) diff --git a/frontend/src/components/Initializers/initializerLookup.ts b/frontend/src/components/Initializers/initializerLookup.ts new file mode 100644 index 0000000000..73b3d889b1 --- /dev/null +++ b/frontend/src/components/Initializers/initializerLookup.ts @@ -0,0 +1,26 @@ +import type { RegisteredInitializer } from '@/types' + +/** + * Resolve a settings entry's `initializer_name` to its catalog definition. + * + * Settings reference an initializer by name; the catalog (from `listRegistered`) + * is the single source of truth for display metadata. When a persisted name is no + * longer registered, return a placeholder so the row still renders. + */ +export function resolveRegisteredInitializer( + initializerName: string, + registeredInitializers: RegisteredInitializer[], +): RegisteredInitializer { + const match = registeredInitializers.find((item) => item.initializer_name === initializerName) + if (match) { + return match + } + + return { + initializer_name: initializerName, + initializer_type: 'UnknownInitializer', + description: 'Initializer is no longer registered.', + required_env_vars: [], + supported_parameters: [], + } +} diff --git a/frontend/src/components/Initializers/initializerParameterForm.test.ts b/frontend/src/components/Initializers/initializerParameterForm.test.ts new file mode 100644 index 0000000000..13bb0ae2db --- /dev/null +++ b/frontend/src/components/Initializers/initializerParameterForm.test.ts @@ -0,0 +1,161 @@ +import type { Parameter } from '@/types' + +import { + buildParametersFromForm, + getInitialFormValues, + getParameterControlKind, +} from './initializerParameterForm' + +function makeParameter(overrides: Partial & { name: string }): Parameter { + return { + type_name: 'str', + required: false, + default: null, + choices: null, + is_list: false, + description: null, + ...overrides, + } +} + +describe('getParameterControlKind', () => { + it('returns boolean for bool parameters', () => { + expect(getParameterControlKind(makeParameter({ name: 'flag', type_name: 'bool' }))).toBe('boolean') + }) + + it('returns multiselect for a constrained list', () => { + const param = makeParameter({ name: 'tags', type_name: 'list[str]', is_list: true, choices: ['a', 'b'] }) + expect(getParameterControlKind(param)).toBe('multiselect') + }) + + it('returns select for a constrained scalar', () => { + const param = makeParameter({ name: 'mode', choices: ['fast', 'slow'] }) + expect(getParameterControlKind(param)).toBe('select') + }) + + it('returns list for an unconstrained list', () => { + const param = makeParameter({ name: 'names', type_name: 'list[str]', is_list: true }) + expect(getParameterControlKind(param)).toBe('list') + }) + + it('returns number for int and float parameters', () => { + expect(getParameterControlKind(makeParameter({ name: 'days', type_name: 'int' }))).toBe('number') + expect(getParameterControlKind(makeParameter({ name: 'ratio', type_name: 'float' }))).toBe('number') + }) + + it('returns text as the default', () => { + expect(getParameterControlKind(makeParameter({ name: 'label' }))).toBe('text') + }) +}) + +describe('getInitialFormValues', () => { + it('derives boolean strings from the provided value and the default', () => { + const params = [ + makeParameter({ name: 'a', type_name: 'bool' }), + makeParameter({ name: 'b', type_name: 'bool', default: 'true' }), + makeParameter({ name: 'c', type_name: 'bool' }), + ] + const values = getInitialFormValues(params, { a: true }) + expect(values).toEqual({ a: 'true', b: 'true', c: 'false' }) + }) + + it('derives multiselect arrays and list strings', () => { + const params = [ + makeParameter({ name: 'tags', type_name: 'list[str]', is_list: true, choices: ['x', 'y'] }), + makeParameter({ name: 'names', type_name: 'list[str]', is_list: true }), + ] + const values = getInitialFormValues(params, { tags: ['x'], names: ['one', 'two'] }) + expect(values).toEqual({ tags: ['x'], names: 'one, two' }) + }) + + it('stringifies scalar values and defaults to empty strings', () => { + const params = [ + makeParameter({ name: 'days', type_name: 'int' }), + makeParameter({ name: 'label' }), + ] + expect(getInitialFormValues(params, { days: 7 })).toEqual({ days: '7', label: '' }) + }) +}) + +describe('buildParametersFromForm', () => { + it('returns null when nothing is provided', () => { + const params = [makeParameter({ name: 'label' })] + const result = buildParametersFromForm(params, { label: ' ' }) + expect(result).toEqual({ ok: true, parameters: null }) + }) + + it('coerces a valid integer', () => { + const params = [makeParameter({ name: 'days', type_name: 'int' })] + const result = buildParametersFromForm(params, { days: '7' }) + expect(result).toEqual({ ok: true, parameters: { days: 7 } }) + }) + + it('rejects a non-numeric value', () => { + const params = [makeParameter({ name: 'days', type_name: 'int' })] + const result = buildParametersFromForm(params, { days: 'seven' }) + expect(result).toEqual({ ok: false, error: 'days must be a number.' }) + }) + + it('rejects a non-integer for an int parameter', () => { + const params = [makeParameter({ name: 'days', type_name: 'int' })] + const result = buildParametersFromForm(params, { days: '1.5' }) + expect(result).toEqual({ ok: false, error: 'days must be an integer.' }) + }) + + it('splits a comma-separated list', () => { + const params = [makeParameter({ name: 'names', type_name: 'list[str]', is_list: true })] + const result = buildParametersFromForm(params, { names: 'a, b ,, c' }) + expect(result).toEqual({ ok: true, parameters: { names: ['a', 'b', 'c'] } }) + }) + + it('keeps selected multiselect choices', () => { + const params = [ + makeParameter({ name: 'tags', type_name: 'list[str]', is_list: true, choices: ['a', 'b'] }), + ] + const result = buildParametersFromForm(params, { tags: ['a', 'b'] }) + expect(result).toEqual({ ok: true, parameters: { tags: ['a', 'b'] } }) + }) + + it('rejects a multiselect value outside the allowed set', () => { + const params = [ + makeParameter({ name: 'tags', type_name: 'list[str]', is_list: true, choices: ['a', 'b'] }), + ] + const result = buildParametersFromForm(params, { tags: ['a', 'c'] }) + expect(result).toEqual({ ok: false, error: 'tags: "c" is not an allowed value.' }) + }) + + it('rejects a select value outside the allowed set', () => { + const params = [makeParameter({ name: 'mode', choices: ['fast', 'slow'] })] + const result = buildParametersFromForm(params, { mode: 'medium' }) + expect(result).toEqual({ ok: false, error: 'mode: "medium" is not an allowed value.' }) + }) + + it('coerces booleans', () => { + const params = [ + makeParameter({ name: 'on', type_name: 'bool' }), + makeParameter({ name: 'off', type_name: 'bool' }), + ] + const result = buildParametersFromForm(params, { on: 'true', off: 'false' }) + expect(result).toEqual({ ok: true, parameters: { on: true, off: false } }) + }) + + it('reports a required parameter with no value', () => { + const params = [makeParameter({ name: 'label', required: true })] + const result = buildParametersFromForm(params, { label: '' }) + expect(result).toEqual({ ok: false, error: 'label is required.' }) + }) + + it('reports a required list with no entries', () => { + const params = [makeParameter({ name: 'names', type_name: 'list[str]', is_list: true, required: true })] + const result = buildParametersFromForm(params, { names: '' }) + expect(result).toEqual({ ok: false, error: 'names is required.' }) + }) + + it('reports a required multiselect with no selection', () => { + const params = [ + makeParameter({ name: 'tags', type_name: 'list[str]', is_list: true, choices: ['a'], required: true }), + ] + const result = buildParametersFromForm(params, { tags: [] }) + expect(result).toEqual({ ok: false, error: 'tags is required.' }) + }) +}) diff --git a/frontend/src/components/Initializers/initializerParameterForm.ts b/frontend/src/components/Initializers/initializerParameterForm.ts new file mode 100644 index 0000000000..9f3db130ec --- /dev/null +++ b/frontend/src/components/Initializers/initializerParameterForm.ts @@ -0,0 +1,155 @@ +import type { Parameter } from '@/types' + +/** The control rendered for a parameter, derived from its declared metadata. */ +export type ParameterControlKind = 'boolean' | 'select' | 'multiselect' | 'list' | 'number' | 'text' + +/** Form state value for a single parameter. Multiselect holds the selected choices; everything else is a raw string. */ +export type ParameterFormValue = string | string[] + +export function getParameterControlKind(param: Parameter): ParameterControlKind { + if (param.type_name === 'bool') { + return 'boolean' + } + const hasChoices = (param.choices?.length ?? 0) > 0 + if (param.is_list && hasChoices) { + return 'multiselect' + } + if (hasChoices) { + return 'select' + } + if (param.is_list) { + return 'list' + } + if (param.type_name === 'int' || param.type_name === 'float') { + return 'number' + } + return 'text' +} + +function parseListValue(raw: string): string[] { + return raw + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) +} + +function initialBooleanValue(param: Parameter, initial: unknown): string { + if (initial != null) { + return String(initial).toLowerCase() === 'true' ? 'true' : 'false' + } + if (param.default != null) { + return param.default.toLowerCase() === 'true' ? 'true' : 'false' + } + return 'false' +} + +export function getInitialFormValues( + params: Parameter[], + initialParameters?: Record | null, +): Record { + const values: Record = {} + for (const param of params) { + const initial = initialParameters?.[param.name] + switch (getParameterControlKind(param)) { + case 'boolean': + values[param.name] = initialBooleanValue(param, initial) + break + case 'multiselect': + values[param.name] = Array.isArray(initial) ? initial.map((entry) => String(entry)) : [] + break + case 'list': + values[param.name] = Array.isArray(initial) + ? initial.map((entry) => String(entry)).join(', ') + : initial != null + ? String(initial) + : '' + break + default: + values[param.name] = initial != null ? String(initial) : '' + break + } + } + return values +} + +export type BuildParametersResult = + | { ok: true; parameters: Record | null } + | { ok: false; error: string } + +export function buildParametersFromForm( + params: Parameter[], + values: Record, +): BuildParametersResult { + const parameters: Record = {} + + for (const param of params) { + const value = values[param.name] + const kind = getParameterControlKind(param) + + if (kind === 'boolean') { + parameters[param.name] = value === 'true' + continue + } + + if (kind === 'multiselect') { + const selected = Array.isArray(value) ? value : [] + const invalid = selected.find((entry) => !(param.choices ?? []).includes(entry)) + if (invalid != null) { + return { ok: false, error: `${param.name}: "${invalid}" is not an allowed value.` } + } + if (selected.length === 0) { + if (param.required) { + return { ok: false, error: `${param.name} is required.` } + } + continue + } + parameters[param.name] = selected + continue + } + + const raw = typeof value === 'string' ? value.trim() : '' + + if (kind === 'list') { + const entries = parseListValue(raw) + if (entries.length === 0) { + if (param.required) { + return { ok: false, error: `${param.name} is required.` } + } + continue + } + parameters[param.name] = entries + continue + } + + if (raw.length === 0) { + if (param.required) { + return { ok: false, error: `${param.name} is required.` } + } + continue + } + + if (kind === 'select') { + if (!(param.choices ?? []).includes(raw)) { + return { ok: false, error: `${param.name}: "${raw}" is not an allowed value.` } + } + parameters[param.name] = raw + continue + } + + if (kind === 'number') { + const parsed = Number(raw) + if (!Number.isFinite(parsed)) { + return { ok: false, error: `${param.name} must be a number.` } + } + if (param.type_name === 'int' && !Number.isInteger(parsed)) { + return { ok: false, error: `${param.name} must be an integer.` } + } + parameters[param.name] = parsed + continue + } + + parameters[param.name] = raw + } + + return { ok: true, parameters: Object.keys(parameters).length > 0 ? parameters : null } +} diff --git a/frontend/src/components/Sidebar/Navigation.tsx b/frontend/src/components/Sidebar/Navigation.tsx index bfbadf0bce..218635db9f 100644 --- a/frontend/src/components/Sidebar/Navigation.tsx +++ b/frontend/src/components/Sidebar/Navigation.tsx @@ -14,6 +14,7 @@ import { SettingsRegular, HistoryRegular, PersonFeedbackRegular, + WrenchRegular, OpenRegular, WeatherMoonRegular, WeatherSunnyRegular, @@ -22,7 +23,7 @@ import { useTheme } from '../../hooks/useTheme' import type { ThemeMode } from '../../hooks/useTheme' import { useNavigationStyles } from './Navigation.styles' -export type ViewName = 'home' | 'chat' | 'history' | 'config' +export type ViewName = 'home' | 'chat' | 'history' | 'config' | 'initializers' interface NavigationProps { currentView: ViewName @@ -103,6 +104,17 @@ export default function Navigation({ currentView, onNavigate, onOpenFeedback }: aria-current={currentView === 'config' ? 'page' : undefined} onClick={() => onNavigate('config')} /> + +