diff --git a/package.json b/package.json index 763fc256751a6..1f64a25272ab2 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,7 @@ "@kbn/ai-assistant-common": "link:x-pack/platform/packages/shared/ai-assistant/common", "@kbn/ai-assistant-connector-selector-action": "link:x-pack/platform/packages/shared/ai-assistant/ai-assistant-connector-selector-action", "@kbn/ai-assistant-cta": "link:x-pack/platform/packages/shared/ai-assistant/ai-assistant-cta", + "@kbn/ai-assistant-default-llm-setting": "link:x-pack/platform/packages/shared/ai-assistant-default-llm-setting", "@kbn/ai-assistant-icon": "link:x-pack/platform/packages/shared/ai-assistant/icon", "@kbn/ai-assistant-management-plugin": "link:src/platform/plugins/shared/ai_assistant_management/selection", "@kbn/ai-security-labs-content": "link:x-pack/solutions/security/packages/ai-security-labs-content", 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 a71b6badac63e..f2c36870add63 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 @@ -139,6 +139,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 OBSERVABILITY_SEARCH_EXCLUDED_DATA_TIERS = 'observability:searchExcludedDataTiers'; export const OBSERVABILITY_ENABLE_DIAGNOSTIC_MODE = 'observability:enableDiagnosticMode'; 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 86e32f36c105b..5d4b984888cdc 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 @@ -671,6 +671,18 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'genAiSettings:defaultAIConnector': { + type: 'keyword', + _meta: { + description: 'Default AI connector', + }, + }, + 'genAiSettings:defaultAIConnectorOnly': { + type: 'boolean', + _meta: { + description: 'Restrict to default AI connector only', + }, + }, 'observability:searchExcludedDataTiers': { type: 'array', items: { 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 a12258a6c929a..d10cd03764496 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 @@ -175,6 +175,8 @@ export interface UsageStats { 'devTools:enablePersistentConsole': boolean; 'aiAssistant:preferredAIAssistantType': string; 'observability:profilingFetchTopNFunctionsFromStacktraces': boolean; + 'genAiSettings:defaultAIConnector': string; + 'genAiSettings:defaultAIConnectorOnly': boolean; 'securitySolution:excludedDataTiersForRuleExecution': string[]; 'securitySolution:maxUnassociatedNotes': number; 'observability:searchExcludedDataTiers': string[]; diff --git a/src/platform/plugins/shared/ai_assistant_management/selection/common/constants.ts b/src/platform/plugins/shared/ai_assistant_management/selection/common/constants.ts new file mode 100644 index 0000000000000..05c3a71c09a00 --- /dev/null +++ b/src/platform/plugins/shared/ai_assistant_management/selection/common/constants.ts @@ -0,0 +1,10 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export const NO_DEFAULT_CONNECTOR = 'NO_DEFAULT_CONNECTOR'; diff --git a/src/platform/plugins/shared/ai_assistant_management/selection/server/plugin.ts b/src/platform/plugins/shared/ai_assistant_management/selection/server/plugin.ts index 6303ec7268539..27e45fc9f3199 100644 --- a/src/platform/plugins/shared/ai_assistant_management/selection/server/plugin.ts +++ b/src/platform/plugins/shared/ai_assistant_management/selection/server/plugin.ts @@ -16,6 +16,10 @@ import { Plugin, DEFAULT_APP_CATEGORIES, } 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 type { AIAssistantManagementSelectionConfig } from './config'; import type { @@ -24,6 +28,7 @@ import type { AIAssistantManagementSelectionPluginServerSetup, AIAssistantManagementSelectionPluginServerStart, } from './types'; +import { NO_DEFAULT_CONNECTOR } from '../common/constants'; import { AIAssistantType } from '../common/ai_assistant_type'; import { PREFERRED_AI_ASSISTANT_TYPE_SETTING_KEY } from '../common/ui_setting_keys'; @@ -92,6 +97,24 @@ export class AIAssistantManagementSelectionPlugin }, }); + core.uiSettings.register({ + [GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR]: { + readonlyMode: 'ui', + readonly: false, + schema: schema.string(), + value: NO_DEFAULT_CONNECTOR, + }, + }); + + core.uiSettings.register({ + [GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY]: { + readonlyMode: 'ui', + readonly: false, + schema: schema.boolean(), + value: false, + }, + }); + core.capabilities.registerProvider(() => { return { management: { diff --git a/src/platform/plugins/shared/ai_assistant_management/selection/tsconfig.json b/src/platform/plugins/shared/ai_assistant_management/selection/tsconfig.json index 248baa86b2241..07a3ea19ddd91 100644 --- a/src/platform/plugins/shared/ai_assistant_management/selection/tsconfig.json +++ b/src/platform/plugins/shared/ai_assistant_management/selection/tsconfig.json @@ -19,7 +19,8 @@ "@kbn/features-plugin", "@kbn/config", "@kbn/doc-links", - "@kbn/licensing-plugin" + "@kbn/licensing-plugin", + "@kbn/management-settings-ids" ], "exclude": ["target/**/*"] } diff --git a/src/platform/plugins/shared/telemetry/schema/oss_platform.json b/src/platform/plugins/shared/telemetry/schema/oss_platform.json index 202da24d39c5c..7070f247da306 100644 --- a/src/platform/plugins/shared/telemetry/schema/oss_platform.json +++ b/src/platform/plugins/shared/telemetry/schema/oss_platform.json @@ -11434,6 +11434,18 @@ "description": "Non-default value of setting." } }, + "genAiSettings:defaultAIConnector": { + "type": "keyword", + "_meta": { + "description": "Default AI connector" + } + }, + "genAiSettings:defaultAIConnectorOnly": { + "type": "boolean", + "_meta": { + "description": "Restrict to default AI connector only" + } + }, "observability:searchExcludedDataTiers": { "type": "array", "items": { diff --git a/tsconfig.base.json b/tsconfig.base.json index d5c14b88d0db8..77d9176c1856d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -22,6 +22,8 @@ "@kbn/ai-assistant-connector-selector-action/*": ["x-pack/platform/packages/shared/ai-assistant/ai-assistant-connector-selector-action/*"], "@kbn/ai-assistant-cta": ["x-pack/platform/packages/shared/ai-assistant/ai-assistant-cta"], "@kbn/ai-assistant-cta/*": ["x-pack/platform/packages/shared/ai-assistant/ai-assistant-cta/*"], + "@kbn/ai-assistant-default-llm-setting": ["x-pack/platform/packages/shared/ai-assistant-default-llm-setting"], + "@kbn/ai-assistant-default-llm-setting/*": ["x-pack/platform/packages/shared/ai-assistant-default-llm-setting/*"], "@kbn/ai-assistant-icon": ["x-pack/platform/packages/shared/ai-assistant/icon"], "@kbn/ai-assistant-icon/*": ["x-pack/platform/packages/shared/ai-assistant/icon/*"], "@kbn/ai-assistant-management-plugin": ["src/platform/plugins/shared/ai_assistant_management/selection"], diff --git a/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/README.md b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/README.md new file mode 100644 index 0000000000000..96f21ba8254af --- /dev/null +++ b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/README.md @@ -0,0 +1,3 @@ +# @kbn/ai-assistant-default-llm-setting + +UI components for setting to configure default LLM. \ No newline at end of file diff --git a/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/index.ts b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/index.ts new file mode 100644 index 0000000000000..4b39a28a2bf0d --- /dev/null +++ b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/index.ts @@ -0,0 +1,12 @@ +/* + * 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 { DefaultAIConnector } from './src/components/default_ai_connector'; +export { + DefaultAiConnectorSettingsContextProvider, + useDefaultAiConnectorSettingContext, +} from './src/context/default_ai_connector_context'; diff --git a/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/jest.config.js b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/jest.config.js new file mode 100644 index 0000000000000..0f431f0b56e22 --- /dev/null +++ b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/jest.config.js @@ -0,0 +1,18 @@ +/* + * 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. + */ + +module.exports = { + coverageDirectory: '/target/kibana-coverage/jest/x-pack/packages/kbn_ai_assistant_src', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/platform/packages/shared/kbn-ai-assistant/src/**/*.{ts,tsx}', + '!/x-pack/platform/packages/shared/kbn-ai-assistant/src/*.test.{ts,tsx}', + ], + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/x-pack/platform/packages/shared/ai-assistant-default-llm-setting'], +}; diff --git a/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/kibana.jsonc b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/kibana.jsonc new file mode 100644 index 0000000000000..15a8f7be47529 --- /dev/null +++ b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-browser", + "id": "@kbn/ai-assistant-default-llm-setting", + "owner": ["@elastic/security-generative-ai"], + "group": "platform", + "visibility": "shared" +} diff --git a/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/package.json b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/package.json new file mode 100644 index 0000000000000..17ae27d19ff1c --- /dev/null +++ b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/ai-assistant-default-llm-setting", + "description": "Default LLM settings for AI Assistant", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0" +} diff --git a/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/setup_tests.ts b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/setup_tests.ts new file mode 100644 index 0000000000000..72e0edd0d07f7 --- /dev/null +++ b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/setup_tests.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import '@testing-library/jest-dom'; diff --git a/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/src/components/default_ai_connector.test.tsx b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/src/components/default_ai_connector.test.tsx new file mode 100644 index 0000000000000..52ef80b816bc4 --- /dev/null +++ b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/src/components/default_ai_connector.test.tsx @@ -0,0 +1,226 @@ +/* + * 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 '@testing-library/jest-dom'; +import { act, render, screen } from '@testing-library/react'; +import { + AI_ASSISTANT_DEFAULT_LLM_SETTING_ENABLED, + DefaultAIConnector, +} from './default_ai_connector'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { I18nProvider } from '@kbn/i18n-react'; +import userEvent from '@testing-library/user-event'; +import { FieldDefinition, UnsavedFieldChange } from '@kbn/management-settings-types'; +import { UiSettingsType } from '@kbn/core-ui-settings-common'; +import { IToasts } from '@kbn/core-notifications-browser'; +import { ApplicationStart } from '@kbn/core-application-browser'; +import { DocLinksStart } from '@kbn/core-doc-links-browser'; +import React from 'react'; +import { DefaultAiConnectorSettingsContextProvider } from '../context/default_ai_connector_context'; +import { FeatureFlagsStart } from '@kbn/core/public'; + +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({ + fields, + unsavedChanges, + enabled = true, +}: { + fields: Record< + string, + FieldDefinition< + UiSettingsType, + string | number | boolean | (string | number)[] | null | undefined + > + >; + unsavedChanges: Record>; + enabled?: boolean; +}) { + const queryClient = new QueryClient(); + const handleFieldChange = jest.fn(); + + const settings = { + handleFieldChange, + fields, + unsavedChanges, + }; + + const utils = render( + <> + + , + { + wrapper: ({ children }) => ( + + + { + if (flag === AI_ASSISTANT_DEFAULT_LLM_SETTING_ENABLED && enabled) { + return true; + } + return false; + }), + } as unknown as FeatureFlagsStart + } + toast={{} as IToasts} + > + {children} + + + + ), + } + ); + + return { + ...utils, + handleFieldChange, + }; +} + +describe('DefaultAIConnector', () => { + describe('rendering', () => { + it('renders all component elements correctly', () => { + const { container } = setupTest({ + fields: {}, + unsavedChanges: {}, + }); + + 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(); + }); + + it('does not render when feature flag is off', () => { + setupTest({ + fields: {}, + unsavedChanges: {}, + enabled: false, + }); + + expect(screen.queryByText('genAiSettings:defaultAIConnector')).not.toBeInTheDocument(); + expect(screen.queryByText('Disallow all other connectors')).not.toBeInTheDocument(); + expect(screen.queryByTestId('defaultAiConnectorComboBox')).not.toBeInTheDocument(); + expect(screen.queryByTestId('defaultAiConnectorCheckbox')).not.toBeInTheDocument(); + }); + }); + + describe('combobox interaction', () => { + it('shows connector options when clicked', async () => { + setupTest({ + fields: {}, + unsavedChanges: {}, + }); + + 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 () => { + const { handleFieldChange } = setupTest({ + fields: {}, + unsavedChanges: {}, + }); + + act(() => { + screen.getByTestId('comboBoxSearchInput').click(); + }); + + await userEvent.click(screen.getByTestId('comboBoxSearchInput')); + await userEvent.click(screen.getByText('Custom Connector 1')); + + expect(handleFieldChange).toHaveBeenCalledWith('genAiSettings:defaultAIConnector', { + type: 'string', + unsavedValue: 'custom1', + }); + }); + }); + + describe('checkbox interaction', () => { + it('updates checkbox state when clicked', async () => { + const { handleFieldChange, container } = setupTest({ + fields: {}, + unsavedChanges: {}, + }); + + expect(container.querySelector('[class$="square-unselected"]')).not.toBeNull(); + + await userEvent.click(screen.getByTestId('defaultAiConnectorCheckbox')); + + expect(handleFieldChange).toHaveBeenCalledWith('genAiSettings:defaultAIConnectorOnly', { + type: 'boolean', + unsavedValue: true, + }); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/src/components/default_ai_connector.tsx b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/src/components/default_ai_connector.tsx new file mode 100644 index 0000000000000..0c54697b68896 --- /dev/null +++ b/x-pack/platform/packages/shared/ai-assistant-default-llm-setting/src/components/default_ai_connector.tsx @@ -0,0 +1,404 @@ +/* + * 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, + EuiDescribedFormGroup, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIconTip, + EuiLink, + EuiTitle, +} 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, + OnFieldChangeFn, + UnsavedFieldChange, +} from '@kbn/management-settings-types'; +import type { UiSettingsType } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import { NO_DEFAULT_CONNECTOR } from '../lib/constants'; +import { useDefaultAiConnectorSettingContext } from '../context/default_ai_connector_context'; + +export const AI_ASSISTANT_DEFAULT_LLM_SETTING_ENABLED = + 'aiAssistant.defaultLlmSettingEnabled' as const; + +interface ConnectorData { + connectors?: Array<{ + id: string; + name: string; + isPreconfigured: boolean; + actionTypeId: string; + config?: Record; + }>; + loading: boolean; +} + +const hasElasticManagedLlm = (connectors: ConnectorData['connectors'] | undefined) => { + if (!Array.isArray(connectors) || connectors.length === 0) { + return false; + } + + return connectors.find( + (connector) => + connector.actionTypeId === '.inference' && + connector.isPreconfigured && + connector.config?.provider === 'elastic' + ); +}; + +interface Props { + settings: { + unsavedChanges: Record>; + handleFieldChange: OnFieldChangeFn; + fields: Record< + string, + FieldDefinition< + UiSettingsType, + string | number | boolean | (string | number)[] | null | undefined + > + >; + }; + connectors: ConnectorData; +} + +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: ConnectorData): 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, settings }) => { + const { toast, application, docLinks, featureFlags } = useDefaultAiConnectorSettingContext(); + const options = useMemo(() => getOptions(connectors), [connectors]); + const { handleFieldChange, fields, unsavedChanges } = settings; + + const onChangeDefaultLlm = (selectedOptions: EuiComboBoxOptionOption[]) => { + 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 8c0a5a353be3b..5458bbb9459ea 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 @@ -26,6 +26,7 @@ import { } from './const'; import { mockSystemPrompts } from '../../mock/system_prompt'; import { DataViewsContract } from '@kbn/data-views-plugin/public'; +import { SettingsStart } from '@kbn/core-ui-settings-browser'; const mockConversations = { [alertConvo.title]: alertConvo, @@ -62,6 +63,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 9305788e501c5..c6b693593c0bb 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, { 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 { Conversation } from '../../..'; import * as i18n from './translations'; import { useAssistantContext } from '../../assistant_context'; @@ -36,6 +37,7 @@ import { SettingsTabs } from './types'; interface Props { dataViews: DataViewsContract; selectedConversation: Conversation; + settings: SettingsStart; onTabChange?: (tabId: string) => void; currentTab: SettingsTabs; } @@ -50,6 +52,7 @@ export const AssistantSettingsManagement: React.FC = React.memo( selectedConversation: defaultSelectedConversation, onTabChange, currentTab: selectedSettingsTab, + settings, }) => { const { assistantFeatures: { assistantModelEvaluation: modelEvaluatorEnabled }, @@ -151,7 +154,9 @@ export const AssistantSettingsManagement: React.FC = React.memo( `} data-test-subj={`tab-${selectedSettingsTab}`} > - {selectedSettingsTab === CONNECTORS_TAB && } + {selectedSettingsTab === CONNECTORS_TAB && ( + + )} {selectedSettingsTab === CONVERSATIONS_TAB && ( { + 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 e7f7ac69a7d1c..2a910b0b20192 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/platform/packages/shared/kbn-elastic-assistant/tsconfig.json b/x-pack/platform/packages/shared/kbn-elastic-assistant/tsconfig.json index 1e89e1db32eb7..5e2c552372788 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/tsconfig.json +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/tsconfig.json @@ -41,5 +41,11 @@ "@kbn/product-doc-base-plugin", "@kbn/spaces-plugin", "@kbn/shared-ux-router", + "@kbn/core-ui-settings-browser", + "@kbn/management-settings-types", + "@kbn/management-settings-utilities", + "@kbn/management-settings-field-definition", + "@kbn/management-settings-ids", + "@kbn/ai-assistant-default-llm-setting", ] } diff --git a/x-pack/platform/plugins/private/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.test.tsx b/x-pack/platform/plugins/private/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.test.tsx index 406a62da6c3dc..f666d13b19b7d 100644 --- a/x-pack/platform/plugins/private/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.test.tsx +++ b/x-pack/platform/plugins/private/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.test.tsx @@ -31,6 +31,14 @@ describe('SettingsTab', () => { }); useKibanaMock.mockReturnValue({ services: { + featureFlags: { + getBooleanValue: jest.fn().mockImplementation((flag) => { + if (flag === 'aiAssistant.defaultLlmSettingEnabled') { + return true; + } + return false; + }), + }, application: { getUrlForApp: getUrlForAppMock, capabilities: { @@ -40,6 +48,13 @@ describe('SettingsTab', () => { http: { basePath: { prepend: prependMock }, }, + notifications: { + toasts: { + addError: jest.fn(), + addSuccess: jest.fn(), + addWarning: jest.fn(), + }, + }, productDocBase: undefined, }, }); @@ -49,7 +64,17 @@ describe('SettingsTab', () => { isPolling: false, isWarmingUpModel: false, }); - useGenAIConnectorsMock.mockReturnValue({ connectors: [{ id: 'test-connector' }] }); + useGenAIConnectorsMock.mockReturnValue({ + connectors: [ + { + id: 'test-connector', + name: 'Test Connector', + isPreconfigured: false, + actionTypeId: 'test-action-type', + }, + ], + loading: false, + }); getUrlForAppMock.mockReset(); prependMock.mockReset(); diff --git a/x-pack/platform/plugins/private/observability_ai_assistant_management/public/routes/components/settings_tab/ui_settings.tsx b/x-pack/platform/plugins/private/observability_ai_assistant_management/public/routes/components/settings_tab/ui_settings.tsx index 6e75b0d1c649a..b790ca5d08269 100644 --- a/x-pack/platform/plugins/private/observability_ai_assistant_management/public/routes/components/settings_tab/ui_settings.tsx +++ b/x-pack/platform/plugins/private/observability_ai_assistant_management/public/routes/components/settings_tab/ui_settings.tsx @@ -18,20 +18,24 @@ import { isEmpty } from 'lodash'; import { i18n } from '@kbn/i18n'; import { LogSourcesSettingSynchronisationInfo } from '@kbn/logs-data-access-plugin/public'; import { useKnowledgeBase } 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 { useEditableSettings } from '../../../hooks/use_editable_settings'; import { useAppContext } from '../../../hooks/use_app_context'; import { useKibana } from '../../../hooks/use_kibana'; import { BottomBarActions } from '../bottom_bar_actions/bottom_bar_actions'; export function UISettings() { - const { - docLinks, - settings, - notifications, - application: { capabilities, getUrlForApp }, - } = useKibana().services; + const { docLinks, settings, notifications, application, featureFlags } = useKibana().services; + const { capabilities, getUrlForApp } = application; const knowledgeBase = useKnowledgeBase(); const { config } = useAppContext(); + const connectors = useGenAIConnectors(); const settingsKeys = [ aiAssistantSimulatedFunctionCalling, @@ -39,8 +43,13 @@ export function UISettings() { ...(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; async function handleSave() { @@ -89,6 +98,18 @@ export function UISettings() { ); })} + + + + {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 5b09f532b16d9..d76ec53630606 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 @@ -23,6 +23,7 @@ import { SECURITY_AI_SETTINGS } from '@kbn/elastic-assistant/impl/assistant/sett import { CONVERSATIONS_TAB } from '@kbn/elastic-assistant/impl/assistant/settings/const'; import type { SettingsTabs } from '@kbn/elastic-assistant/impl/assistant/settings/types'; +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'; @@ -36,15 +37,14 @@ export const ManagementSettings = React.memo(() => { } = useAssistantContext(); const spaceId = useSpaceId(); const { - application: { - navigateToApp, - capabilities: { - securitySolutionAssistant: { 'ai-assistant': securityAIAssistantEnabled }, - }, - }, + application, data: { dataViews }, chrome: { docTitle, setBreadcrumbs }, serverless, + settings, + docLinks, + featureFlags, + notifications, } = useKibana().services; const onFetchedConversations = useCallback( @@ -66,6 +66,12 @@ export const ManagementSettings = React.memo(() => { getDefaultConversation({ cTitle: WELCOME_CONVERSATION_TITLE }), [conversations, getDefaultConversation] ); + const { + navigateToApp, + capabilities: { + securitySolutionAssistant: { 'ai-assistant': securityAIAssistantEnabled }, + }, + } = application; docTitle.change(SECURITY_AI_SETTINGS); @@ -135,12 +141,20 @@ export const ManagementSettings = React.memo(() => { if (conversations) { 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 5d4677835d46e..24785707cc776 100644 --- a/x-pack/solutions/security/plugins/security_solution/tsconfig.json +++ b/x-pack/solutions/security/plugins/security_solution/tsconfig.json @@ -242,5 +242,6 @@ "@kbn/scout-security", "@kbn/inference-endpoint-ui-common", "@kbn/core-metrics-server", + "@kbn/ai-assistant-default-llm-setting", ] } diff --git a/yarn.lock b/yarn.lock index 27c355c37b937..e38ded5e4dd20 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3804,6 +3804,10 @@ version "0.0.0" uid "" +"@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" uid ""