diff --git a/src/platform/packages/shared/kbn-management/settings/setting_ids/index.ts b/src/platform/packages/shared/kbn-management/settings/setting_ids/index.ts index d85717ab042f3..928ed6d47dc73 100644 --- a/src/platform/packages/shared/kbn-management/settings/setting_ids/index.ts +++ b/src/platform/packages/shared/kbn-management/settings/setting_ids/index.ts @@ -123,6 +123,9 @@ export const OBSERVABILITY_AI_ASSISTANT_SIMULATED_FUNCTION_CALLING = 'observability:aiAssistantSimulatedFunctionCalling'; export const OBSERVABILITY_AI_ASSISTANT_SEARCH_CONNECTOR_INDEX_PATTERN = 'observability:aiAssistantSearchConnectorIndexPattern'; +export const GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR = 'genAiSettings:defaultAIConnector'; +export const GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY = + 'genAiSettings:defaultAIConnectorOnly'; export const AI_ASSISTANT_PREFERRED_AI_ASSISTANT_TYPE = 'aiAssistant:preferredAIAssistantType'; export const AI_ANONYMIZATION_SETTINGS = 'ai:anonymizationSettings'; export const OBSERVABILITY_SEARCH_EXCLUDED_DATA_TIERS = 'observability:searchExcludedDataTiers'; diff --git a/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/schema.ts b/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/schema.ts index e1fb3a273139d..7d295d3a48681 100644 --- a/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/schema.ts @@ -696,4 +696,16 @@ export const stackManagementSchema: MakeSchemaFrom = { description: 'Enable diagnostic mode', }, }, + 'genAiSettings:defaultAIConnector': { + type: 'keyword', + _meta: { + description: 'Default AI connector', + }, + }, + 'genAiSettings:defaultAIConnectorOnly': { + type: 'boolean', + _meta: { + description: 'Restrict to default AI connector only', + }, + }, }; diff --git a/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/types.ts b/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/types.ts index 31f1da6379f55..6281b1bc43009 100644 --- a/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/types.ts @@ -178,5 +178,7 @@ export interface UsageStats { 'observability:enableStreamsUI': boolean; 'observability:enableDiagnosticMode': boolean; 'observability:streamsEnableSignificantEvents': boolean; + 'genAiSettings:defaultAIConnector': string; + 'genAiSettings:defaultAIConnectorOnly': boolean; 'observability:streamsEnableGroupStreams': boolean; } diff --git a/src/platform/plugins/shared/telemetry/schema/oss_platform.json b/src/platform/plugins/shared/telemetry/schema/oss_platform.json index f2ebb06b3b804..3a7c999d72a26 100644 --- a/src/platform/plugins/shared/telemetry/schema/oss_platform.json +++ b/src/platform/plugins/shared/telemetry/schema/oss_platform.json @@ -11071,6 +11071,18 @@ "_meta": { "description": "Enable diagnostic mode" } + }, + "genAiSettings:defaultAIConnector": { + "type": "keyword", + "_meta": { + "description": "Default AI connector" + } + }, + "genAiSettings:defaultAIConnectorOnly": { + "type": "boolean", + "_meta": { + "description": "Restrict to default AI connector only" + } } } }, diff --git a/x-pack/platform/plugins/private/gen_ai_settings/common/constants.ts b/x-pack/platform/plugins/private/gen_ai_settings/common/constants.ts new file mode 100644 index 0000000000000..1cfad15d32c6f --- /dev/null +++ b/x-pack/platform/plugins/private/gen_ai_settings/common/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const NO_DEFAULT_CONNECTOR = 'NO_DEFAULT_CONNECTOR'; diff --git a/x-pack/platform/plugins/private/gen_ai_settings/public/components/bottom_bar_actions/bottom_bar_actions.test.tsx b/x-pack/platform/plugins/private/gen_ai_settings/public/components/bottom_bar_actions/bottom_bar_actions.test.tsx new file mode 100644 index 0000000000000..bc23ef4e63716 --- /dev/null +++ b/x-pack/platform/plugins/private/gen_ai_settings/public/components/bottom_bar_actions/bottom_bar_actions.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen } from '@testing-library/react'; +import { BottomBarActions } from './bottom_bar_actions'; +import React from 'react'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; + +describe('bottom_bar_actions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + function Providers({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); + } + + it('renders correctly', () => { + const onDiscardChanges = jest.fn(); + const onSave = jest.fn(); + render( + , + { wrapper: Providers } + ); + + expect(screen.getByTestId('genAiSettingsBottomBar')).toBeInTheDocument(); + expect(screen.getByText('5 unsaved changes')).toBeInTheDocument(); + expect(screen.getByText('Save Changes')).toBeInTheDocument(); + expect(screen.getByText('Discard changes')).toBeInTheDocument(); + + expect(onDiscardChanges).not.toHaveBeenCalled(); + screen.getByText('Discard changes').click(); + expect(onDiscardChanges).toHaveBeenCalled(); + + expect(onSave).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/plugins/private/gen_ai_settings/public/components/bottom_bar_actions/bottom_bar_actions.tsx b/x-pack/platform/plugins/private/gen_ai_settings/public/components/bottom_bar_actions/bottom_bar_actions.tsx new file mode 100644 index 0000000000000..3cd7aecb57141 --- /dev/null +++ b/x-pack/platform/plugins/private/gen_ai_settings/public/components/bottom_bar_actions/bottom_bar_actions.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { + EuiBottomBar, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiHealth, + EuiText, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +interface Props { + unsavedChangesCount: number; + isLoading: boolean; + onDiscardChanges: () => void; + onSave: () => void; + saveLabel: string; + appTestSubj: string; + areChangesInvalid?: boolean; +} + +export const BottomBarActions = ({ + isLoading, + onDiscardChanges, + onSave, + unsavedChangesCount, + saveLabel, + appTestSubj, + areChangesInvalid = false, +}: Props) => { + return ( + + + + + + + + + + + + + + + + + + + {saveLabel} + + + + + + + + ); +}; diff --git a/x-pack/platform/plugins/private/gen_ai_settings/public/components/default_ai_connector/default_ai_connector.test.tsx b/x-pack/platform/plugins/private/gen_ai_settings/public/components/default_ai_connector/default_ai_connector.test.tsx new file mode 100644 index 0000000000000..210ad09372168 --- /dev/null +++ b/x-pack/platform/plugins/private/gen_ai_settings/public/components/default_ai_connector/default_ai_connector.test.tsx @@ -0,0 +1,204 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, render, screen } from '@testing-library/react'; +import { DefaultAIConnector } from './default_ai_connector'; +import React from 'react'; +import { SettingsContextProvider, useSettingsContext } from '../../contexts/settings_context'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { I18nProvider } from '@kbn/i18n-react'; +import userEvent from '@testing-library/user-event'; + +function SettingsProbe({ onValue }: { onValue: (v: any) => void }) { + const value = useSettingsContext(); + React.useEffect(() => { + onValue(value); + }, [value, onValue]); + return null; +} + +const mockConnectors = { + loading: false, + reload: jest.fn(), + connectors: [ + { + actionTypeId: 'pre-configured.1', + id: 'pre-configured1', + isDeprecated: false, + isPreconfigured: true, + isSystemAction: false, + name: 'Pre configured Connector', + referencedByCount: 0, + }, + { + actionTypeId: 'custom.1', + id: 'custom1', + isDeprecated: false, + isPreconfigured: false, + isSystemAction: false, + name: 'Custom Connector 1', + referencedByCount: 0, + }, + ], +}; + +function setupTest() { + const queryClient = new QueryClient(); + let settingsValue: ReturnType | undefined; + + const utils = render( + <> + + (settingsValue = v)} /> + , + { + wrapper: ({ children }) => ( + + + {children} + + + ), + } + ); + + return { + ...utils, + settingsValue: () => settingsValue, + }; +} + +describe('DefaultAIConnector', () => { + describe('rendering', () => { + it('renders all component elements correctly', () => { + const { container } = setupTest(); + + expect(screen.getByText('genAiSettings:defaultAIConnector')).toBeInTheDocument(); + expect(screen.getByText('Disallow all other connectors')).toBeInTheDocument(); + expect(screen.getByTestId('defaultAiConnectorComboBox')).toBeInTheDocument(); + expect(screen.getByTestId('defaultAiConnectorCheckbox')).toBeInTheDocument(); + + expect(screen.getByTestId('comboBoxSearchInput')).toHaveAttribute( + 'value', + 'No default connector' + ); + expect(container.querySelector('[class$="square-unselected"]')).not.toBeNull(); + }); + }); + + describe('combobox interaction', () => { + it('shows connector options when clicked', async () => { + setupTest(); + + act(() => { + screen.getByTestId('comboBoxSearchInput').click(); + }); + + await userEvent.click(screen.getByTestId('comboBoxSearchInput')); + + expect(screen.getByText('Pre configured Connector')).toBeVisible(); + expect(screen.getByText('Custom Connector 1')).toBeVisible(); + + expect( + // eslint-disable-next-line no-bitwise + screen + .getByText('Pre-configured') + .compareDocumentPosition(screen.getByText('Pre configured Connector')) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + // eslint-disable-next-line no-bitwise + screen + .getByText('Custom connectors') + .compareDocumentPosition(screen.getByText('Custom Connector 1')) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + + expect( + // eslint-disable-next-line no-bitwise + screen + .getByText('Pre configured Connector') + .compareDocumentPosition(screen.getByText('Custom connectors')) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('updates selection when connector is chosen', async () => { + setupTest(); + + act(() => { + screen.getByTestId('comboBoxSearchInput').click(); + }); + + await userEvent.click(screen.getByTestId('comboBoxSearchInput')); + await userEvent.click(screen.getByText('Custom Connector 1')); + + expect(screen.getByTestId('comboBoxSearchInput')).toHaveAttribute( + 'value', + 'Custom Connector 1' + ); + }); + }); + + describe('checkbox interaction', () => { + it('updates checkbox state when clicked', async () => { + const { container } = setupTest(); + + expect(container.querySelector('[class$="square-unselected"]')).not.toBeNull(); + + await userEvent.click(screen.getByTestId('defaultAiConnectorCheckbox')); + + expect(container.querySelector('[class$="square-selected"]')).not.toBeNull(); + }); + }); + + describe('settings context integration', () => { + it('adds connector selection to unsaved changes', async () => { + const { settingsValue } = setupTest(); + + act(() => { + screen.getByTestId('comboBoxSearchInput').click(); + }); + await userEvent.click(screen.getByTestId('comboBoxSearchInput')); + await userEvent.click(screen.getByText('Custom Connector 1')); + + await userEvent.click(screen.getByTestId('defaultAiConnectorCheckbox')); + + expect(settingsValue()!.unsavedChanges).toEqual({ + 'genAiSettings:defaultAIConnector': { + type: 'string', + unsavedValue: 'custom1', + }, + 'genAiSettings:defaultAIConnectorOnly': { + type: 'boolean', + unsavedValue: true, + }, + }); + }); + + it('reverts UI state when changes are discarded', async () => { + const { container, settingsValue } = setupTest(); + + act(() => { + screen.getByTestId('comboBoxSearchInput').click(); + }); + await userEvent.click(screen.getByTestId('comboBoxSearchInput')); + await userEvent.click(screen.getByText('Custom Connector 1')); + await userEvent.click(screen.getByTestId('defaultAiConnectorCheckbox')); + + act(() => { + settingsValue()!.cleanUnsavedChanges(); + }); + + expect(screen.getByTestId('comboBoxSearchInput')).toHaveAttribute( + 'value', + 'No default connector' + ); + expect(container.querySelector('[class$="square-unselected"]')).not.toBeNull(); + }); + }); +}); diff --git a/x-pack/platform/plugins/private/gen_ai_settings/public/components/default_ai_connector/default_ai_connector.tsx b/x-pack/platform/plugins/private/gen_ai_settings/public/components/default_ai_connector/default_ai_connector.tsx new file mode 100644 index 0000000000000..d36084e7b3c77 --- /dev/null +++ b/x-pack/platform/plugins/private/gen_ai_settings/public/components/default_ai_connector/default_ai_connector.tsx @@ -0,0 +1,257 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import { + EuiCheckbox, + EuiComboBox, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIconTip, +} from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR, + GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY, +} from '@kbn/management-settings-ids'; +import type { FieldDefinition, UnsavedFieldChange } from '@kbn/management-settings-types'; +import type { UiSettingsType } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import type { UseGenAiConnectorsResult } from '../../hooks/use_genai_connectors'; +import { useSettingsContext } from '../../contexts/settings_context'; +import { NO_DEFAULT_CONNECTOR } from '../../../common/constants'; +import { useKibana } from '../../hooks/use_kibana'; + +interface Props { + connectors: UseGenAiConnectorsResult; +} + +const NoDefaultOption: EuiComboBoxOptionOption = { + label: i18n.translate( + 'xpack.gen_ai_settings.settings.defaultLLm.select.option.noDefaultConnector', + { defaultMessage: 'No default connector' } + ), + value: NO_DEFAULT_CONNECTOR, +}; + +const getOptions = (connectors: UseGenAiConnectorsResult): EuiComboBoxOptionOption[] => { + const preconfigured = + connectors.connectors + ?.filter((connector) => connector.isPreconfigured) + .map((connector) => ({ + label: connector.name, + value: connector.id, + })) ?? []; + + const custom = + connectors.connectors + ?.filter((connector) => !connector.isPreconfigured) + .map((connector) => ({ + label: connector.name, + value: connector.id, + })) ?? []; + + return [ + NoDefaultOption, + { + label: i18n.translate( + 'xpack.gen_ai_settings.settings.defaultLLm.select.group.preconfigured.label', + { defaultMessage: 'Pre-configured' } + ), + value: 'preconfigured', + options: preconfigured, + }, + { + label: i18n.translate('xpack.gen_ai_settings.settings.defaultLLm.select.group.custom.label', { + defaultMessage: 'Custom connectors', + }), + value: 'custom', + options: custom, + }, + ]; +}; + +const getOptionsByValues = ( + value: string, + options: EuiComboBoxOptionOption[] +): EuiComboBoxOptionOption[] => { + const getOptionsByValuesHelper = ( + option: EuiComboBoxOptionOption + ): EuiComboBoxOptionOption[] => { + if (option.options === undefined && option.value === value) { + // If the option has no sub-options and its value is in the selected values, include it + return [option]; + } + if (option.options) { + // If the option has sub-options, recursively get their options + return option.options.flatMap(getOptionsByValuesHelper); + } + return []; + }; + + return options.flatMap(getOptionsByValuesHelper); +}; + +export const DefaultAIConnector: React.FC = ({ connectors }) => { + const options = useMemo(() => getOptions(connectors), [connectors]); + const { handleFieldChange, fields, unsavedChanges } = useSettingsContext(); + const { services } = useKibana(); + const { notifications } = services; + + const onChangeDefaultLlm = (selectedOptions: EuiComboBoxOptionOption[]) => { + const values = selectedOptions.map((option) => option.value); + if (values.length > 1) { + notifications.toasts.addDanger({ + title: i18n.translate( + 'xpack.observabilityAiAssistantManagement.defaultLlm.onChange.error.multipleSelected.title', + { + defaultMessage: 'An error occurred while changing the setting', + } + ), + text: i18n.translate( + 'xpack.observabilityAiAssistantManagement.defaultLlm.onChange.error.multipleSelected.text', + { + defaultMessage: 'Only one default AI connector can be selected', + } + ), + }); + throw new Error('Only one default AI connector can be selected'); + } + const value = values[0] ?? NO_DEFAULT_CONNECTOR; + + if (value === fields[GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR]?.savedValue) { + handleFieldChange(GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR); + return; + } + + handleFieldChange(GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR, { + type: 'string', + unsavedValue: value, + }); + }; + + const onChangeDefaultOnly = (checked: boolean) => { + if (checked === fields[GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY]?.savedValue) { + handleFieldChange(GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY); + return; + } + + handleFieldChange(GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY, { + type: 'boolean', + unsavedValue: checked, + }); + }; + + const defaultLlmValues = getDefaultLlmValue(unsavedChanges, fields); + + const selectedOptions = useMemo( + () => getOptionsByValues(defaultLlmValues, options), + [defaultLlmValues, options] + ); + + const defaultLlmOnlyValue = getDefaultLlmOnlyValue(unsavedChanges, fields); + + return ( + <> + + + + + + + + + } + checked={defaultLlmOnlyValue} + onChange={(e) => onChangeDefaultOnly(e.target.checked)} + /> + + + + + + + + + ); +}; + +/** + * Gets current value for the default LLM connector. First checks for unsaved changes, then saved, then default. + */ +function getDefaultLlmValue( + unsavedChanges: Record>, + fields: Record> +) { + const defaultLlmUnsavedValue = unsavedChanges[GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR] + ?.unsavedValue as string | undefined; + const defaultLlmSavedValue = fields[GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR]?.savedValue as + | string + | undefined; + const defaultLlmDefaultValue = fields[GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR]?.defaultValue as + | string + | undefined; + + const defaultLlmValue = + defaultLlmUnsavedValue ?? + defaultLlmSavedValue ?? + defaultLlmDefaultValue ?? + NO_DEFAULT_CONNECTOR; + return defaultLlmValue; +} + +/** + * Gets current value for the default LLM only setting. First checks for unsaved changes, then saved, then default. + */ +function getDefaultLlmOnlyValue( + unsavedChanges: Record>, + fields: Record> +): boolean { + const defaultLlmOnlyUnsavedValue = unsavedChanges[ + GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY + ]?.unsavedValue as boolean | undefined; + const defaultLlmOnlySavedValue = fields[GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY] + ?.savedValue as boolean | undefined; + const defaultLlmOnlyDefaultValue = fields[GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY] + ?.defaultValue as boolean | undefined; + + const defaultLlmOnlyValue = + defaultLlmOnlyUnsavedValue ?? defaultLlmOnlySavedValue ?? defaultLlmOnlyDefaultValue ?? false; + return defaultLlmOnlyValue; +} diff --git a/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.test.tsx b/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.test.tsx index 98d8a769563ab..8c5b435266685 100644 --- a/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.test.tsx +++ b/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.test.tsx @@ -12,6 +12,8 @@ import { coreMock } from '@kbn/core/public/mocks'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { GenAiSettingsApp } from './gen_ai_settings_app'; import { useEnabledFeatures } from '../contexts/enabled_features_context'; +import { SettingsContextProvider } from '../contexts/settings_context'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; // Mock the context hook jest.mock('../contexts/enabled_features_context'); @@ -43,9 +45,13 @@ describe('GenAiSettingsApp', () => { const renderComponent = (props = {}) => { return renderWithI18n( - - - + + + + + + + ); }; @@ -91,7 +97,8 @@ describe('GenAiSettingsApp', () => { // Connectors section expect(screen.getByTestId('connectorsSection')).toBeInTheDocument(); expect(screen.getByTestId('connectorsTitle')).toBeInTheDocument(); - expect(screen.getByTestId('manageConnectorsLink')).toBeInTheDocument(); + expect(screen.getByTestId('defaultAiConnectorComboBox')).toBeInTheDocument(); + expect(screen.getByTestId('defaultAiConnectorCheckbox')).toBeInTheDocument(); // Feature visibility section (with default settings) expect(screen.getByTestId('aiFeatureVisibilitySection')).toBeInTheDocument(); diff --git a/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.tsx b/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.tsx index 5cd78ce6973e2..19ed48c8acd3c 100644 --- a/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.tsx +++ b/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.tsx @@ -17,7 +17,6 @@ import { EuiIcon, EuiTitle, EuiLink, - EuiButton, useEuiTheme, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -25,11 +24,15 @@ import { FormattedMessage } from '@kbn/i18n-react'; import type { ManagementAppMountParams } from '@kbn/management-plugin/public'; import { getSpaceIdFromPath } from '@kbn/spaces-utils'; +import { isEmpty } from 'lodash'; import { useEnabledFeatures } from '../contexts/enabled_features_context'; import { useKibana } from '../hooks/use_kibana'; import { GoToSpacesButton } from './go_to_spaces_button'; import { useGenAiConnectors } from '../hooks/use_genai_connectors'; import { getElasticManagedLlmConnector } from '../utils/get_elastic_managed_llm_connector'; +import { useSettingsContext } from '../contexts/settings_context'; +import { DefaultAIConnector } from './default_ai_connector/default_ai_connector'; +import { BottomBarActions } from './bottom_bar_actions/bottom_bar_actions'; interface GenAiSettingsAppProps { setBreadcrumbs: ManagementAppMountParams['setBreadcrumbs']; @@ -37,9 +40,10 @@ interface GenAiSettingsAppProps { export const GenAiSettingsApp: React.FC = ({ setBreadcrumbs }) => { const { services } = useKibana(); - const { application, http, docLinks } = services; + const { application, http, docLinks, notifications } = services; const { showSpacesIntegration, isPermissionsBased, showAiBreadcrumb } = useEnabledFeatures(); const { euiTheme } = useEuiTheme(); + const { unsavedChanges, isSaving, cleanUnsavedChanges, saveAll } = useSettingsContext(); const hasConnectorsAllPrivilege = application.capabilities.actions?.show === true && @@ -90,7 +94,24 @@ export const GenAiSettingsApp: React.FC = ({ setBreadcrum id="genAiSettings.aiConnectorDescription" defaultMessage={`A large language model (LLM) is required to power the AI Assistant and AI-driven features in Elastic. In order to use the AI Assistant you must ${ hasConnectorsAllPrivilege ? 'set up' : 'have' - } a Generative AI connector.`} + } a Generative AI connector. {manageConnectors}`} + values={{ + manageConnectors: ( + + + + ), + }} />

); @@ -106,7 +127,7 @@ export const GenAiSettingsApp: React.FC = ({ setBreadcrum showSpacesNote ? ' Set up your own connectors or disable the AI Assistant from the {aiFeatureVisibility} setting below.' : '' - }`} + } {manageConnectors}`} values={{ link: ( = ({ setBreadcrum /> ), + manageConnectors: ( + + + + ), elasticManagedLlm: ( = ({ setBreadcrum showSpacesIntegration, canManageSpaces, docLinks, + application, ]); - return ( -
- -

- -

-
- - - - - - - - - -

- -

-
-
- - } - description={connectorDescription} - > - - - - { - application.navigateToApp('management', { - path: 'insightsAndAlerting/triggersActionsConnectors/connectors', - openInNewTab: true, - }); - }} - > - {hasConnectorsAllPrivilege ? ( - - ) : ( - - )} - - - - -
+ async function handleSave() { + try { + await saveAll(); + } catch (e) { + const error = e as Error; + notifications.toasts.addDanger({ + title: i18n.translate('xpack.observabilityAiAssistantManagement.save.error', { + defaultMessage: 'An error occurred while saving the settings', + }), + text: error.message, + }); + throw error; + } + } - {showSpacesIntegration && canManageSpaces && } + return ( + <> +
+ +

+ +

+
- {showSpacesIntegration && canManageSpaces && ( + + - - - } - description={ -

- {isPermissionsBased ? ( - - - - ), - spaces: ( - - - - ), - rolesLink: ( - - - - ), - }} - /> - ) : ( - - - - ), - }} - /> - )} -

+ + + + + + +

+ +

+
+
+
} + description={connectorDescription} > - + + + + + - )} - - -
+ + {showSpacesIntegration && canManageSpaces && } + + {showSpacesIntegration && canManageSpaces && ( + + + + } + description={ +

+ {isPermissionsBased ? ( + + + + ), + spaces: ( + + + + ), + rolesLink: ( + + + + ), + }} + /> + ) : ( + + + + ), + }} + /> + )} +

