From 18b517a9fb7f35b054424a1e9ebc81249c9779b2 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 22 Oct 2024 07:19:44 +1100 Subject: [PATCH] [8.16] [Security Assistant] Fix KB output fields (#196567) (#197118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Backport This will backport the following commits from `main` to `8.16`: - [[Security Assistant] Fix KB output fields (#196567)](https://github.com/elastic/kibana/pull/196567) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) Co-authored-by: Patryk Kopyciński --- .../impl/assistant/index.test.tsx | 107 +++++++++++++++ .../settings/assistant_settings.test.tsx | 13 +- .../assistant/settings/assistant_settings.tsx | 124 +++++++++++++++++- .../assistant_settings_button.test.tsx | 9 ++ .../settings/assistant_settings_button.tsx | 51 +++++-- .../index_entry_editor.test.tsx | 52 ++++---- .../index_entry_editor.tsx | 109 +++++++++++---- .../features/src/assistant/kibana_features.ts | 6 + 8 files changed, 406 insertions(+), 65 deletions(-) diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx index 5dfa3bc89c0e6..f16007a5e550f 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx @@ -23,6 +23,7 @@ import { Conversation } from '../assistant_context/types'; import * as all from './chat_send/use_chat_send'; import { useConversation } from './use_conversation'; import { AIConnector } from '../connectorland/connector_selector'; +import { omit } from 'lodash'; jest.mock('../connectorland/use_load_connectors'); jest.mock('../connectorland/connector_setup'); @@ -138,6 +139,112 @@ describe('Assistant', () => { >); }); + describe('persistent storage', () => { + it('should refetchCurrentUserConversations after settings save button click', async () => { + const chatSendSpy = jest.spyOn(all, 'useChatSend'); + await renderAssistant(); + + fireEvent.click(screen.getByTestId('settings')); + + jest.mocked(useFetchCurrentUserConversations).mockReturnValue({ + data: { + ...mockData, + welcome_id: { + ...mockData.welcome_id, + apiConfig: { newProp: true }, + }, + }, + isLoading: false, + refetch: jest.fn().mockResolvedValue({ + isLoading: false, + data: { + ...mockData, + welcome_id: { + ...mockData.welcome_id, + apiConfig: { newProp: true }, + }, + }, + }), + isFetched: true, + } as unknown as DefinedUseQueryResult, unknown>); + + await act(async () => { + fireEvent.click(screen.getByTestId('save-button')); + }); + + expect(chatSendSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + currentConversation: { + apiConfig: { newProp: true }, + category: 'assistant', + id: mockData.welcome_id.id, + messages: [], + title: 'Welcome', + replacements: {}, + }, + }) + ); + }); + + it('should refetchCurrentUserConversations after settings save button click, but do not update convos when refetch returns bad results', async () => { + jest.mocked(useFetchCurrentUserConversations).mockReturnValue({ + data: mockData, + isLoading: false, + refetch: jest.fn().mockResolvedValue({ + isLoading: false, + data: omit(mockData, 'welcome_id'), + }), + isFetched: true, + } as unknown as DefinedUseQueryResult, unknown>); + const chatSendSpy = jest.spyOn(all, 'useChatSend'); + await renderAssistant(); + + fireEvent.click(screen.getByTestId('settings')); + await act(async () => { + fireEvent.click(screen.getByTestId('save-button')); + }); + + expect(chatSendSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + currentConversation: { + apiConfig: { connectorId: '123' }, + replacements: {}, + category: 'assistant', + id: mockData.welcome_id.id, + messages: [], + title: 'Welcome', + }, + }) + ); + }); + + it('should delete conversation when delete button is clicked', async () => { + await renderAssistant(); + const deleteButton = screen.getAllByTestId('delete-option')[0]; + await act(async () => { + fireEvent.click(deleteButton); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('confirmModalConfirmButton')); + }); + + await waitFor(() => { + expect(mockDeleteConvo).toHaveBeenCalledWith(mockData.electric_sheep_id.id); + }); + }); + it('should refetchCurrentUserConversations after clear chat history button click', async () => { + await renderAssistant(); + fireEvent.click(screen.getByTestId('chat-context-menu')); + fireEvent.click(screen.getByTestId('clear-chat')); + fireEvent.click(screen.getByTestId('confirmModalConfirmButton')); + await waitFor(() => { + expect(clearConversation).toHaveBeenCalled(); + expect(refetchResults).toHaveBeenCalled(); + }); + }); + }); + describe('when selected conversation changes and some connectors are loaded', () => { it('should persist the conversation id to local storage', async () => { const getConversation = jest.fn().mockResolvedValue(mockData.electric_sheep_id); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.test.tsx index 14bfcb4cdbbec..c9f4f07d83b11 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.test.tsx @@ -38,7 +38,7 @@ const mockContext = { basePromptContexts: MOCK_QUICK_PROMPTS, setSelectedSettingsTab, http: {}, - assistantFeatures: { assistantModelEvaluation: true }, + assistantFeatures: { assistantModelEvaluation: true, assistantKnowledgeBaseByDefault: false }, selectedSettingsTab: 'CONVERSATIONS_TAB', assistantAvailability: { isAssistantEnabled: true, @@ -136,6 +136,17 @@ describe('AssistantSettings', () => { QUICK_PROMPTS_TAB, SYSTEM_PROMPTS_TAB, ])('%s', (tab) => { + it('Opens the tab on button click', () => { + (useAssistantContext as jest.Mock).mockImplementation(() => ({ + ...mockContext, + selectedSettingsTab: tab === CONVERSATIONS_TAB ? ANONYMIZATION_TAB : CONVERSATIONS_TAB, + })); + const { getByTestId } = render(, { + wrapper, + }); + fireEvent.click(getByTestId(`${tab}-button`)); + expect(setSelectedSettingsTab).toHaveBeenCalledWith(tab); + }); it('renders with the correct tab open', () => { (useAssistantContext as jest.Mock).mockImplementation(() => ({ ...mockContext, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx index f325e411bae2b..350780ea5b168 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx @@ -9,10 +9,14 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { EuiButton, EuiButtonEmpty, + EuiIcon, EuiModal, EuiModalFooter, + EuiKeyPadMenu, + EuiKeyPadMenuItem, EuiPage, EuiPageBody, + EuiPageSidebar, EuiSplitPanel, } from '@elastic/eui'; @@ -76,7 +80,16 @@ export const AssistantSettings: React.FC = React.memo( conversations, conversationsLoaded, }) => { - const { http, toasts, selectedSettingsTab, setSelectedSettingsTab } = useAssistantContext(); + const { + assistantFeatures: { + assistantModelEvaluation: modelEvaluatorEnabled, + assistantKnowledgeBaseByDefault, + }, + http, + toasts, + selectedSettingsTab, + setSelectedSettingsTab, + } = useAssistantContext(); useEffect(() => { if (selectedSettingsTab == null) { @@ -201,6 +214,115 @@ export const AssistantSettings: React.FC = React.memo( return ( + {!assistantKnowledgeBaseByDefault && ( + + + setSelectedSettingsTab(CONVERSATIONS_TAB)} + data-test-subj={`${CONVERSATIONS_TAB}-button`} + > + <> + + + + + setSelectedSettingsTab(QUICK_PROMPTS_TAB)} + data-test-subj={`${QUICK_PROMPTS_TAB}-button`} + > + <> + + + + + setSelectedSettingsTab(SYSTEM_PROMPTS_TAB)} + data-test-subj={`${SYSTEM_PROMPTS_TAB}-button`} + > + + + + setSelectedSettingsTab(ANONYMIZATION_TAB)} + data-test-subj={`${ANONYMIZATION_TAB}-button`} + > + + + setSelectedSettingsTab(KNOWLEDGE_BASE_TAB)} + data-test-subj={`${KNOWLEDGE_BASE_TAB}-button`} + > + + + {modelEvaluatorEnabled && ( + setSelectedSettingsTab(EVALUATION_TAB)} + data-test-subj={`${EVALUATION_TAB}-button`} + > + + + )} + + + )} + { const original = jest.requireActual('../../assistant_context'); @@ -57,6 +59,13 @@ describe('AssistantSettingsButton', () => { jest.clearAllMocks(); }); + it('Clicking the settings gear opens the conversations tab', () => { + const { getByTestId } = render(); + fireEvent.click(getByTestId('settings')); + expect(setSelectedSettingsTab).toHaveBeenCalledWith(CONVERSATIONS_TAB); + expect(setIsSettingsModalVisible).toHaveBeenCalledWith(true); + }); + it('Settings modal is visible and calls correct actions per click', () => { const { getByTestId } = render( diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.tsx index 40bf1e740ab60..3d6544643ba3e 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.tsx @@ -6,6 +6,7 @@ */ import React, { useCallback } from 'react'; +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { QueryObserverResult, RefetchOptions, RefetchQueryFilters } from '@tanstack/react-query'; import { DataStreamApis } from '../use_data_stream_apis'; import { AIConnector } from '../../connectorland/connector_selector'; @@ -13,6 +14,7 @@ import { Conversation } from '../../..'; import { AssistantSettings } from './assistant_settings'; import * as i18n from './translations'; import { useAssistantContext } from '../../assistant_context'; +import { CONVERSATIONS_TAB } from './const'; interface Props { defaultConnector?: AIConnector; @@ -45,7 +47,11 @@ export const AssistantSettingsButton: React.FC = React.memo( refetchCurrentUserConversations, refetchPrompts, }) => { - const { toasts } = useAssistantContext(); + const { + assistantFeatures: { assistantKnowledgeBaseByDefault }, + toasts, + setSelectedSettingsTab, + } = useAssistantContext(); // Modal control functions const cleanupAndCloseModal = useCallback(() => { @@ -73,18 +79,39 @@ export const AssistantSettingsButton: React.FC = React.memo( [cleanupAndCloseModal, refetchCurrentUserConversations, refetchPrompts, toasts] ); + const handleShowConversationSettings = useCallback(() => { + setSelectedSettingsTab(CONVERSATIONS_TAB); + setIsSettingsModalVisible(true); + }, [setIsSettingsModalVisible, setSelectedSettingsTab]); + return ( - isSettingsModalVisible && ( - - ) + <> + {!assistantKnowledgeBaseByDefault && ( + + + + )} + + {isSettingsModalVisible && ( + + )} + ); } ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.test.tsx index d4634cdf4c563..e4656b10d1d31 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.test.tsx @@ -42,10 +42,10 @@ describe('IndexEntryEditor', () => { jest.clearAllMocks(); }); - it('renders the form fields with initial values', () => { + it('renders the form fields with initial values', async () => { const { getByDisplayValue } = render(); - waitFor(() => { + await waitFor(() => { expect(getByDisplayValue('Test Entry')).toBeInTheDocument(); expect(getByDisplayValue('Test Description')).toBeInTheDocument(); expect(getByDisplayValue('Test Query Description')).toBeInTheDocument(); @@ -54,35 +54,37 @@ describe('IndexEntryEditor', () => { }); }); - it('updates the name field on change', () => { + it('updates the name field on change', async () => { const { getByTestId } = render(); - waitFor(() => { + await waitFor(() => { const nameInput = getByTestId('entry-name'); fireEvent.change(nameInput, { target: { value: 'New Entry Name' } }); + expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function)); }); - - expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function)); }); - it('updates the description field on change', () => { + it('updates the description field on change', async () => { const { getByTestId } = render(); - waitFor(() => { + + await waitFor(() => { const descriptionInput = getByTestId('entry-description'); fireEvent.change(descriptionInput, { target: { value: 'New Description' } }); }); - expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function)); + await waitFor(() => { + expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function)); + }); }); - it('updates the query description field on change', () => { + it('updates the query description field on change', async () => { const { getByTestId } = render(); - waitFor(() => { + + await waitFor(() => { const queryDescriptionInput = getByTestId('query-description'); fireEvent.change(queryDescriptionInput, { target: { value: 'New Query Description' } }); + expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function)); }); - - expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function)); }); it('displays sharing options and updates on selection', async () => { @@ -91,8 +93,6 @@ describe('IndexEntryEditor', () => { await waitFor(() => { fireEvent.click(getByTestId('sharing-select')); fireEvent.click(getByTestId('sharing-private-option')); - }); - await waitFor(() => { expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function)); }); }); @@ -100,28 +100,25 @@ describe('IndexEntryEditor', () => { it('fetches index options and updates on selection', async () => { const { getAllByTestId, getByTestId } = render(); - await waitFor(() => expect(mockDataViews.getIndices).toHaveBeenCalled()); - await waitFor(() => { + expect(mockDataViews.getIndices).toHaveBeenCalled(); fireEvent.click(getByTestId('index-combobox')); fireEvent.click(getAllByTestId('comboBoxToggleListButton')[0]); + fireEvent.click(getByTestId('index-2')); + expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function)); }); - fireEvent.click(getByTestId('index-2')); - - expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function)); }); it('fetches field options based on selected index and updates on selection', async () => { const { getByTestId, getAllByTestId } = render(); - await waitFor(() => + await waitFor(() => { expect(mockDataViews.getFieldsForWildcard).toHaveBeenCalledWith({ pattern: 'index-1', - fieldTypes: ['semantic_text'], - }) - ); + }); + }); - await waitFor(() => { + await waitFor(async () => { fireEvent.click(getByTestId('index-combobox')); fireEvent.click(getAllByTestId('comboBoxToggleListButton')[0]); }); @@ -135,7 +132,10 @@ describe('IndexEntryEditor', () => { within(getByTestId('entry-combobox')).getByTestId('comboBoxSearchInput'), 'field-3' ); - expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function)); + + await waitFor(() => { + expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function)); + }); }); it('disables the field combo box if no index is selected', () => { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx index 7475ea55ca5fc..550861bcbffd9 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx @@ -17,7 +17,7 @@ import { EuiSuperSelect, } from '@elastic/eui'; import useAsync from 'react-use/lib/useAsync'; -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { IndexEntry } from '@kbn/elastic-assistant-common'; import { DataViewsContract } from '@kbn/data-views-plugin/public'; import * as i18n from './translations'; @@ -96,29 +96,37 @@ export const IndexEntryEditor: React.FC = React.memo( })); }, [dataViews]); - const fieldOptions = useAsync(async () => { - const fields = await dataViews.getFieldsForWildcard({ - pattern: entry?.index ?? '', - fieldTypes: ['semantic_text'], - }); + const indexFields = useAsync( + async () => + dataViews.getFieldsForWildcard({ + pattern: entry?.index ?? '', + }), + [] + ); - return fields - .filter((field) => field.esTypes?.includes('semantic_text')) - .map((field) => ({ + const fieldOptions = useMemo( + () => + indexFields?.value + ?.filter((field) => field.esTypes?.includes('semantic_text')) + .map((field) => ({ + 'data-test-subj': field.name, + label: field.name, + value: field.name, + })) ?? [], + [indexFields?.value] + ); + + const outputFieldOptions = useMemo( + () => + indexFields?.value?.map((field) => ({ 'data-test-subj': field.name, label: field.name, value: field.name, - })); - }, [entry]); - - const setIndex = useCallback( - async (e: Array>) => { - setEntry((prevEntry) => ({ ...prevEntry, index: e[0]?.value })); - }, - [setEntry] + })) ?? [], + [indexFields?.value] ); - const onCreateOption = (searchValue: string) => { + const onCreateIndexOption = (searchValue: string) => { const normalizedSearchValue = searchValue.trim().toLowerCase(); if (!normalizedSearchValue) { @@ -131,7 +139,6 @@ export const IndexEntryEditor: React.FC = React.memo( }; setIndex([newOption]); - setField([{ label: '', value: '' }]); }; const onCreateFieldOption = (searchValue: string) => { @@ -170,6 +177,52 @@ export const IndexEntryEditor: React.FC = React.memo( [setEntry] ); + // Field + const setOutputFields = useCallback( + async (e: Array>) => { + setEntry((prevEntry) => ({ + ...prevEntry, + outputFields: e + ?.filter((option) => !!option.value) + .map((option) => option.value as string), + })); + }, + [setEntry] + ); + + const setIndex = useCallback( + async (e: Array>) => { + setEntry((prevEntry) => ({ ...prevEntry, index: e[0]?.value })); + setField([]); + setOutputFields([]); + }, + [setEntry, setField, setOutputFields] + ); + + const onCreateOutputFieldsOption = useCallback( + (searchValue: string) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + + const newOption: EuiComboBoxOptionOption = { + label: searchValue, + value: searchValue, + }; + + setOutputFields([ + ...(entry?.outputFields?.map((field) => ({ + label: field, + value: field, + })) ?? []), + newOption, + ]); + }, + [entry?.outputFields, setOutputFields] + ); + return ( = React.memo( aria-label={i18n.ENTRY_INDEX_NAME_INPUT_LABEL} isClearable={true} singleSelection={{ asPlainText: true }} - onCreateOption={onCreateOption} + onCreateOption={onCreateIndexOption} fullWidth options={indexOptions.value ?? []} selectedOptions={ @@ -228,7 +281,7 @@ export const IndexEntryEditor: React.FC = React.memo( singleSelection={{ asPlainText: true }} onCreateOption={onCreateFieldOption} fullWidth - options={fieldOptions.value ?? []} + options={fieldOptions} selectedOptions={ entry?.field ? [ @@ -281,11 +334,17 @@ export const IndexEntryEditor: React.FC = React.memo( ({ + label: field, + value: field, + })) ?? [] + } + onChange={setOutputFields} /> diff --git a/x-pack/packages/security-solution/features/src/assistant/kibana_features.ts b/x-pack/packages/security-solution/features/src/assistant/kibana_features.ts index e7bafd5595316..81cf7d18af129 100644 --- a/x-pack/packages/security-solution/features/src/assistant/kibana_features.ts +++ b/x-pack/packages/security-solution/features/src/assistant/kibana_features.ts @@ -26,6 +26,9 @@ export const getAssistantBaseKibanaFeature = (): BaseKibanaFeatureConfig => ({ app: [ASSISTANT_FEATURE_ID, 'kibana'], catalogue: [APP_ID], minimumLicense: 'enterprise', + management: { + kibana: ['securityAiAssistantManagement'], + }, privileges: { all: { api: ['elasticAssistant'], @@ -36,6 +39,9 @@ export const getAssistantBaseKibanaFeature = (): BaseKibanaFeatureConfig => ({ read: [], }, ui: [], + management: { + kibana: ['securityAiAssistantManagement'], + }, }, read: { // No read-only mode currently supported