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/**/*"]