+ } + > + + + +
+ )} + + +
+ {!isEmpty(unsavedChanges) && ( + + )} + ); }; diff --git a/x-pack/platform/plugins/private/gen_ai_settings/public/contexts/settings_context.test.tsx b/x-pack/platform/plugins/private/gen_ai_settings/public/contexts/settings_context.test.tsx new file mode 100644 index 0000000000000..7dc4a33f8ecec --- /dev/null +++ b/x-pack/platform/plugins/private/gen_ai_settings/public/contexts/settings_context.test.tsx @@ -0,0 +1,173 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook, waitFor } from '@testing-library/react'; +import { SettingsContextProvider, useSettingsContext } from './settings_context'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import type { PublicUiSettingsParams, UserProvidedValues } from '@kbn/core/public'; +import { Subject } from 'rxjs'; + +describe('settings_context', () => { + const setupSettingsContext = () => { + const queryClient = new QueryClient(); + const set = jest.fn().mockResolvedValue(undefined); + + const rendered = renderHook(() => useSettingsContext(), { + wrapper: ({ children }) => ( + new Subject(), + isOverridden: () => false, + isCustom: () => false, + set, + getAll: jest.fn().mockReturnValue({ + 'genAiSettings:defaultAIConnector': { + readonlyMode: 'ui', + value: 'NO_DEFAULT_CONNECTOR', + userValue: 'pmeClaudeV37SonnetUsEast1', + }, + 'genAiSettings:defaultAIConnectorOnly': { + readonlyMode: 'ui', + value: false, + userValue: true, + }, + } as Record>), + }, + }, + }} + > + + {children} + + + ), + }); + + return { result: rendered.result, set }; + }; + + it('should provide the correct initial state', async () => { + const { result } = setupSettingsContext(); + + await waitFor(() => { + expect(result.current.fields).toEqual( + expect.objectContaining({ + 'genAiSettings:defaultAIConnector': expect.anything(), + 'genAiSettings:defaultAIConnectorOnly': expect.anything(), + }) + ); + }); + + expect(result.current.unsavedChanges).toEqual({}); + expect(result.current.handleFieldChange).toBeInstanceOf(Function); + expect(result.current.saveAll).toBeInstanceOf(Function); + expect(result.current.cleanUnsavedChanges).toBeInstanceOf(Function); + expect(result.current.saveSingleSetting).toBeInstanceOf(Function); + }); + + it('should handle updating unsaved changes', async () => { + const { result } = setupSettingsContext(); + + await waitFor(() => { + expect(result.current.fields).toBeDefined(); + }); + + expect(result.current.unsavedChanges).toEqual({}); + + act(() => { + result.current.handleFieldChange('test', { + type: 'string', + unsavedValue: 'testValue', + }); + }); + + expect(result.current.unsavedChanges).toEqual({ + test: { + type: 'string', + unsavedValue: 'testValue', + }, + }); + }); + + it('should save unsaved changes', async () => { + const { result, set } = setupSettingsContext(); + + await waitFor(() => { + expect(result.current.fields).toBeDefined(); + }); + + act(() => { + result.current.handleFieldChange('test', { + type: 'string', + unsavedValue: 'testValue', + }); + }); + + expect(set).toHaveBeenCalledTimes(0); + + await act(async () => { + await result.current.saveAll(); + }); + + expect(set).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(result.current.unsavedChanges).toEqual({}); + }); + }); + + it('should save single setting', async () => { + const { result, set } = setupSettingsContext(); + + await waitFor(() => { + expect(result.current.fields).toBeDefined(); + }); + + expect(set).toHaveBeenCalledTimes(0); + + await act(async () => { + await result.current.saveSingleSetting({ + id: 'foo', + change: 'bar', + }); + }); + + expect(set).toHaveBeenCalledTimes(1); + }); + + it('should revert unsaved changes', async () => { + const { result } = setupSettingsContext(); + + await waitFor(() => { + expect(result.current.fields).toBeDefined(); + }); + + act(() => { + result.current.handleFieldChange('test', { + type: 'string', + unsavedValue: 'testValue', + }); + }); + + expect(result.current.unsavedChanges).toEqual({ + test: { + type: 'string', + unsavedValue: 'testValue', + }, + }); + + act(() => { + result.current.cleanUnsavedChanges(); + }); + + expect(result.current.unsavedChanges).toEqual({}); + }); +}); diff --git a/x-pack/platform/plugins/private/gen_ai_settings/public/contexts/settings_context.tsx b/x-pack/platform/plugins/private/gen_ai_settings/public/contexts/settings_context.tsx new file mode 100644 index 0000000000000..66562d3468734 --- /dev/null +++ b/x-pack/platform/plugins/private/gen_ai_settings/public/contexts/settings_context.tsx @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { createContext, useContext } from 'react'; +import type { + FieldDefinition, + OnFieldChangeFn, + UiSettingMetadata, + UnsavedFieldChange, +} from '@kbn/management-settings-types'; +import { isEmpty } from 'lodash'; +import type { IUiSettingsClient, UiSettingsType } from '@kbn/core/public'; +import { normalizeSettings } from '@kbn/management-settings-utilities'; +import { getFieldDefinition } from '@kbn/management-settings-field-definition'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR, + GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY, +} from '@kbn/management-settings-ids'; +import { useKibana } from '../hooks/use_kibana'; + +type SettingsContext = ReturnType; + +const SettingsContext = createContext(null); + +const useSettingsContext = () => { + const context = useContext(SettingsContext); + if (!context) { + throw new Error('useSettingsContext must be inside of a SettingsContextProvider.Provider.'); + } + return context; +}; + +const SETTING_KEYS = [ + GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR, + GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY, +]; + +export const SettingsContextProvider = ({ children }: { children: React.ReactNode }) => { + const value = Settings({ settingsKeys: SETTING_KEYS }); + return {children}; +}; + +function combineErrors(errors: Error[]): Error { + const message = errors.map((err) => err.message || String(err)).join('; '); + return new Error(message); +} + +function getSettingsFields({ + settingsKeys, + uiSettings, +}: { + settingsKeys: string[]; + uiSettings?: IUiSettingsClient; +}) { + if (!uiSettings) { + return {}; + } + + const uiSettingsDefinition = uiSettings.getAll(); + const normalizedSettings = normalizeSettings(uiSettingsDefinition); + + return settingsKeys.reduce>((acc, key) => { + const setting: UiSettingMetadata = normalizedSettings[key]; + if (setting) { + const field = getFieldDefinition({ + id: key, + setting, + params: { isCustom: uiSettings.isCustom(key), isOverridden: uiSettings.isOverridden(key) }, + }); + acc[key] = field; + } + return acc; + }, {}); +} + +const Settings = ({ settingsKeys }: { settingsKeys: string[] }) => { + const { + services: { settings }, + } = useKibana(); + + const [unsavedChanges, setUnsavedChanges] = React.useState>( + {} + ); + + const queryClient = useQueryClient(); + + const fieldsQuery = useQuery({ + queryKey: ['settingsFields', settingsKeys], + queryFn: async () => { + return getSettingsFields({ settingsKeys, uiSettings: settings?.client }); + }, + refetchOnWindowFocus: true, + }); + + const saveSingleSettingMutation = useMutation({ + mutationFn: async ({ + id, + change, + }: { + id: string; + change: UnsavedFieldChange['unsavedValue']; + }) => { + await settings.client.set(id, change); + queryClient.invalidateQueries({ queryKey: ['settingsFields', settingsKeys] }); + }, + }); + + const saveAllMutation = useMutation({ + mutationFn: async () => { + if (settings && !isEmpty(unsavedChanges)) { + const updateErrors: Error[] = []; + const subscription = settings.client.getUpdateErrors$().subscribe((error) => { + updateErrors.push(error); + }); + try { + await Promise.all( + Object.entries(unsavedChanges).map(([key, value]) => { + return settings.client.set(key, value.unsavedValue); + }) + ); + queryClient.invalidateQueries({ queryKey: ['settingsFields', settingsKeys] }); + cleanUnsavedChanges(); + if (updateErrors.length > 0) { + throw combineErrors(updateErrors); + } + } catch (e) { + throw e; + } finally { + if (subscription) { + subscription.unsubscribe(); + } + } + } + }, + }); + + const handleFieldChange: OnFieldChangeFn = (id, change) => { + if (!change) { + const { [id]: unsavedChange, ...rest } = unsavedChanges; + setUnsavedChanges(rest); + return; + } + setUnsavedChanges((changes) => ({ ...changes, [id]: change })); + }; + + function cleanUnsavedChanges() { + setUnsavedChanges({}); + } + + return { + fields: fieldsQuery.data ?? {}, + unsavedChanges, + handleFieldChange, + saveAll: saveAllMutation.mutateAsync, + isSaving: saveAllMutation.isLoading || saveSingleSettingMutation.isLoading, + cleanUnsavedChanges, + saveSingleSetting: saveSingleSettingMutation.mutateAsync, + }; +}; + +export { SettingsContext, useSettingsContext }; diff --git a/x-pack/platform/plugins/private/gen_ai_settings/public/management_section/mount_section.tsx b/x-pack/platform/plugins/private/gen_ai_settings/public/management_section/mount_section.tsx index 06ad521e1ffbe..a0d827eac4289 100644 --- a/x-pack/platform/plugins/private/gen_ai_settings/public/management_section/mount_section.tsx +++ b/x-pack/platform/plugins/private/gen_ai_settings/public/management_section/mount_section.tsx @@ -14,10 +14,13 @@ import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import type { CoreSetup } from '@kbn/core/public'; import type { ManagementAppMountParams } from '@kbn/management-plugin/public'; import { i18n } from '@kbn/i18n'; +import { wrapWithTheme } from '@kbn/react-kibana-context-theme'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { GenAiSettingsApp } from '../components/gen_ai_settings_app'; import { EnabledFeaturesContextProvider } from '../contexts/enabled_features_context'; import type { GenAiSettingsConfigType } from '../../common/config'; import { createCallGenAiSettingsAPI } from '../api/client'; +import { SettingsContextProvider } from '../contexts/settings_context'; interface MountSectionParams { core: CoreSetup; @@ -39,20 +42,24 @@ export const mountManagementSection = async ({ ); const genAiSettingsApi = createCallGenAiSettingsAPI(coreStart); - + const queryClient = new QueryClient(); const GenAiSettingsAppWithContext = () => ( - - - - - - - - - + + + + + + + + + + + + + ); - ReactDOM.render(, element); + ReactDOM.render(wrapWithTheme(, core.theme), element); return () => { ReactDOM.unmountComponentAtNode(element); diff --git a/x-pack/platform/plugins/private/gen_ai_settings/server/plugin.ts b/x-pack/platform/plugins/private/gen_ai_settings/server/plugin.ts index 1161383b0cb6b..d856f129806d5 100644 --- a/x-pack/platform/plugins/private/gen_ai_settings/server/plugin.ts +++ b/x-pack/platform/plugins/private/gen_ai_settings/server/plugin.ts @@ -7,12 +7,18 @@ import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/server'; import type { Logger, PluginInitializerContext } from '@kbn/core/server'; +import { + GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR, + GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY, +} from '@kbn/management-settings-ids'; +import { schema } from '@kbn/config-schema'; import { registerServerRoutes } from './routes/register_routes'; import type { GenAiSettingsPluginSetupDependencies, GenAiSettingsPluginStartDependencies, } from './types'; import type { GenAiSettingsRouteHandlerResources } from './routes/types'; +import { NO_DEFAULT_CONNECTOR } from '../common/constants'; export type GenAiSettingsPluginSetup = Record; export type GenAiSettingsPluginStart = Record; @@ -67,6 +73,36 @@ export class GenAiSettingsPlugin isDev: false, }); + core.uiSettings.register({ + /** + * TODO: + * Once assistants changes have been made that watch this uiSetting, + * change the bellow configuration to the following: + * {"readonlyMode": "ui", "schema": schema.string(), "value": "NO_DEFAULT_CONNECTOR"} + */ + [GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR]: { + readonlyMode: 'ui', + readonly: true, + schema: schema.string(), + value: NO_DEFAULT_CONNECTOR, + }, + }); + + core.uiSettings.register({ + /** + * TODO: + * Once assistants changes have been made that watch this uiSetting, + * change the bellow configuration to the following: + * {"readonlyMode": "ui", "schema": schema.boolean(), "value": false} + */ + [GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY]: { + readonlyMode: 'ui', + readonly: true, + schema: schema.boolean(), + value: false, + }, + }); + return {}; } diff --git a/x-pack/platform/plugins/private/gen_ai_settings/tsconfig.json b/x-pack/platform/plugins/private/gen_ai_settings/tsconfig.json index b7bea760c971a..e7b145408d742 100644 --- a/x-pack/platform/plugins/private/gen_ai_settings/tsconfig.json +++ b/x-pack/platform/plugins/private/gen_ai_settings/tsconfig.json @@ -22,6 +22,11 @@ "@kbn/server-route-repository-client", "@kbn/inference-common", "@kbn/logging", + "@kbn/management-settings-ids", + "@kbn/management-settings-types", + "@kbn/management-settings-utilities", + "@kbn/management-settings-field-definition", + "@kbn/react-kibana-context-theme", "@kbn/licensing-plugin" ], "exclude": ["target/**/*"]