[]) => {
+ const values = selectedOptions.map((option) => option.value);
+ if (values.length > 1) {
+ toast?.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);
+
+ const elasticManagedLlmExists = hasElasticManagedLlm(connectors.connectors);
+
+ const connectorDescription = useMemo(() => {
+ if (!elasticManagedLlmExists) {
+ return (
+
+
+
+
+ ),
+ }}
+ />
+
+ );
+ }
+
+ return (
+
+
+
+
+ ),
+ manageConnectors: (
+
+
+
+ ),
+ elasticManagedLlm: (
+
+
+
+ ),
+ }}
+ />
+
+ );
+ }, [elasticManagedLlmExists, application, docLinks]);
+
+ if (!featureFlags.getBooleanValue(AI_ASSISTANT_DEFAULT_LLM_SETTING_ENABLED, false)) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ }
+ description={connectorDescription}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ }
+ 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/packages/shared/ai-assistant-default-llm-setting/src/context/default_ai_connector_context.tsx b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/src/context/default_ai_connector_context.tsx
new file mode 100644
index 0000000000000..b8a5ca9c5def6
--- /dev/null
+++ b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/src/context/default_ai_connector_context.tsx
@@ -0,0 +1,75 @@
+/*
+ * 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, { createContext, useContext } from 'react';
+import type { ApplicationStart, DocLinksStart, FeatureFlagsStart, IToasts } from '@kbn/core/public';
+
+type DefaultAiConnectorSettingContext = ReturnType;
+
+const DefaultAiConnectorSettingContext = createContext(
+ null
+);
+
+export const useDefaultAiConnectorSettingContext = () => {
+ const context = useContext(DefaultAiConnectorSettingContext);
+ if (!context) {
+ throw new Error(
+ 'useDefaultAiConnectorContext must be inside of a SettingsContextProvider.Provider.'
+ );
+ }
+ return context;
+};
+
+export const DefaultAiConnectorSettingsContextProvider = ({
+ children,
+ toast,
+ application,
+ docLinks,
+ featureFlags,
+}: {
+ children: React.ReactNode;
+ toast: IToasts | undefined;
+ application: ApplicationStart;
+ docLinks: DocLinksStart;
+ featureFlags: FeatureFlagsStart;
+}) => {
+ const value = DefaultAiConnector({
+ toast,
+ application,
+ docLinks,
+ featureFlags,
+ });
+ return (
+
+ {children}
+
+ );
+};
+
+const DefaultAiConnector = ({
+ toast,
+ application,
+ docLinks,
+ featureFlags,
+}: {
+ toast: IToasts | undefined;
+ application: ApplicationStart;
+ docLinks: DocLinksStart;
+ featureFlags: FeatureFlagsStart;
+}) => {
+ return {
+ toast,
+ application,
+ docLinks,
+ featureFlags,
+ };
+};
+
+export {
+ DefaultAiConnectorSettingContext as SettingsContext,
+ useDefaultAiConnectorSettingContext as useSettingsContext,
+};
diff --git a/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/src/lib/constants.ts b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/src/lib/constants.ts
new file mode 100644
index 0000000000000..1cfad15d32c6f
--- /dev/null
+++ b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/src/lib/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/packages/shared/ai-assistant-default-llm-setting/tsconfig.json b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/tsconfig.json
new file mode 100644
index 0000000000000..88a03c24103dc
--- /dev/null
+++ b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/tsconfig.json
@@ -0,0 +1,32 @@
+{
+ "extends": "../../../../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "target/types",
+ "types": [
+ "jest",
+ "node",
+ "react",
+ "@emotion/react/types/css-prop"
+ ]
+ },
+ "include": [
+ "**/*.ts",
+ "**/*.tsx",
+ ".storybook/**/*.ts",
+ ".storybook/**/*.tsx",
+ ],
+ "exclude": [
+ "target/**/*"
+ ],
+ "kbn_references": [
+ "@kbn/i18n-react",
+ "@kbn/management-settings-types",
+ "@kbn/core-ui-settings-common",
+ "@kbn/core-notifications-browser",
+ "@kbn/core-application-browser",
+ "@kbn/core-doc-links-browser",
+ "@kbn/management-settings-ids",
+ "@kbn/core",
+ "@kbn/i18n",
+ ]
+}
diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.test.tsx
index 077e0bb38edfb..756b407251db9 100644
--- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.test.tsx
+++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.test.tsx
@@ -25,6 +25,7 @@ import {
SYSTEM_PROMPTS_TAB,
} from './const';
import { DataViewsContract } from '@kbn/data-views-plugin/public';
+import { SettingsStart } from '@kbn/core-ui-settings-browser';
const mockContext = {
basePromptContexts: MOCK_QUICK_PROMPTS,
@@ -48,6 +49,7 @@ const testProps = {
dataViews: mockDataViews,
onTabChange,
currentTab: CONNECTORS_TAB,
+ settings: {} as SettingsStart,
};
jest.mock('../../assistant_context');
diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.tsx
index 2d1b458eeebe3..fd1c78140f6df 100644
--- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.tsx
+++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.tsx
@@ -9,6 +9,7 @@ import React, { useEffect, useMemo } from 'react';
import { EuiAvatar, EuiPageTemplate, EuiTitle, useEuiShadow, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { DataViewsContract } from '@kbn/data-views-plugin/public';
+import { SettingsStart } from '@kbn/core-ui-settings-browser';
import * as i18n from './translations';
import { useAssistantContext } from '../../assistant_context';
import { useLoadConnectors } from '../../connectorland/use_load_connectors';
@@ -34,6 +35,7 @@ import { ManagementSettingsTabs } from './types';
interface Props {
dataViews: DataViewsContract;
+ settings: SettingsStart;
onTabChange?: (tabId: string) => void;
currentTab: ManagementSettingsTabs;
}
@@ -43,7 +45,7 @@ interface Props {
* anonymization, knowledge base, and evaluation via the `isModelEvaluationEnabled` feature flag.
*/
export const AssistantSettingsManagement: React.FC = React.memo(
- ({ dataViews, onTabChange, currentTab: selectedSettingsTab }) => {
+ ({ dataViews, onTabChange, currentTab: selectedSettingsTab, settings }) => {
const {
assistantFeatures: { assistantModelEvaluation: modelEvaluatorEnabled },
http,
@@ -154,7 +156,9 @@ export const AssistantSettingsManagement: React.FC = React.memo(
`}
data-test-subj={`tab-${selectedSettingsTab}`}
>
- {selectedSettingsTab === CONNECTORS_TAB && }
+ {selectedSettingsTab === CONNECTORS_TAB && (
+
+ )}
{selectedSettingsTab === CONVERSATIONS_TAB && (
void;
currentTab: ManagementSettingsTabs;
+ settings: SettingsStart;
}
/**
@@ -49,7 +51,7 @@ interface Props {
* anonymization, knowledge base, and evaluation via the `isModelEvaluationEnabled` feature flag.
*/
export const SearchAILakeConfigurationsSettingsManagement: React.FC = React.memo(
- ({ dataViews, onTabChange, currentTab }) => {
+ ({ dataViews, onTabChange, currentTab, settings }) => {
const {
assistantFeatures: { assistantModelEvaluation: modelEvaluatorEnabled },
http,
@@ -122,7 +124,9 @@ export const SearchAILakeConfigurationsSettingsManagement: React.FC = Rea
const renderTabBody = useCallback(() => {
switch (currentTab) {
case CONNECTORS_TAB:
- return ;
+ return (
+
+ );
case SYSTEM_PROMPTS_TAB:
return (
= Rea
/>
);
}
- }, [connectors, currentTab, dataViews, defaultConnector, modelEvaluatorEnabled]);
+ }, [connectors, currentTab, dataViews, defaultConnector, modelEvaluatorEnabled, settings]);
return (
{
+interface Props {
+ connectors: AIConnector[] | undefined;
+ settings: SettingsStart;
+}
+
+export const AIForSOCConnectorSettingsManagement = ({ connectors, settings }: Props) => {
return (
<>
-
+
>
diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/bottom_bar_actions/bottom_bar_actions.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/bottom_bar_actions/bottom_bar_actions.test.tsx
new file mode 100644
index 0000000000000..bc23ef4e63716
--- /dev/null
+++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/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/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/bottom_bar_actions/bottom_bar_actions.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/bottom_bar_actions/bottom_bar_actions.tsx
new file mode 100644
index 0000000000000..1b95a6afb31cf
--- /dev/null
+++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/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/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/context/settings_context.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/context/settings_context.test.tsx
new file mode 100644
index 0000000000000..bf998ca41fbe2
--- /dev/null
+++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/context/settings_context.test.tsx
@@ -0,0 +1,175 @@
+/*
+ * 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 type { PublicUiSettingsParams, UserProvidedValues } from '@kbn/core/public';
+import { Subject } from 'rxjs';
+import { SettingsStart } from '@kbn/core-ui-settings-browser';
+
+describe('settings_context', () => {
+ const setupSettingsContext = () => {
+ const queryClient = new QueryClient();
+ const set = jest.fn().mockResolvedValue(undefined);
+
+ const rendered = renderHook(() => useSettingsContext(), {
+ wrapper: ({ children }) => {
+ return (
+
+ 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),
+ },
+ } as unknown as SettingsStart
+ }
+ >
+ {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/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/context/settings_context.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/context/settings_context.tsx
new file mode 100644
index 0000000000000..c5612ba8ca528
--- /dev/null
+++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/context/settings_context.tsx
@@ -0,0 +1,172 @@
+/*
+ * 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, { 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 { SettingsStart } from '@kbn/core-ui-settings-browser';
+
+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,
+ settings,
+}: {
+ children: React.ReactNode;
+ settings: SettingsStart;
+}) => {
+ const value = Settings({ settingsKeys: SETTING_KEYS, settings });
+ 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,
+ settings,
+}: {
+ settingsKeys: string[];
+ settings: SettingsStart;
+}) => {
+ 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);
+ }
+ } 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/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/index.tsx
index afa8bd48567a2..fc0e02a6d3836 100644
--- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/index.tsx
+++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/index.tsx
@@ -7,20 +7,29 @@
import {
EuiButton,
+ EuiDescribedFormGroup,
EuiFlexGroup,
EuiFlexItem,
+ EuiFormRow,
EuiPanel,
- EuiSpacer,
- EuiText,
EuiTitle,
} from '@elastic/eui';
-import { css } from '@emotion/react';
import React, { useCallback } from 'react';
+import { DefaultAIConnector } from '@kbn/ai-assistant-default-llm-setting';
+import { isEmpty } from 'lodash';
+import { SettingsStart } from '@kbn/core-ui-settings-browser';
import { useAssistantContext } from '../../assistant_context';
-
import * as i18n from './translations';
+import { SettingsContextProvider, useSettingsContext } from './context/settings_context';
+import { BottomBarActions } from './bottom_bar_actions/bottom_bar_actions';
+import { AIConnector } from '../connector_selector';
+
+interface Props {
+ connectors: AIConnector[] | undefined;
+ settings: SettingsStart;
+}
-const ConnectorsSettingsManagementComponent: React.FC = () => {
+const ConnectorsSettingsManagementComponent: React.FC = ({ connectors, settings }) => {
const { navigateToApp } = useAssistantContext();
const onClick = useCallback(
@@ -32,25 +41,84 @@ const ConnectorsSettingsManagementComponent: React.FC = () => {
);
return (
-
-
- {i18n.CONNECTOR_SETTINGS_MANAGEMENT_TITLE}
-
-
-
-
+
+
+
+
+
+ {i18n.CONNECTOR_SETTINGS_MANAGEMENT_TITLE}
+
+
+
+
+ }
+ description={i18n.CONNECTOR_SETTINGS_MANAGEMENT_DESCRIPTION}
>
- {i18n.CONNECTOR_SETTINGS_MANAGEMENT_DESCRIPTION}
-
-
-
- {i18n.CONNECTOR_MANAGEMENT_BUTTON_TITLE}
-
-
-
+
+
+
+ {i18n.CONNECTOR_MANAGEMENT_BUTTON_TITLE}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const DefaultAIConnectorHoc: React.FC> = ({ connectors }) => {
+ const { fields, handleFieldChange, unsavedChanges } = useSettingsContext();
+
+ return (
+
+ );
+};
+
+export const BottomBarActionsHoc = () => {
+ const { unsavedChanges, cleanUnsavedChanges, isSaving, saveAll } = useSettingsContext();
+ const { toasts } = useAssistantContext();
+ if (isEmpty(unsavedChanges)) {
+ return null;
+ }
+
+ async function handleSave() {
+ try {
+ await saveAll();
+ } catch (e) {
+ const error = e as Error;
+
+ toasts?.addDanger({
+ title: i18n.BOTTOM_BAR_ACTIONS_SAVE_ERROR,
+ text: error.message,
+ });
+ throw error;
+ }
+ }
+
+ return (
+
);
};
diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/translations.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/translations.ts
index 505a78fcf44f7..5d0aedb348a4c 100644
--- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/translations.ts
+++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/connectorland/connector_settings_management/translations.ts
@@ -28,3 +28,17 @@ export const CONNECTOR_MANAGEMENT_BUTTON_TITLE = i18n.translate(
defaultMessage: 'Manage Connectors',
}
);
+
+export const BOTTOM_BAR_ACTIONS_SAVE_LABEL = i18n.translate(
+ 'xpack.elasticAssistant.settings.bottomBar.action.saveButton',
+ {
+ defaultMessage: 'Save changes',
+ }
+);
+
+export const BOTTOM_BAR_ACTIONS_SAVE_ERROR = i18n.translate(
+ 'xpack.elasticAssistant.settings.bottomBar.action.save.error',
+ {
+ defaultMessage: 'An error occurred while saving the settings',
+ }
+);
diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.test.tsx b/x-pack/solutions/observability/plugins/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.test.tsx
index 1c55a275c46d0..ea57721dcab24 100644
--- a/x-pack/solutions/observability/plugins/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.test.tsx
+++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.test.tsx
@@ -46,6 +46,14 @@ describe('SettingsTab', () => {
});
useKibanaMock.mockReturnValue({
services: {
+ featureFlags: {
+ getBooleanValue: jest.fn().mockImplementation((flag) => {
+ if (flag === 'aiAssistant.defaultLlmSettingEnabled') {
+ return true;
+ }
+ return false;
+ }),
+ },
application: {
getUrlForApp: getUrlForAppMock,
capabilities: {
@@ -75,7 +83,17 @@ describe('SettingsTab', () => {
installProductDoc: jest.fn().mockResolvedValue({}),
uninstallProductDoc: jest.fn().mockResolvedValue({}),
});
- useGenAIConnectorsMock.mockReturnValue({ connectors: [{ id: 'test-connector' }] });
+ useGenAIConnectorsMock.mockReturnValue({
+ connectors: [
+ {
+ id: 'test-connector',
+ name: 'Test Connector',
+ isPreconfigured: false,
+ actionTypeId: 'test-action-type',
+ },
+ ],
+ loading: false,
+ });
useInferenceEndpointsMock.mockReturnValue({
inferenceEndpoints: [{ id: 'test-endpoint', inference_id: 'test-inference-id' }],
isLoading: false,
@@ -120,7 +138,7 @@ describe('SettingsTab', () => {
});
it('should not show knowledge base model section when no connectors exist', () => {
- useGenAIConnectorsMock.mockReturnValue({ connectors: [] });
+ useGenAIConnectorsMock.mockReturnValue({ connectors: [], loading: false });
const { queryByTestId } = render(, {
coreStart: {
diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_management/public/routes/components/settings_tab/ui_settings.tsx b/x-pack/solutions/observability/plugins/observability_ai_assistant_management/public/routes/components/settings_tab/ui_settings.tsx
index b98a2719df70e..9c791afa511cf 100644
--- a/x-pack/solutions/observability/plugins/observability_ai_assistant_management/public/routes/components/settings_tab/ui_settings.tsx
+++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_management/public/routes/components/settings_tab/ui_settings.tsx
@@ -19,17 +19,21 @@ import { isEmpty } from 'lodash';
import { i18n } from '@kbn/i18n';
import { LogSourcesSettingSynchronisationInfo } from '@kbn/logs-data-access-plugin/public';
import { UseKnowledgeBaseResult } from '@kbn/ai-assistant';
+import {
+ GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR,
+ GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY,
+} from '@kbn/management-settings-ids';
+import { DefaultAIConnector } from '@kbn/ai-assistant-default-llm-setting';
+import { useGenAIConnectors } from '@kbn/ai-assistant/src/hooks';
+import { DefaultAiConnectorSettingsContextProvider } from '@kbn/ai-assistant-default-llm-setting/src/context/default_ai_connector_context';
import { useAppContext } from '../../../hooks/use_app_context';
import { useKibana } from '../../../hooks/use_kibana';
export function UISettings({ knowledgeBase }: { knowledgeBase: UseKnowledgeBaseResult }) {
- const {
- docLinks,
- settings,
- notifications,
- application: { capabilities, getUrlForApp },
- } = useKibana().services;
+ const { docLinks, settings, notifications, application, featureFlags } = useKibana().services;
+ const { capabilities, getUrlForApp } = application;
const { config } = useAppContext();
+ const connectors = useGenAIConnectors();
const settingsKeys = [
aiAnonymizationSettings,
@@ -38,8 +42,13 @@ export function UISettings({ knowledgeBase }: { knowledgeBase: UseKnowledgeBaseR
...(config.visibilityEnabled ? [aiAssistantPreferredAIAssistantType] : []),
];
+ const customComponentSettingsKeys = [
+ GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR,
+ GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY,
+ ];
+
const { fields, handleFieldChange, unsavedChanges, saveAll, isSaving, cleanUnsavedChanges } =
- useEditableSettings(settingsKeys);
+ useEditableSettings([...settingsKeys, ...customComponentSettingsKeys]);
const canEditAdvancedSettings = capabilities.advancedSettings?.save;
@@ -88,6 +97,18 @@ export function UISettings({ knowledgeBase }: { knowledgeBase: UseKnowledgeBaseR
);
})}
+
+
+
+
{config.logSourcesEnabled && (
{
getCurrent: jest.fn().mockResolvedValue({ data: { color: 'blue', initials: 'P' } }),
},
},
+ notifications: {
+ toasts: {
+ addError: jest.fn(),
+ addSuccess: jest.fn(),
+ addWarning: jest.fn(),
+ },
+ },
},
});
diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/stack_management/management_settings.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/stack_management/management_settings.tsx
index 08f14861860a8..da5a75e4c6dbd 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/assistant/stack_management/management_settings.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/stack_management/management_settings.tsx
@@ -14,21 +14,27 @@ import { CONVERSATIONS_TAB } from '@kbn/elastic-assistant/impl/assistant/setting
import type { ManagementSettingsTabs } from '@kbn/elastic-assistant/impl/assistant/settings/types';
import { AssistantSpaceIdProvider } from '@kbn/elastic-assistant/impl/assistant/use_space_aware_context';
+import { DefaultAiConnectorSettingsContextProvider } from '@kbn/ai-assistant-default-llm-setting/src/context/default_ai_connector_context';
import { useKibana } from '../../common/lib/kibana';
import { useSpaceId } from '../../common/hooks/use_space_id';
export const ManagementSettings = React.memo(() => {
const {
- application: {
- navigateToApp,
- capabilities: {
- securitySolutionAssistant: { 'ai-assistant': securityAIAssistantEnabled },
- },
- },
+ application,
data: { dataViews },
chrome: { docTitle, setBreadcrumbs },
serverless,
+ settings,
+ docLinks,
+ featureFlags,
+ notifications,
} = useKibana().services;
+ const {
+ navigateToApp,
+ capabilities: {
+ securitySolutionAssistant: { 'ai-assistant': securityAIAssistantEnabled },
+ },
+ } = application;
const spaceId = useSpaceId();
docTitle.change(SECURITY_AI_SETTINGS);
@@ -98,11 +104,19 @@ export const ManagementSettings = React.memo(() => {
return spaceId ? (
-
+
+
+
) : null;
});
diff --git a/x-pack/solutions/security/plugins/security_solution/public/configurations/tabs/ai_settings.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/configurations/tabs/ai_settings.test.tsx
index 002a91d997220..fcbe740810c56 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/configurations/tabs/ai_settings.test.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/configurations/tabs/ai_settings.test.tsx
@@ -31,6 +31,13 @@ describe('AISettings', () => {
securitySolutionAssistant: { 'ai-assistant': true },
},
},
+ notifications: {
+ toasts: {
+ addError: jest.fn(),
+ addSuccess: jest.fn(),
+ addWarning: jest.fn(),
+ },
+ },
data: { dataViews: {} },
},
});
@@ -76,6 +83,13 @@ describe('AISettings', () => {
securitySolutionAssistant: { 'ai-assistant': false },
},
},
+ notifications: {
+ toasts: {
+ addError: jest.fn(),
+ addSuccess: jest.fn(),
+ addWarning: jest.fn(),
+ },
+ },
data: { dataViews: {} },
},
});
diff --git a/x-pack/solutions/security/plugins/security_solution/public/configurations/tabs/ai_settings.tsx b/x-pack/solutions/security/plugins/security_solution/public/configurations/tabs/ai_settings.tsx
index c6963e4f19ceb..0822ff12d7b1f 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/configurations/tabs/ai_settings.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/configurations/tabs/ai_settings.tsx
@@ -13,6 +13,7 @@ import {
AssistantSpaceIdProvider,
} from '@kbn/elastic-assistant';
import { useSearchParams } from 'react-router-dom-v5-compat';
+import { DefaultAiConnectorSettingsContextProvider } from '@kbn/ai-assistant-default-llm-setting';
import { SecurityPageName } from '../../../common/constants';
import { useKibana, useNavigation } from '../../common/lib/kibana';
import { useSpaceId } from '../../common/hooks/use_space_id';
@@ -20,14 +21,20 @@ import { useSpaceId } from '../../common/hooks/use_space_id';
export const AISettings: React.FC = () => {
const { navigateTo } = useNavigation();
const {
- application: {
- navigateToApp,
- capabilities: {
- securitySolutionAssistant: { 'ai-assistant': securityAIAssistantEnabled },
- },
- },
+ application,
data: { dataViews },
+ settings,
+ docLinks,
+ notifications: { toasts },
+ featureFlags,
} = useKibana().services;
+
+ const {
+ navigateToApp,
+ capabilities: {
+ securitySolutionAssistant: { 'ai-assistant': securityAIAssistantEnabled },
+ },
+ } = application;
const spaceId = useSpaceId();
const onTabChange = useCallback(
(tab: string) => {
@@ -49,11 +56,19 @@ export const AISettings: React.FC = () => {
}
return spaceId ? (
-
+
+
+
) : null;
};
diff --git a/x-pack/solutions/security/plugins/security_solution/tsconfig.json b/x-pack/solutions/security/plugins/security_solution/tsconfig.json
index e324a53de22b2..e1a170da4d6d3 100644
--- a/x-pack/solutions/security/plugins/security_solution/tsconfig.json
+++ b/x-pack/solutions/security/plugins/security_solution/tsconfig.json
@@ -259,5 +259,6 @@
"@kbn/elastic-assistant-shared-state",
"@kbn/elastic-assistant-shared-state-plugin",
"@kbn/core-metrics-server",
+ "@kbn/ai-assistant-default-llm-setting",
]
}
diff --git a/yarn.lock b/yarn.lock
index a45ab62f5aef3..a44e7521eb26f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3758,10 +3758,13 @@
version "0.0.0"
uid ""
-"@kbn/ai-assistant-icon@link:x-pack/platform/packages/shared/ai-assistant/icon":
+"@kbn/ai-assistant-default-llm-setting@link:x-pack/platform/packages/shared/ai-assistant-default-llm-setting":
version "0.0.0"
uid ""
+"@kbn/ai-assistant-icon@link:x-pack/platform/packages/shared/ai-assistant/icon":
+ version "0.0.0"
+
"@kbn/ai-assistant-management-plugin@link:src/platform/plugins/shared/ai_assistant_management/selection":
version "0.0.0"
uid ""
From 2812f6d306cfc47a0367fc0841b04b7a0302746e Mon Sep 17 00:00:00 2001
From: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Date: Tue, 9 Sep 2025 09:17:23 +0000
Subject: [PATCH 2/2] [CI] Auto-commit changed files from 'node
scripts/yarn_deduplicate'
---
.../packages/shared/kbn-elastic-assistant/tsconfig.json | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/tsconfig.json b/x-pack/platform/packages/shared/kbn-elastic-assistant/tsconfig.json
index 5a5227c93de83..b6ab21779f9f4 100644
--- a/x-pack/platform/packages/shared/kbn-elastic-assistant/tsconfig.json
+++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/tsconfig.json
@@ -44,5 +44,11 @@
"@kbn/alerts-ui-shared",
"@kbn/deeplinks-security",
"@kbn/inference-common",
+ "@kbn/core-ui-settings-browser",
+ "@kbn/ai-assistant-default-llm-setting",
+ "@kbn/management-settings-types",
+ "@kbn/management-settings-utilities",
+ "@kbn/management-settings-field-definition",
+ "@kbn/management-settings-ids",
]
}