From 575e80bcccf0b74af17f3cb635736a1968015b1a Mon Sep 17 00:00:00 2001 From: Rodney Norris Date: Wed, 18 Jun 2025 14:58:57 -0500 Subject: [PATCH] [Search][Playground] View Saved Playground (#223062) ## Summary This PR implements the frontend for opening a Saved Playground. As a part of that there are several refactors to the current playground that warrant regression testing. ### Testing To test the saved playground view the search mode feature flag should be enabled, either with a config override or via console: ``` POST kbn:/internal/kibana/settings/searchPlayground:searchModeEnabled {"value": true} ``` Then you will need to manually save a playground: ``` curl -X "PUT" "http://localhost:5601/internal/search_playground/playgrounds" \ -H 'elastic-api-version: 1' \ -H 'kbn-xsrf: dev' \ -H 'x-elastic-internal-origin: Kibana' \ -H 'Content-Type: application/json; charset=utf-8' \ -u 'elastic_serverless:' \ -d $'{ "elasticsearchQueryJSON": "{\\"retriever\\":{\\"standard\\":{\\"query\\":{\\"semantic\\":{\\"field\\":\\"text\\",\\"query\\":\\"{query}\\"}}}},\\"highlight\\":{\\"fields\\":{\\"text\\":{\\"type\\":\\"semantic\\",\\"number_of_fragments\\":2,\\"order\\":\\"score\\"}}}}", "indices": [ "search-test" ], "name": "Test playground", "queryFields": { "search-test": [ "text" ] } }' ``` *Note this creates a saved playground in the Default space, and playgrounds are space aware so it will only be available in the default space. If you want to create a playground in another space you will need to update this URL to include the space. This assumes you have a `search-test` index created using the semantic_text onboarding workflow mapping. Then you can open the saved playground page at: `/app/search_playground/p/` ## Screenshots Chat ![image](https://github.com/user-attachments/assets/700958ed-e0e4-4276-b670-4bd4b70b3df9) Chat - Query ![image](https://github.com/user-attachments/assets/4f2cb9f1-f1fe-47bd-b53d-4e59a4713689) Search - Query ![image](https://github.com/user-attachments/assets/be96dcd9-2395-4117-a7d9-1080a0e1895b) ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .buildkite/ftr_search_stateful_configs.yml | 2 +- .../public/navigation_tree.ts | 1 + .../plugins/search_playground/common/index.ts | 3 + .../search_playground/common/prompt.ts | 2 + .../search_playground/public/application.tsx | 5 +- .../public/components/chat.tsx | 23 +- .../public/components/field_error_callout.tsx | 39 +++ .../public/components/header.test.tsx | 27 +- .../public/components/header.tsx | 47 +++- .../public/components/not_found.tsx | 2 +- .../public/components/playgorund.tsx | 8 +- .../components/query_mode/chat_prompt.tsx | 6 +- .../query_mode/query_side_panel.tsx | 9 +- .../components/query_mode/query_viewer.tsx | 66 ++--- .../components/query_mode/search_query.tsx | 8 +- .../query_mode/search_query_mode.tsx | 9 +- .../saved_playground/saved_playground.tsx | 167 +++++++++++ .../saved_playground_fetch_error.tsx | 46 +++ .../summarization_panel.tsx | 2 - .../view_code/examples/dev_tools.tsx | 8 +- .../examples/py_lang_client.test.tsx | 5 +- .../view_code/examples/py_lang_client.tsx | 11 +- .../examples/py_langchain_python.test.tsx | 12 +- .../examples/py_langchain_python.tsx | 9 +- .../components/view_code/view_code_flyout.tsx | 15 +- .../public/hooks/use_elasticsearch_query.ts | 8 +- .../public/hooks/use_llms_models.test.ts | 150 ---------- .../public/hooks/use_llms_models.test.tsx | 181 ++++++++++++ .../public/hooks/use_llms_models.ts | 111 ++++---- .../public/hooks/use_load_connectors.ts | 171 ++---------- .../hooks/use_playground_breadcrumbs.ts | 47 ++-- .../hooks/use_saved_playground_parameters.ts | 18 ++ .../public/hooks/use_search_preview.ts | 7 +- ...se_page_mode.ts => use_show_setup_page.ts} | 20 +- .../hooks/use_user_query_validations.ts | 35 --- .../use_validate_playground_view_modes.ts | 28 ++ .../public/layout/page_template.tsx | 39 +++ .../public/playground_overview.tsx | 26 +- .../public/playground_router.tsx | 5 + .../providers/saved_playground_provider.tsx | 86 ++++++ .../providers/unsaved_form_provider.test.tsx | 4 - .../providers/unsaved_form_provider.tsx | 35 +-- .../search_playground/public/routes.ts | 6 + .../public/saved_playground.tsx | 24 ++ .../plugins/search_playground/public/types.ts | 26 +- .../utils/playground_connectors.test.ts | 225 +++++++++++++++ .../public/utils/playground_connectors.ts | 146 ++++++++++ .../utils/playground_form_resolver.test.ts | 264 ++++++++++++++++++ .../public/utils/playground_form_resolver.ts | 64 +++++ .../public/utils/saved_playgrounds.ts | 89 ++++++ .../public/utils/user_query.test.ts | 126 +++------ .../public/utils/user_query.ts | 93 +++--- .../playground_saved_object/schema/v1/v1.ts | 2 +- .../plugins/search_playground/tsconfig.json | 1 - .../public/navigation_tree.ts | 1 + .../search_playground/saved_playgrounds.ts | 123 ++++++++ .../utils/create_playground.ts | 71 +++++ .../apps/shared/solution_tour.ts | 29 ++ .../config/config.feature_flags.ts | 38 +++ .../ftr_provider_context.d.ts | 13 + .../functional_search/index.feature_flags.ts | 18 ++ x-pack/solutions/search/test/tsconfig.json | 3 + .../page_objects/search_playground_page.ts | 53 +++- .../test_suites/search/index.feature_flags.ts | 1 + .../search_playground/saved_playgrounds.ts | 136 +++++++++ .../utils/create_playground.ts | 67 +++++ 66 files changed, 2385 insertions(+), 737 deletions(-) create mode 100644 x-pack/solutions/search/plugins/search_playground/public/components/field_error_callout.tsx create mode 100644 x-pack/solutions/search/plugins/search_playground/public/components/saved_playground/saved_playground.tsx create mode 100644 x-pack/solutions/search/plugins/search_playground/public/components/saved_playground/saved_playground_fetch_error.tsx delete mode 100644 x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.test.ts create mode 100644 x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.test.tsx create mode 100644 x-pack/solutions/search/plugins/search_playground/public/hooks/use_saved_playground_parameters.ts rename x-pack/solutions/search/plugins/search_playground/public/hooks/{use_page_mode.ts => use_show_setup_page.ts} (62%) delete mode 100644 x-pack/solutions/search/plugins/search_playground/public/hooks/use_user_query_validations.ts create mode 100644 x-pack/solutions/search/plugins/search_playground/public/hooks/use_validate_playground_view_modes.ts create mode 100644 x-pack/solutions/search/plugins/search_playground/public/layout/page_template.tsx create mode 100644 x-pack/solutions/search/plugins/search_playground/public/providers/saved_playground_provider.tsx create mode 100644 x-pack/solutions/search/plugins/search_playground/public/saved_playground.tsx create mode 100644 x-pack/solutions/search/plugins/search_playground/public/utils/playground_connectors.test.ts create mode 100644 x-pack/solutions/search/plugins/search_playground/public/utils/playground_connectors.ts create mode 100644 x-pack/solutions/search/plugins/search_playground/public/utils/playground_form_resolver.test.ts create mode 100644 x-pack/solutions/search/plugins/search_playground/public/utils/playground_form_resolver.ts create mode 100644 x-pack/solutions/search/plugins/search_playground/public/utils/saved_playgrounds.ts create mode 100644 x-pack/solutions/search/test/functional_search/apps/search_playground/saved_playgrounds.ts create mode 100644 x-pack/solutions/search/test/functional_search/apps/search_playground/utils/create_playground.ts create mode 100644 x-pack/solutions/search/test/functional_search/apps/shared/solution_tour.ts create mode 100644 x-pack/solutions/search/test/functional_search/config/config.feature_flags.ts create mode 100644 x-pack/solutions/search/test/functional_search/ftr_provider_context.d.ts create mode 100644 x-pack/solutions/search/test/functional_search/index.feature_flags.ts create mode 100644 x-pack/test_serverless/functional/test_suites/search/search_playground/saved_playgrounds.ts create mode 100644 x-pack/test_serverless/functional/test_suites/search/search_playground/utils/create_playground.ts diff --git a/.buildkite/ftr_search_stateful_configs.yml b/.buildkite/ftr_search_stateful_configs.yml index 84036c73c896..a5647a88c4e5 100644 --- a/.buildkite/ftr_search_stateful_configs.yml +++ b/.buildkite/ftr_search_stateful_configs.yml @@ -6,6 +6,6 @@ defaultQueue: 'n2-4-spot' enabled: - x-pack/test/functional_search/config.ts - x-pack/test/functional/apps/search_playground/config.ts + - x-pack/solutions/search/test/functional_search/config/config.feature_flags.ts - x-pack/solutions/search/test/api_integration/apis/search_playground/config.ts - x-pack/solutions/search/test/api_integration/apis/guided_onboarding/config.ts - diff --git a/x-pack/solutions/search/plugins/enterprise_search/public/navigation_tree.ts b/x-pack/solutions/search/plugins/enterprise_search/public/navigation_tree.ts index c090202aa8af..80e81dee7945 100644 --- a/x-pack/solutions/search/plugins/enterprise_search/public/navigation_tree.ts +++ b/x-pack/solutions/search/plugins/enterprise_search/public/navigation_tree.ts @@ -142,6 +142,7 @@ export const getNavigationTreeDefinition = ({ }), }, { + breadcrumbStatus: 'hidden', link: 'searchPlayground', }, { diff --git a/x-pack/solutions/search/plugins/search_playground/common/index.ts b/x-pack/solutions/search/plugins/search_playground/common/index.ts index b765f4b151cc..3fd14848e9f8 100644 --- a/x-pack/solutions/search/plugins/search_playground/common/index.ts +++ b/x-pack/solutions/search/plugins/search_playground/common/index.ts @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import { Pagination } from './types'; +export { APIRoutes, type PlaygroundSavedObject } from './types'; export const PLUGIN_ID = 'searchPlayground'; export const PLUGIN_NAME = i18n.translate('xpack.searchPlayground.plugin.name', { @@ -34,3 +35,5 @@ export enum ROUTE_VERSIONS { } export const PLAYGROUND_SAVED_OBJECT_TYPE = 'search_playground'; + +export const DEFAULT_CONTEXT_DOCUMENTS = 3; diff --git a/x-pack/solutions/search/plugins/search_playground/common/prompt.ts b/x-pack/solutions/search/plugins/search_playground/common/prompt.ts index 3df5b63b6f36..17af9bef59f4 100644 --- a/x-pack/solutions/search/plugins/search_playground/common/prompt.ts +++ b/x-pack/solutions/search/plugins/search_playground/common/prompt.ts @@ -102,3 +102,5 @@ export const QuestionRewritePrompt = (options: QuestionRewritePromptOptions): st gemini: GeminiPrompt, }[options.type || 'openai'](systemInstructions, true); }; + +export const DEFAULT_LLM_PROMPT = 'You are an assistant for question-answering tasks.'; diff --git a/x-pack/solutions/search/plugins/search_playground/public/application.tsx b/x-pack/solutions/search/plugins/search_playground/public/application.tsx index 1e356cb9539e..f20d0228f796 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/application.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/application.tsx @@ -9,7 +9,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { QueryClientProvider } from '@tanstack/react-query'; import { CoreStart } from '@kbn/core/public'; -import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { I18nProvider } from '@kbn/i18n-react'; import { Router } from '@kbn/shared-ux-router'; @@ -24,7 +23,7 @@ export const renderApp = async ( const { PlaygroundRouter } = await import('./playground_router'); ReactDOM.render( - + core.rendering.addContext( @@ -34,7 +33,7 @@ export const renderApp = async ( - , + ), element ); diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/chat.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/chat.tsx index 093e3eaee547..cb7add21deec 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/components/chat.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/components/chat.tsx @@ -6,7 +6,7 @@ */ import React, { useEffect, useMemo, useState } from 'react'; -import { Controller, useFormContext } from 'react-hook-form'; +import { Controller, FieldErrors, useFormContext } from 'react-hook-form'; import { EuiButtonEmpty, EuiButtonIcon, @@ -38,17 +38,20 @@ import { useUsageTracker } from '../hooks/use_usage_tracker'; import { PlaygroundBodySection } from './playground_body_section'; import { elasticsearchQueryString } from '../utils/user_query'; -const buildFormData = (formData: PlaygroundForm): ChatRequestData => ({ - connector_id: formData[PlaygroundFormFields.summarizationModel].connectorId!, +const buildFormData = ( + formData: PlaygroundForm, + formErrors: FieldErrors +): ChatRequestData => ({ + connector_id: formData[PlaygroundFormFields.summarizationModel]!.connectorId!, prompt: formData[PlaygroundFormFields.prompt], indices: formData[PlaygroundFormFields.indices].join(), citations: formData[PlaygroundFormFields.citations], elasticsearch_query: elasticsearchQueryString( formData[PlaygroundFormFields.elasticsearchQuery], formData[PlaygroundFormFields.userElasticsearchQuery], - formData[PlaygroundFormFields.userElasticsearchQueryValidations] + formErrors[PlaygroundFormFields.userElasticsearchQuery] ), - summarization_model: formData[PlaygroundFormFields.summarizationModel].value, + summarization_model: formData[PlaygroundFormFields.summarizationModel]!.value, source_fields: JSON.stringify(formData[PlaygroundFormFields.sourceFields]), doc_size: formData[PlaygroundFormFields.docSize], }); @@ -57,7 +60,7 @@ export const Chat = () => { const { euiTheme } = useEuiTheme(); const { control, - formState: { isValid, isSubmitting }, + formState: { isValid, isSubmitting, errors: formErrors }, resetField, handleSubmit, getValues, @@ -70,7 +73,7 @@ export const Chat = () => { await append( { content: data.question, role: MessageRole.user, createdAt: new Date() }, { - data: buildFormData(data), + data: buildFormData(data, formErrors), } ); usageTracker?.click(AnalyticsEvents.chatQuestionSent); @@ -102,7 +105,7 @@ export const Chat = () => { setIsRegenerating(true); const formData = getValues(); await reload({ - data: buildFormData(formData), + data: buildFormData(formData, formErrors), }); setIsRegenerating(false); @@ -193,10 +196,6 @@ export const Chat = () => { name={PlaygroundFormFields.question} control={control} defaultValue="" - rules={{ - required: true, - validate: (rule) => !!rule?.trim(), - }} render={({ field }) => ( { + return ( + + ); +}; diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/header.test.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/header.test.tsx index c548ec707298..d59bd9184621 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/components/header.test.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/components/header.test.tsx @@ -16,14 +16,9 @@ import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { EuiForm } from '@elastic/eui'; import { FormProvider, useForm } from 'react-hook-form'; -const mockUsePlaygroundParameters = jest.fn(); - jest.mock('../hooks/use_source_indices_field', () => ({ useSourceIndicesFields: () => ({}), })); -jest.mock('../hooks/use_playground_parameters', () => ({ - usePlaygroundParameters: () => mockUsePlaygroundParameters(), -})); const MockFormProvider = ({ children }: { children: React.ReactElement }) => { const methods = useForm({ @@ -68,14 +63,15 @@ const MockPlaygroundForm = ({ describe('Header', () => { it('renders correctly', () => { - mockUsePlaygroundParameters.mockReturnValue({ - pageMode: PlaygroundPageMode.chat, - viewMode: PlaygroundViewMode.preview, - }); render( {}}> -
{}} onSelectPageModeChange={() => {}} /> +
{}} + onSelectPageModeChange={() => {}} + /> ); @@ -85,14 +81,15 @@ describe('Header', () => { }); it('renders correctly with preview mode', () => { - mockUsePlaygroundParameters.mockReturnValue({ - pageMode: PlaygroundPageMode.search, - viewMode: PlaygroundViewMode.preview, - }); render( {}}> -
{}} onSelectPageModeChange={() => {}} /> +
{}} + onSelectPageModeChange={() => {}} + /> ); diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/header.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/header.tsx index 7faae9ecc8f3..d230993c64e4 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/components/header.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/components/header.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { + EuiBadge, EuiButtonGroup, EuiFlexGroup, EuiPageHeaderSection, @@ -23,22 +24,28 @@ import { PlaygroundHeaderDocs } from './playground_header_docs'; import { Toolbar } from './toolbar'; import { PlaygroundPageMode, PlaygroundViewMode } from '../types'; import { useSearchPlaygroundFeatureFlag } from '../hooks/use_search_playground_feature_flag'; -import { usePlaygroundParameters } from '../hooks/use_playground_parameters'; interface HeaderProps { + pageMode: PlaygroundPageMode; + viewMode: PlaygroundViewMode; showDocs?: boolean; onModeChange: (mode: PlaygroundViewMode) => void; onSelectPageModeChange: (mode: PlaygroundPageMode) => void; isActionsDisabled?: boolean; + playgroundName?: string; + hasChanges?: boolean; } export const Header: React.FC = ({ + pageMode, + viewMode, onModeChange, showDocs = false, isActionsDisabled = false, onSelectPageModeChange, + playgroundName, + hasChanges, }) => { - const { pageMode, viewMode } = usePlaygroundParameters(); const isSearchModeEnabled = useSearchPlaygroundFeatureFlag(); const { euiTheme } = useEuiTheme(); const options: Array = [ @@ -74,15 +81,25 @@ export const Header: React.FC = ({ > - -

- -

-
+ {playgroundName === undefined ? ( + +

+ +

+
+ ) : ( + +

{playgroundName}

+
+ )} + {isSearchModeEnabled && ( = ({ onChange={(e) => onSelectPageModeChange(e.target.value as PlaygroundPageMode)} /> )} + {isSearchModeEnabled && playgroundName !== undefined && hasChanges ? ( + + + + ) : null}
diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/not_found.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/not_found.tsx index 278ac0807b6c..ad03d987d1c9 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/components/not_found.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/components/not_found.tsx @@ -52,7 +52,7 @@ export const PlaygroundRouteNotFound = () => {

} actions={ - + {i18n.translate('xpack.searchPlayground.notFound.action1', { defaultMessage: 'Back to Playground', })} diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/playgorund.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/playgorund.tsx index 0e4723381e58..579edd4788fa 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/components/playgorund.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/components/playgorund.tsx @@ -24,7 +24,8 @@ import { import { Chat } from './chat'; import { SearchMode } from './search_mode/search_mode'; import { SearchPlaygroundSetupPage } from './setup_page/search_playground_setup_page'; -import { usePageMode } from '../hooks/use_page_mode'; +import { useShowSetupPage } from '../hooks/use_show_setup_page'; +import { useValidatePlaygroundViewModes } from '../hooks/use_validate_playground_view_modes'; import { useKibana } from '../hooks/use_kibana'; import { usePlaygroundParameters } from '../hooks/use_playground_parameters'; import { useSearchPlaygroundFeatureFlag } from '../hooks/use_search_playground_feature_flag'; @@ -40,6 +41,7 @@ export interface AppProps { } export const Playground: React.FC = ({ showDocs = false }) => { + useValidatePlaygroundViewModes(); const isSearchModeEnabled = useSearchPlaygroundFeatureFlag(); const location = useLocation(); const { pageMode, viewMode } = usePlaygroundParameters(); @@ -66,7 +68,7 @@ export const Playground: React.FC = ({ showDocs = false }) => { navigateToView(pageMode, id, location.search); const handlePageModeChange = (mode: PlaygroundPageMode) => navigateToView(mode, viewMode, location.search); - const { showSetupPage } = usePageMode({ + const { showSetupPage } = useShowSetupPage({ hasSelectedIndices, hasConnectors: Boolean(connectors?.length), }); @@ -74,6 +76,8 @@ export const Playground: React.FC = ({ showDocs = false }) => { return ( <>
{ ({ name: PlaygroundFormFields.userElasticsearchQuery, }); - const { - field: { value: userElasticsearchQueryValidations }, - } = useController({ - name: PlaygroundFormFields.userElasticsearchQueryValidations, - }); const handleSearch = useCallback( (e: React.FormEvent) => { @@ -134,7 +129,7 @@ export const QuerySidePanel = ({ indexFields={group} updateFields={updateFields} queryFields={queryFields} - customizedQuery={userElasticsearchQueryValidations?.isUserCustomized ?? false} + customizedQuery={userElasticsearchQuery !== null} /> ))} diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/query_mode/query_viewer.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/query_mode/query_viewer.tsx index 41932ed2d6ed..63e3dc9e49d2 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/components/query_mode/query_viewer.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/components/query_mode/query_viewer.tsx @@ -10,7 +10,6 @@ import { EuiBadge, EuiButton, EuiButtonEmpty, - EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiSplitPanel, @@ -22,12 +21,13 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { CodeEditor } from '@kbn/code-editor'; import { monaco as monacoEditor } from '@kbn/monaco'; -import { Controller, useController, useFormContext } from 'react-hook-form'; +import { useController, useFormContext } from 'react-hook-form'; import { AnalyticsEvents } from '../../analytics/constants'; import { useUsageTracker } from '../../hooks/use_usage_tracker'; import { PlaygroundForm, PlaygroundFormFields } from '../../types'; import { FullHeight, QueryViewTitlePanel, PanelFillContainer } from './styles'; import { formatElasticsearchQueryString } from '../../utils/user_query'; +import { FieldErrorCallout } from '../field_error_callout'; export const ElasticsearchQueryViewer = ({ executeQuery, @@ -41,7 +41,7 @@ export const ElasticsearchQueryViewer = ({ const { euiTheme } = useEuiTheme(); const usageTracker = useUsageTracker(); const [esQueryFirstEdit, setEsQueryFirtEdit] = useState(false); - const { control } = useFormContext(); + const { trigger } = useFormContext(); const { field: { value: elasticsearchQuery }, } = useController({ @@ -49,14 +49,10 @@ export const ElasticsearchQueryViewer = ({ }); const { field: { value: userElasticsearchQuery, onChange: onChangeUserQuery }, + fieldState: { error: userElasticsearchQueryError }, } = useController({ name: PlaygroundFormFields.userElasticsearchQuery, }); - const { - field: { value: userElasticsearchQueryValidations }, - } = useController({ - name: PlaygroundFormFields.userElasticsearchQueryValidations, - }); const generatedEsQuery = useMemo( () => formatElasticsearchQueryString(elasticsearchQuery), [elasticsearchQuery] @@ -76,10 +72,12 @@ export const ElasticsearchQueryViewer = ({ setEsQueryFirtEdit(true); usageTracker?.count(AnalyticsEvents.editElasticsearchQuery); } - onChangeUserQuery(value); + onChangeUserQuery(value === generatedEsQuery ? null : value); + trigger(PlaygroundFormFields.userElasticsearchQuery); }, - [esQueryFirstEdit, generatedEsQuery, usageTracker, onChangeUserQuery] + [esQueryFirstEdit, generatedEsQuery, usageTracker, onChangeUserQuery, trigger] ); + const userElasticsearchQueryIsCustomized = userElasticsearchQuery !== null; return ( @@ -95,7 +93,7 @@ export const ElasticsearchQueryViewer = ({ /> - {userElasticsearchQueryValidations?.isUserCustomized ? ( + {userElasticsearchQueryIsCustomized ? ( - {userElasticsearchQueryValidations?.isUserCustomized ? ( + {userElasticsearchQuery !== null ? ( - {userElasticsearchQueryValidations?.isUserCustomized && - (userElasticsearchQueryValidations?.userQueryErrors?.length ?? 0) > 0 ? ( + {userElasticsearchQueryIsCustomized && userElasticsearchQueryError !== undefined ? ( - {userElasticsearchQueryValidations.userQueryErrors!.map((error, errorIndex) => ( - - ))} + ) : null} - ( - - )} + diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/query_mode/search_query.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/query_mode/search_query.tsx index a7ce1fd2d1d3..dd26f4dec566 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/components/query_mode/search_query.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/components/query_mode/search_query.tsx @@ -15,7 +15,6 @@ import { PlaygroundForm, PlaygroundFormFields } from '../../types'; export const SearchQuery = ({ isLoading }: { isLoading: boolean }) => { const { control } = useFormContext(); const { - field: { value: searchBarValue }, formState: { isSubmitting }, } = useController({ name: PlaygroundFormFields.searchQuery, @@ -29,8 +28,11 @@ export const SearchQuery = ({ isLoading }: { isLoading: boolean }) => { ({ - name: PlaygroundFormFields.userElasticsearchQueryValidations, + field: { value: userElasticsearchQuery }, + fieldState: { invalid: userElasticsearchQueryInvalid }, + } = useController({ + name: PlaygroundFormFields.userElasticsearchQuery, }); const executeQueryDisabled = disableExecuteQuery( - userElasticsearchQueryValidations, + userElasticsearchQuery === null || !userElasticsearchQueryInvalid, pageMode === PlaygroundPageMode.chat ? question : searchQuery ); const isLoading = fetchStatus !== 'idle'; diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/saved_playground/saved_playground.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/saved_playground/saved_playground.tsx new file mode 100644 index 000000000000..609e2de3b1d2 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/public/components/saved_playground/saved_playground.tsx @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { EuiFlexGroup, EuiLoadingSpinner } from '@elastic/eui'; +import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; +import { Route, Routes } from '@kbn/shared-ux-router'; + +import { PLUGIN_ID } from '../../../common'; +import { useKibana } from '../../hooks/use_kibana'; +import { useLLMsModels } from '../../hooks/use_llms_models'; +import { useLoadConnectors } from '../../hooks/use_load_connectors'; +import { usePlaygroundBreadcrumbs } from '../../hooks/use_playground_breadcrumbs'; +import { useSavedPlaygroundParameters } from '../../hooks/use_saved_playground_parameters'; +import { useShowSetupPage } from '../../hooks/use_show_setup_page'; +import { + PlaygroundPageMode, + PlaygroundViewMode, + SavedPlaygroundForm, + SavedPlaygroundFormFields, +} from '../../types'; +import { Header } from '../header'; +import { + SAVED_PLAYGROUND_CHAT_PATH, + SAVED_PLAYGROUND_CHAT_QUERY_PATH, + SAVED_PLAYGROUND_SEARCH_PATH, + SAVED_PLAYGROUND_SEARCH_QUERY_PATH, +} from '../../routes'; +import { Chat } from '../chat'; +import { ChatSetupPage } from '../setup_page/chat_setup_page'; +import { SearchMode } from '../search_mode/search_mode'; +import { SearchQueryMode } from '../query_mode/search_query_mode'; + +import { SavedPlaygroundFetchError } from './saved_playground_fetch_error'; + +export const SavedPlayground = () => { + const models = useLLMsModels(); + const { playgroundId, pageMode, viewMode } = useSavedPlaygroundParameters(); + const { application } = useKibana().services; + const { data: connectors } = useLoadConnectors(); + // TODO: need to handle errors from Form (loading errors, indices no longer exist etc.) + const { formState, watch, setValue } = useFormContext(); + const playgroundName = watch(SavedPlaygroundFormFields.name); + const playgroundIndices = watch(SavedPlaygroundFormFields.indices); + const summarizationModel = watch(SavedPlaygroundFormFields.summarizationModel); + usePlaygroundBreadcrumbs(playgroundName); + const { showSetupPage } = useShowSetupPage({ + hasSelectedIndices: true, // Saved Playgrounds always have indices ? (at least to be saved) + hasConnectors: Boolean(connectors?.length), + }); + const navigateToView = useCallback( + (page: PlaygroundPageMode, view?: PlaygroundViewMode, searchParams?: string) => { + let path = `/p/${playgroundId}/${page}`; + if (view && view !== PlaygroundViewMode.preview) { + path += `/${view}`; + } + if (searchParams) { + path += searchParams; + } + application.navigateToApp(PLUGIN_ID, { + path, + }); + }, + [application, playgroundId] + ); + useEffect(() => { + if (formState.isLoading) return; + if (pageMode === undefined) { + // If there is not a pageMode set we redirect based on if there is a model set in the + // saved playground as a best guess for default mode. until we save mode with the playground + navigateToView( + summarizationModel !== undefined ? PlaygroundPageMode.Chat : PlaygroundPageMode.Search, + PlaygroundViewMode.preview + ); + return; + } + // Handle Unknown modes + if (!Object.values(PlaygroundPageMode).includes(pageMode)) { + navigateToView( + summarizationModel !== undefined ? PlaygroundPageMode.Chat : PlaygroundPageMode.Search, + PlaygroundViewMode.preview + ); + return; + } + if (!Object.values(PlaygroundViewMode).includes(viewMode)) { + navigateToView(pageMode, PlaygroundViewMode.preview); + } + }, [pageMode, viewMode, summarizationModel, formState.isLoading, navigateToView]); + useEffect(() => { + // When opening chat mode without a model selected try to select a default model + // if one is available. + if (formState.isLoading) return; + if ( + pageMode === PlaygroundPageMode.Chat && + summarizationModel === undefined && + models.length > 0 + ) { + const defaultModel = models.find((model) => !model.disabled); + if (defaultModel) { + setValue(SavedPlaygroundFormFields.summarizationModel, defaultModel); + } + } + }, [formState.isLoading, pageMode, summarizationModel, models, setValue]); + + const handleModeChange = (id: PlaygroundViewMode) => + navigateToView(pageMode ?? PlaygroundPageMode.Search, id, location.search); + const handlePageModeChange = (mode: PlaygroundPageMode) => + navigateToView(mode, viewMode, location.search); + + const { isLoading } = formState; + if (isLoading || pageMode === undefined) { + return ( + + + + + + ); + } + if (playgroundName.length === 0 && playgroundIndices.length === 0) { + return ; + } + return ( + <> +
+ + {showSetupPage ? ( + <> + + {/* {isSearchModeEnabled && ( + // TODO: This should be impossible + )} */} + + ) : ( + <> + + } + /> + + } + /> + + )} + + + ); +}; diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/saved_playground/saved_playground_fetch_error.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/saved_playground/saved_playground_fetch_error.tsx new file mode 100644 index 000000000000..52b6f13596c7 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/public/components/saved_playground/saved_playground_fetch_error.tsx @@ -0,0 +1,46 @@ +/* + * 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, { useCallback, useMemo } from 'react'; +import { EuiButton } from '@elastic/eui'; +import { useFormContext } from 'react-hook-form'; +import { i18n } from '@kbn/i18n'; +import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; +import { PLUGIN_ID } from '../../../common'; +import { useKibana } from '../../hooks/use_kibana'; +import { SavedPlaygroundFormFetchError } from '../../types'; + +export const SavedPlaygroundFetchError = () => { + const { + services: { application }, + } = useKibana(); + const { getValues } = useFormContext(); + const formData = useMemo(() => getValues(), [getValues]); + const goToPlayground = useCallback(() => { + application.navigateToApp(PLUGIN_ID); + }, [application]); + return ( + + {i18n.translate('xpack.searchPlayground.savedPlayground.fetchError.title', { + defaultMessage: 'Error loading playground', + })} + + } + body={

{formData.error.message}

} + actions={ + + {i18n.translate('xpack.searchPlayground.savedPlayground.fetchError.action1', { + defaultMessage: 'Back to Playgrounds', + })} + + } + /> + ); +}; diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/summarization_panel/summarization_panel.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/summarization_panel/summarization_panel.tsx index bc9aec0fe1d1..9e6fc19db4ef 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/components/summarization_panel/summarization_panel.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/components/summarization_panel/summarization_panel.tsx @@ -23,7 +23,6 @@ export const SummarizationPanel: React.FC = () => { ( { } /> diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/dev_tools.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/dev_tools.tsx index 39dff1e97fc3..7bcabed3a8a6 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/dev_tools.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/dev_tools.tsx @@ -13,18 +13,20 @@ import { PlaygroundForm, PlaygroundFormFields } from '../../../types'; import { elasticsearchQueryObject } from '../../../utils/user_query'; export const DevToolsCode: React.FC = () => { - const { getValues } = useFormContext(); + const { + getValues, + formState: { errors: formErrors }, + } = useFormContext(); const { [PlaygroundFormFields.indices]: indices, [PlaygroundFormFields.elasticsearchQuery]: esQuery, [PlaygroundFormFields.searchQuery]: searchQuery, [PlaygroundFormFields.userElasticsearchQuery]: userElasticsearchQuery, - [PlaygroundFormFields.userElasticsearchQueryValidations]: userElasticsearchQueryValidations, } = getValues(); const query = elasticsearchQueryObject( esQuery, userElasticsearchQuery, - userElasticsearchQueryValidations + formErrors[PlaygroundFormFields.userElasticsearchQuery] ); const replacedQuery = searchQuery ?? '' diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_lang_client.test.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_lang_client.test.tsx index 3bfc6c676763..e08cccb439c7 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_lang_client.test.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_lang_client.test.tsx @@ -11,6 +11,7 @@ import { ES_CLIENT_DETAILS } from '../view_code_flyout'; import { PlaygroundForm } from '../../../types'; describe('PY_LANG_CLIENT function', () => { + const formErrors = {}; test('renders with correct content', () => { // Mocking necessary values for your function const formValues = { @@ -25,7 +26,7 @@ describe('PY_LANG_CLIENT function', () => { const clientDetails = ES_CLIENT_DETAILS('http://my-local-cloud-instance'); - const { container } = render(PY_LANG_CLIENT(formValues, clientDetails)); + const { container } = render(PY_LANG_CLIENT(formValues, formErrors, clientDetails)); expect(container.firstChild?.textContent).toMatchSnapshot(); }); @@ -43,7 +44,7 @@ describe('PY_LANG_CLIENT function', () => { const clientDetails = ES_CLIENT_DETAILS('http://my-local-cloud-instance'); - const { container } = render(PY_LANG_CLIENT(formValues, clientDetails)); + const { container } = render(PY_LANG_CLIENT(formValues, formErrors, clientDetails)); expect(container.firstChild?.textContent).toMatchSnapshot(); }); diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_lang_client.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_lang_client.tsx index 65488cd176b1..578ff50aef2b 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_lang_client.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_lang_client.tsx @@ -7,12 +7,17 @@ import { EuiCodeBlock } from '@elastic/eui'; import React from 'react'; -import { PlaygroundForm } from '../../../types'; +import { FieldErrors } from 'react-hook-form'; +import { PlaygroundForm, PlaygroundFormFields } from '../../../types'; import { Prompt } from '../../../../common/prompt'; import { elasticsearchQueryObject } from '../../../utils/user_query'; import { getESQuery } from './utils'; -export const PY_LANG_CLIENT = (formValues: PlaygroundForm, clientDetails: string) => ( +export const PY_LANG_CLIENT = ( + formValues: PlaygroundForm, + formErrors: FieldErrors, + clientDetails: string +) => ( {`## Install the required packages ## pip install -qU elasticsearch openai @@ -34,7 +39,7 @@ def get_elasticsearch_results(query): ...elasticsearchQueryObject( formValues.elasticsearch_query, formValues.user_elasticsearch_query, - formValues.user_elasticsearch_query_validations + formErrors[PlaygroundFormFields.userElasticsearchQuery] ), size: formValues.doc_size, })} diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_langchain_python.test.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_langchain_python.test.tsx index 7c43e01cee6c..9642f8fbb13b 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_langchain_python.test.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_langchain_python.test.tsx @@ -26,7 +26,11 @@ describe('LangchainPythonExmaple component', () => { const clientDetails = ES_CLIENT_DETAILS('http://my-local-cloud-instance'); const { container } = render( - + ); expect(container.firstChild?.textContent).toMatchSnapshot(); @@ -47,7 +51,11 @@ describe('LangchainPythonExmaple component', () => { const clientDetails = ES_CLIENT_DETAILS('http://my-local-cloud-instance'); const { container } = render( - + ); expect(container.firstChild?.textContent).toMatchSnapshot(); diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_langchain_python.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_langchain_python.tsx index 1ffda975c0e9..8e0c0234a8ed 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_langchain_python.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_langchain_python.tsx @@ -7,7 +7,8 @@ import { EuiCodeBlock } from '@elastic/eui'; import React, { useMemo } from 'react'; -import { PlaygroundForm } from '../../../types'; +import { FieldErrors } from 'react-hook-form'; +import { PlaygroundForm, PlaygroundFormFields } from '../../../types'; import { Prompt } from '../../../../common/prompt'; import { elasticsearchQueryObject } from '../../../utils/user_query'; import { getESQuery } from './utils'; @@ -31,9 +32,11 @@ export const getSourceFields = (sourceFields: PlaygroundForm['source_fields']) = export const LangchainPythonExmaple = ({ formValues, + formErrors, clientDetails, }: { formValues: PlaygroundForm; + formErrors: FieldErrors; clientDetails: string; }) => { const { esQuery, hasContentFieldsArray, indices, prompt, sourceFields } = useMemo(() => { @@ -43,7 +46,7 @@ export const LangchainPythonExmaple = ({ elasticsearchQueryObject( formValues.elasticsearch_query, formValues.user_elasticsearch_query, - formValues.user_elasticsearch_query_validations + formErrors[PlaygroundFormFields.userElasticsearchQuery] ) ), indices: formValues.indices.join(','), @@ -54,7 +57,7 @@ export const LangchainPythonExmaple = ({ }), ...fields, }; - }, [formValues]); + }, [formValues, formErrors]); return ( {`## Install the required packages diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/view_code_flyout.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/view_code_flyout.tsx index c28977456bb3..a84450a54658 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/view_code_flyout.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/view_code_flyout.tsx @@ -46,7 +46,10 @@ es_client = Elasticsearch( export const ViewCodeFlyout: React.FC = ({ onClose, selectedPageMode }) => { const usageTracker = useUsageTracker(); const [selectedLanguage, setSelectedLanguage] = useState('py-es-client'); - const { getValues } = useFormContext(); + const { + getValues, + formState: { errors: formErrors }, + } = useFormContext(); const formValues = getValues(); const { services: { cloud, http }, @@ -60,8 +63,14 @@ export const ViewCodeFlyout: React.FC = ({ onClose, selecte const CLIENT_STEP = ES_CLIENT_DETAILS(elasticsearchUrl); const steps: Record = { - 'lc-py': , - 'py-es-client': PY_LANG_CLIENT(formValues, CLIENT_STEP), + 'lc-py': ( + + ), + 'py-es-client': PY_LANG_CLIENT(formValues, formErrors, CLIENT_STEP), }; const handleLanguageChange = (e: React.ChangeEvent) => { setSelectedLanguage(e.target.value); diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_elasticsearch_query.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_elasticsearch_query.ts index 3384d8985e10..cdb67420a39c 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_elasticsearch_query.ts +++ b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_elasticsearch_query.ts @@ -19,13 +19,16 @@ import { elasticsearchQueryString } from '../utils/user_query'; export const useElasticsearchQuery = (pageMode: PlaygroundPageMode) => { const { http } = useKibana().services; - const { getValues } = useFormContext(); + const { + getValues, + formState: { errors: formErrors }, + } = useFormContext(); const executeEsQuery = () => { const formValues = getValues(); const esQuery = elasticsearchQueryString( formValues[PlaygroundFormFields.elasticsearchQuery], formValues[PlaygroundFormFields.userElasticsearchQuery], - formValues[PlaygroundFormFields.userElasticsearchQueryValidations] + formErrors[PlaygroundFormFields.userElasticsearchQuery] ); const body = pageMode === PlaygroundPageMode.chat @@ -50,6 +53,7 @@ export const useElasticsearchQuery = (pageMode: PlaygroundPageMode) => { }; const { refetch: executeQuery, ...rest } = useQuery({ + queryKey: ['searchPlayground', 'queryTest'], queryFn: executeEsQuery, enabled: false, retry: false, diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.test.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.test.ts deleted file mode 100644 index d416791df38f..000000000000 --- a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -/* - * 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 { renderHook } from '@testing-library/react'; -import { useLoadConnectors } from './use_load_connectors'; -import { useLLMsModels } from './use_llms_models'; -import { LLMs } from '../types'; - -jest.mock('./use_load_connectors', () => ({ - useLoadConnectors: jest.fn(), -})); - -const mockConnectors = [ - { id: 'connectorId1', name: 'OpenAI Connector', type: LLMs.openai }, - { id: 'connectorId2', name: 'OpenAI Azure Connector', type: LLMs.openai_azure }, - { id: 'connectorId2', name: 'Bedrock Connector', type: LLMs.bedrock }, - { id: 'connectorId3', name: 'OpenAI OSS Model Connector', type: LLMs.openai_other }, - { - id: 'connectorId4', - name: 'EIS Connector', - type: LLMs.inference, - config: { provider: 'openai' }, - }, -]; -const mockUseLoadConnectors = (data: any) => { - (useLoadConnectors as jest.Mock).mockReturnValue({ data }); -}; - -describe('useLLMsModels Hook', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('returns LLMModels with connectors available', () => { - mockUseLoadConnectors(mockConnectors); - - const { result } = renderHook(() => useLLMsModels()); - - expect(result.current).toEqual([ - { - connectorId: 'connectorId1', - connectorName: 'OpenAI Connector', - connectorType: LLMs.openai, - disabled: false, - icon: expect.any(String), - id: 'connectorId1OpenAI GPT-4o ', - name: 'OpenAI GPT-4o ', - showConnectorName: false, - value: 'gpt-4o', - promptTokenLimit: 128000, - }, - { - connectorId: 'connectorId1', - connectorName: 'OpenAI Connector', - connectorType: LLMs.openai, - disabled: false, - icon: expect.any(String), - id: 'connectorId1OpenAI GPT-4 Turbo ', - name: 'OpenAI GPT-4 Turbo ', - showConnectorName: false, - value: 'gpt-4-turbo', - promptTokenLimit: 128000, - }, - { - connectorId: 'connectorId1', - connectorName: 'OpenAI Connector', - connectorType: LLMs.openai, - disabled: false, - icon: expect.any(String), - id: 'connectorId1OpenAI GPT-3.5 Turbo ', - name: 'OpenAI GPT-3.5 Turbo ', - showConnectorName: false, - value: 'gpt-3.5-turbo', - promptTokenLimit: 16385, - }, - { - connectorId: 'connectorId2', - connectorName: 'OpenAI Azure Connector', - connectorType: LLMs.openai_azure, - disabled: false, - icon: expect.any(String), - id: 'connectorId2OpenAI Azure Connector (Azure OpenAI)', - name: 'OpenAI Azure Connector (Azure OpenAI)', - showConnectorName: false, - value: undefined, - promptTokenLimit: undefined, - }, - { - connectorId: 'connectorId2', - connectorName: 'Bedrock Connector', - connectorType: LLMs.bedrock, - disabled: false, - icon: expect.any(String), - id: 'connectorId2Anthropic Claude 3 Haiku', - name: 'Anthropic Claude 3 Haiku', - showConnectorName: false, - value: 'anthropic.claude-3-haiku-20240307-v1:0', - promptTokenLimit: 200000, - }, - { - connectorId: 'connectorId2', - connectorName: 'Bedrock Connector', - connectorType: LLMs.bedrock, - disabled: false, - icon: expect.any(String), - id: 'connectorId2Anthropic Claude 3.5 Sonnet', - name: 'Anthropic Claude 3.5 Sonnet', - showConnectorName: false, - value: 'anthropic.claude-3-5-sonnet-20240620-v1:0', - promptTokenLimit: 200000, - }, - { - connectorId: 'connectorId3', - connectorName: 'OpenAI OSS Model Connector', - connectorType: LLMs.openai_other, - disabled: false, - icon: expect.any(String), - id: 'connectorId3OpenAI OSS Model Connector (OpenAI Compatible Service)', - name: 'OpenAI OSS Model Connector (OpenAI Compatible Service)', - showConnectorName: false, - value: undefined, - promptTokenLimit: undefined, - }, - { - connectorId: 'connectorId4', - connectorName: 'EIS Connector', - connectorType: LLMs.inference, - disabled: false, - icon: expect.any(String), - id: 'connectorId4EIS Connector', - name: 'EIS Connector', - showConnectorName: false, - value: undefined, - promptTokenLimit: undefined, - }, - ]); - }); - - it('returns emptyd when connectors not available', () => { - mockUseLoadConnectors([]); - - const { result } = renderHook(() => useLLMsModels()); - - expect(result.current).toEqual([]); - }); -}); diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.test.tsx b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.test.tsx new file mode 100644 index 000000000000..8f22c752b03c --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.test.tsx @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { LoadConnectorsQuery } from './use_load_connectors'; +import { useLLMsModels } from './use_llms_models'; +import { LLMs } from '../types'; + +jest.mock('./use_load_connectors', () => ({ + useLoadConnectors: jest.fn(), + LOAD_CONNECTORS_QUERY_KEY: jest.requireActual('./use_load_connectors').LOAD_CONNECTORS_QUERY_KEY, + LoadConnectorsQuery: jest.fn(), +})); + +jest.mock('./use_kibana', () => ({ + useKibana: jest.fn().mockReturnValue({ + services: { + http: {}, + notifications: { + toasts: { + addError: jest.fn(), + }, + }, + }, + }), +})); + +const mockedLoadConnectorsQuery = jest.fn(); + +const mockConnectors = [ + { id: 'connectorId1', name: 'OpenAI Connector', type: LLMs.openai }, + { id: 'connectorId2', name: 'OpenAI Azure Connector', type: LLMs.openai_azure }, + { id: 'connectorId2', name: 'Bedrock Connector', type: LLMs.bedrock }, + { id: 'connectorId3', name: 'OpenAI OSS Model Connector', type: LLMs.openai_other }, + { + id: 'connectorId4', + name: 'EIS Connector', + type: LLMs.inference, + config: { provider: 'openai' }, + }, +]; +const mockLoadConnectorsQuery = (data: any) => { + (LoadConnectorsQuery as jest.Mock).mockReturnValue(mockedLoadConnectorsQuery); + mockedLoadConnectorsQuery.mockResolvedValue(data); +}; + +describe('useLLMsModels Query Hook', () => { + const queryClient = new QueryClient(); + const wrapper = ({ children }: React.PropsWithChildren<{}>) => ( + {children} + ); + beforeEach(() => { + jest.clearAllMocks(); + queryClient.clear(); + }); + + it('returns LLMModels with connectors available', async () => { + mockLoadConnectorsQuery(mockConnectors); + + const { result } = renderHook(() => useLLMsModels(), { wrapper }); + await waitFor(() => expect(mockedLoadConnectorsQuery).toHaveBeenCalledTimes(1)); + + await waitFor(() => + expect(result.current).toStrictEqual([ + { + connectorId: 'connectorId1', + connectorName: 'OpenAI Connector', + connectorType: LLMs.openai, + disabled: false, + icon: expect.any(String), + id: 'connectorId1OpenAI GPT-4o ', + name: 'OpenAI GPT-4o ', + showConnectorName: false, + value: 'gpt-4o', + promptTokenLimit: 128000, + }, + { + connectorId: 'connectorId1', + connectorName: 'OpenAI Connector', + connectorType: LLMs.openai, + disabled: false, + icon: expect.any(String), + id: 'connectorId1OpenAI GPT-4 Turbo ', + name: 'OpenAI GPT-4 Turbo ', + showConnectorName: false, + value: 'gpt-4-turbo', + promptTokenLimit: 128000, + }, + { + connectorId: 'connectorId1', + connectorName: 'OpenAI Connector', + connectorType: LLMs.openai, + disabled: false, + icon: expect.any(String), + id: 'connectorId1OpenAI GPT-3.5 Turbo ', + name: 'OpenAI GPT-3.5 Turbo ', + showConnectorName: false, + value: 'gpt-3.5-turbo', + promptTokenLimit: 16385, + }, + { + connectorId: 'connectorId2', + connectorName: 'OpenAI Azure Connector', + connectorType: LLMs.openai_azure, + disabled: false, + icon: expect.any(String), + id: 'connectorId2OpenAI Azure Connector (Azure OpenAI)', + name: 'OpenAI Azure Connector (Azure OpenAI)', + showConnectorName: false, + value: undefined, + promptTokenLimit: undefined, + }, + { + connectorId: 'connectorId2', + connectorName: 'Bedrock Connector', + connectorType: LLMs.bedrock, + disabled: false, + icon: expect.any(String), + id: 'connectorId2Anthropic Claude 3 Haiku', + name: 'Anthropic Claude 3 Haiku', + showConnectorName: false, + value: 'anthropic.claude-3-haiku-20240307-v1:0', + promptTokenLimit: 200000, + }, + { + connectorId: 'connectorId2', + connectorName: 'Bedrock Connector', + connectorType: LLMs.bedrock, + disabled: false, + icon: expect.any(String), + id: 'connectorId2Anthropic Claude 3.5 Sonnet', + name: 'Anthropic Claude 3.5 Sonnet', + showConnectorName: false, + value: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + promptTokenLimit: 200000, + }, + { + connectorId: 'connectorId3', + connectorName: 'OpenAI OSS Model Connector', + connectorType: LLMs.openai_other, + disabled: false, + icon: expect.any(String), + id: 'connectorId3OpenAI OSS Model Connector (OpenAI Compatible Service)', + name: 'OpenAI OSS Model Connector (OpenAI Compatible Service)', + showConnectorName: false, + value: undefined, + promptTokenLimit: undefined, + }, + { + connectorId: 'connectorId4', + connectorName: 'EIS Connector', + connectorType: LLMs.inference, + disabled: false, + icon: expect.any(String), + id: 'connectorId4EIS Connector', + name: 'EIS Connector', + showConnectorName: false, + value: undefined, + promptTokenLimit: undefined, + }, + ]) + ); + }); + + it('returns emptyd when connectors not available', async () => { + mockLoadConnectorsQuery([]); + + const { result } = renderHook(() => useLLMsModels(), { wrapper }); + + await waitFor(() => expect(mockedLoadConnectorsQuery).toHaveBeenCalledTimes(1)); + + expect(result.current).toEqual([]); + }); +}); diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.ts index abf1344e3efa..df3e77d2e330 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.ts +++ b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.ts @@ -5,14 +5,17 @@ * 2.0. */ +import { type QueryClient, useQuery, useQueryClient } from '@tanstack/react-query'; +import { HttpSetup } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; -import { useMemo } from 'react'; import { SERVICE_PROVIDERS } from '@kbn/inference-endpoint-ui-common'; + import type { PlaygroundConnector, InferenceActionConnector, ActionConnector } from '../types'; import { LLMs } from '../../common/types'; import { LLMModel } from '../types'; -import { useLoadConnectors } from './use_load_connectors'; import { MODELS } from '../../common/models'; +import { useKibana } from './use_kibana'; +import { LOAD_CONNECTORS_QUERY_KEY, LoadConnectorsQuery } from './use_load_connectors'; const isInferenceActionConnector = ( connector: ActionConnector @@ -96,52 +99,66 @@ const mapLlmToModels: Record< }, }; +export const LLMS_QUERY_KEY = ['search-playground', 'llms-models']; + +export const LLMsQuery = + (http: HttpSetup, client: QueryClient) => async (): Promise => { + const connectors = await client.fetchQuery({ + queryKey: LOAD_CONNECTORS_QUERY_KEY, + queryFn: LoadConnectorsQuery(http), + retry: false, + }); + + const mapConnectorTypeToCount = connectors.reduce>>( + (result, connector) => { + result[connector.type] = (result[connector.type] || 0) + 1; + return result; + }, + {} + ); + + const models = connectors.reduce((result, connector) => { + const connectorType = connector.type as LLMs; + const llmParams = mapLlmToModels[connectorType]; + + if (!llmParams) { + return result; + } + + const showConnectorName = Number(mapConnectorTypeToCount?.[connectorType]) > 1; + + llmParams + .getModels(connector.name, false) + .map(({ label, value, promptTokenLimit }) => ({ + id: connector?.id + label, + name: label, + value, + connectorType: connector.type, + connectorName: connector.name, + showConnectorName, + icon: typeof llmParams.icon === 'function' ? llmParams.icon(connector) : llmParams.icon, + disabled: !connector, + connectorId: connector.id, + promptTokenLimit, + })) + .forEach((model) => result.push(model)); + + return result; + }, []); + + return models; + }; + export const useLLMsModels = (): LLMModel[] => { - const { data: connectors } = useLoadConnectors(); + const client = useQueryClient(); + const { + services: { http }, + } = useKibana(); - const mapConnectorTypeToCount = useMemo( - () => - connectors?.reduce>>( - (result, connector) => ({ - ...result, - [connector.type]: (result[connector.type as LLMs] || 0) + 1, - }), - {} - ), - [connectors] - ); + const { data } = useQuery(LLMS_QUERY_KEY, LLMsQuery(http, client), { + keepPreviousData: true, + retry: false, + }); - return useMemo( - () => - connectors?.reduce((result, connector) => { - const connectorType = connector.type as LLMs; - const llmParams = mapLlmToModels[connectorType]; - - if (!llmParams) { - return result; - } - - const showConnectorName = Number(mapConnectorTypeToCount?.[connectorType]) > 1; - - return [ - ...result, - ...llmParams - .getModels(connector.name, false) - .map(({ label, value, promptTokenLimit }) => ({ - id: connector?.id + label, - name: label, - value, - connectorType: connector.type, - connectorName: connector.name, - showConnectorName, - icon: - typeof llmParams.icon === 'function' ? llmParams.icon(connector) : llmParams.icon, - disabled: !connector, - connectorId: connector.id, - promptTokenLimit, - })), - ]; - }, []) || [], - [connectors, mapConnectorTypeToCount] - ); + return data || []; }; diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_load_connectors.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_load_connectors.ts index e86ccd499dd9..8984f4ef4bd5 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_load_connectors.ts +++ b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_load_connectors.ts @@ -8,162 +8,39 @@ import type { UseQueryResult } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query'; import { loadAllActions as loadConnectors } from '@kbn/triggers-actions-ui-plugin/public/common/constants'; -import type { IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser'; +import type { HttpSetup, IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser'; import { i18n } from '@kbn/i18n'; -import { - OPENAI_CONNECTOR_ID, - OpenAiProviderType, - BEDROCK_CONNECTOR_ID, - GEMINI_CONNECTOR_ID, - INFERENCE_CONNECTOR_ID, -} from '@kbn/stack-connectors-plugin/public/common'; -import { isSupportedConnector } from '@kbn/inference-common'; -import { isInferenceEndpointExists } from '@kbn/inference-endpoint-ui-common'; import { useKibana } from './use_kibana'; -import { - LLMs, - type ActionConnector, - type UserConfiguredActionConnector, - type PlaygroundConnector, - InferenceActionConnector, -} from '../types'; +import { type PlaygroundConnector } from '../types'; +import { parsePlaygroundConnectors } from '../utils/playground_connectors'; -const QUERY_KEY = ['search-playground, load-connectors']; +export const LOAD_CONNECTORS_QUERY_KEY = ['search-playground, load-connectors']; -type OpenAIConnector = UserConfiguredActionConnector< - { apiProvider: OpenAiProviderType }, - Record ->; - -const connectorTypeToLLM: Array<{ - actionId: string; - actionProvider?: string; - match: (connector: ActionConnector) => boolean; - transform: (connector: ActionConnector) => PlaygroundConnector; -}> = [ - { - actionId: OPENAI_CONNECTOR_ID, - actionProvider: OpenAiProviderType.AzureAi, - match: (connector) => - connector.actionTypeId === OPENAI_CONNECTOR_ID && - (connector as OpenAIConnector)?.config?.apiProvider === OpenAiProviderType.AzureAi, - transform: (connector) => ({ - ...connector, - title: i18n.translate('xpack.searchPlayground.openAIAzureConnectorTitle', { - defaultMessage: 'OpenAI Azure', - }), - type: LLMs.openai_azure, - }), - }, - { - actionId: OPENAI_CONNECTOR_ID, - match: (connector) => - connector.actionTypeId === OPENAI_CONNECTOR_ID && - ((connector as OpenAIConnector)?.config?.apiProvider === OpenAiProviderType.OpenAi || - !!connector.isPreconfigured), - transform: (connector) => ({ - ...connector, - title: i18n.translate('xpack.searchPlayground.openAIConnectorTitle', { - defaultMessage: 'OpenAI', - }), - type: LLMs.openai, - }), - }, - { - actionId: OPENAI_CONNECTOR_ID, - actionProvider: OpenAiProviderType.Other, - match: (connector) => - connector.actionTypeId === OPENAI_CONNECTOR_ID && - (connector as OpenAIConnector)?.config?.apiProvider === OpenAiProviderType.Other, - transform: (connector) => ({ - ...connector, - title: i18n.translate('xpack.searchPlayground.openAIOtherConnectorTitle', { - defaultMessage: 'OpenAI Other', - }), - type: LLMs.openai_other, - }), - }, - { - actionId: BEDROCK_CONNECTOR_ID, - match: (connector) => connector.actionTypeId === BEDROCK_CONNECTOR_ID, - transform: (connector) => ({ - ...connector, - title: i18n.translate('xpack.searchPlayground.bedrockConnectorTitle', { - defaultMessage: 'Bedrock', - }), - type: LLMs.bedrock, - }), - }, - { - actionId: GEMINI_CONNECTOR_ID, - match: (connector) => connector.actionTypeId === GEMINI_CONNECTOR_ID, - transform: (connector) => ({ - ...connector, - title: i18n.translate('xpack.searchPlayground.geminiConnectorTitle', { - defaultMessage: 'Gemini', - }), - type: LLMs.gemini, - }), - }, - { - actionId: INFERENCE_CONNECTOR_ID, - match: (connector) => - connector.actionTypeId === INFERENCE_CONNECTOR_ID && isSupportedConnector(connector), - transform: (connector) => ({ - ...connector, - title: i18n.translate('xpack.searchPlayground.aiConnectorTitle', { - defaultMessage: 'AI Connector', - }), - type: LLMs.inference, - }), - }, -]; +export const LoadConnectorsQuery = (http: HttpSetup) => async () => { + const queryResult = await loadConnectors({ http }); + return parsePlaygroundConnectors(queryResult, http); +}; export const useLoadConnectors = (): UseQueryResult => { const { services: { http, notifications }, } = useKibana(); - return useQuery( - QUERY_KEY, - async () => { - const queryResult = await loadConnectors({ http }); - - return queryResult.reduce>(async (result, connector) => { - const { transform } = connectorTypeToLLM.find(({ match }) => match(connector)) || {}; - - if ( - !connector.isMissingSecrets && - !!transform && - (connector.actionTypeId !== '.inference' || - (connector.actionTypeId === '.inference' && - (await isInferenceEndpointExists( - http, - (connector as InferenceActionConnector)?.config?.inferenceId - )))) - ) { - return [...(await result), transform(connector)]; - } - - return result; - }, Promise.resolve([])); + return useQuery(LOAD_CONNECTORS_QUERY_KEY, LoadConnectorsQuery(http), { + retry: false, + keepPreviousData: true, + onError: (error: IHttpFetchError) => { + if (error.name !== 'AbortError') { + notifications?.toasts?.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { + title: i18n.translate('xpack.searchPlayground.loadConnectorsError', { + defaultMessage: + 'Error loading connectors. Please check your configuration and try again.', + }), + } + ); + } }, - { - retry: false, - keepPreviousData: true, - onError: (error: IHttpFetchError) => { - if (error.name !== 'AbortError') { - notifications?.toasts?.addError( - error.body && error.body.message ? new Error(error.body.message) : error, - { - title: i18n.translate('xpack.searchPlayground.loadConnectorsError', { - defaultMessage: - 'Error loading connectors. Please check your configuration and try again.', - }), - } - ); - } - }, - } - ); + }); }; diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_playground_breadcrumbs.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_playground_breadcrumbs.ts index 3fd64c3a7c2a..d4874ac859ca 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_playground_breadcrumbs.ts +++ b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_playground_breadcrumbs.ts @@ -9,31 +9,42 @@ import { useEffect } from 'react'; import { i18n } from '@kbn/i18n'; +import { PLUGIN_PATH } from '../../common'; import { useKibana } from './use_kibana'; -export const usePlaygroundBreadcrumbs = () => { - const { searchNavigation } = useKibana().services; +export const usePlaygroundBreadcrumbs = (playgroundName?: string) => { + const { cloud, http, searchNavigation } = useKibana().services; + const isServerless = cloud?.isServerlessEnabled ?? false; useEffect(() => { - searchNavigation?.breadcrumbs.setSearchBreadCrumbs( - [ - { - text: i18n.translate('xpack.searchPlayground.breadcrumbs.build', { - defaultMessage: 'Build', - }), - }, - { - text: i18n.translate('xpack.searchPlayground.breadcrumbs.playground', { - defaultMessage: 'Playground', - }), - }, - ], - { forClassicChromeStyle: true } - ); + searchNavigation?.breadcrumbs.setSearchBreadCrumbs([ + ...(isServerless + ? [] // Serverless is setting Build breadcrumb automatically + : [ + { + text: i18n.translate('xpack.searchPlayground.breadcrumbs.build', { + defaultMessage: 'Build', + }), + }, + ]), + { + text: i18n.translate('xpack.searchPlayground.breadcrumbs.playground', { + defaultMessage: 'Playground', + }), + href: playgroundName !== undefined ? http.basePath.prepend(PLUGIN_PATH) : undefined, + }, + ...(playgroundName !== undefined + ? [ + { + text: playgroundName, + }, + ] + : []), + ]); return () => { // Clear breadcrumbs on unmount; searchNavigation?.breadcrumbs.clearBreadcrumbs(); }; - }, [searchNavigation]); + }, [http, searchNavigation, isServerless, playgroundName]); }; diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_saved_playground_parameters.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_saved_playground_parameters.ts new file mode 100644 index 000000000000..6600b97ace52 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_saved_playground_parameters.ts @@ -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. + */ + +import { useParams } from 'react-router-dom'; +import { SavedPlaygroundRouterParameters, PlaygroundViewMode } from '../types'; + +export const useSavedPlaygroundParameters = () => { + const { playgroundId, pageMode, viewMode } = useParams(); + return { + playgroundId, + pageMode, + viewMode: viewMode ?? PlaygroundViewMode.preview, + }; +}; diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_search_preview.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_search_preview.ts index 448fbdc25bb0..8cc124087481 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_search_preview.ts +++ b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_search_preview.ts @@ -71,7 +71,10 @@ export const useSearchPreview = ({ const { services: { http }, } = useKibana(); - const { getValues } = useFormContext(); + const { + getValues, + formState: { errors: formErrors }, + } = useFormContext(); const indices = getValues(PlaygroundFormFields.indices); const elasticsearchQuery = getValues(PlaygroundFormFields.elasticsearchQuery); const queryFn = () => { @@ -79,7 +82,7 @@ export const useSearchPreview = ({ const elasticsearchQueryBody = elasticsearchQueryObject( formData[PlaygroundFormFields.elasticsearchQuery], formData[PlaygroundFormFields.userElasticsearchQuery], - formData[PlaygroundFormFields.userElasticsearchQueryValidations] + formErrors[PlaygroundFormFields.userElasticsearchQuery] ); return fetchSearchResults({ query, diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_page_mode.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_show_setup_page.ts similarity index 62% rename from x-pack/solutions/search/plugins/search_playground/public/hooks/use_page_mode.ts rename to x-pack/solutions/search/plugins/search_playground/public/hooks/use_show_setup_page.ts index 36c86a32679d..5bd6339c14f1 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_page_mode.ts +++ b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_show_setup_page.ts @@ -7,12 +7,10 @@ import { useEffect, useState } from 'react'; -import { PLUGIN_ID } from '../../common'; -import { PlaygroundPageMode, PlaygroundViewMode } from '../types'; -import { useKibana } from './use_kibana'; +import { PlaygroundPageMode } from '../types'; import { usePlaygroundParameters } from './use_playground_parameters'; -export const usePageMode = ({ +export const useShowSetupPage = ({ hasSelectedIndices, hasConnectors, }: { @@ -20,8 +18,7 @@ export const usePageMode = ({ hasConnectors: boolean; }) => { const [showSetupPage, setShowSetupPage] = useState(true); - const { pageMode, viewMode } = usePlaygroundParameters(); - const { application } = useKibana().services; + const { pageMode } = usePlaygroundParameters(); useEffect(() => { if (pageMode === PlaygroundPageMode.chat) { @@ -38,19 +35,8 @@ export const usePageMode = ({ } } }, [hasSelectedIndices, showSetupPage, pageMode, hasConnectors]); - useEffect(() => { - // Handle Unknown modes - if (!Object.values(PlaygroundPageMode).includes(pageMode)) { - application.navigateToApp(PLUGIN_ID, { path: '/not_found' }); - return; - } - if (!Object.values(PlaygroundViewMode).includes(viewMode)) { - application.navigateToApp(PLUGIN_ID, { path: `/${pageMode}` }); - } - }, [application, pageMode, viewMode]); return { showSetupPage, - pageMode, }; }; diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_user_query_validations.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_user_query_validations.ts deleted file mode 100644 index bfa025e9fed6..000000000000 --- a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_user_query_validations.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 { useEffect } from 'react'; -import { UseFormReturn } from 'react-hook-form/dist/types'; -import { useDebounceFn } from '@kbn/react-hooks'; - -import { PlaygroundForm, PlaygroundFormFields } from '../types'; -import { validateUserElasticSearchQuery } from '../utils/user_query'; - -const DEBOUNCE_OPTIONS = { wait: 500 }; -export const useUserQueryValidations = ({ - watch, - setValue, - getValues, -}: Pick, 'watch' | 'getValues' | 'setValue'>) => { - const userElasticsearchQuery = watch(PlaygroundFormFields.userElasticsearchQuery); - const elasticsearchQuery = watch(PlaygroundFormFields.elasticsearchQuery); - - const userQueryValidation = useDebounceFn(() => { - const [esQuery, userInputQuery] = getValues([ - PlaygroundFormFields.elasticsearchQuery, - PlaygroundFormFields.userElasticsearchQuery, - ]); - const validations = validateUserElasticSearchQuery(userInputQuery, esQuery); - setValue(PlaygroundFormFields.userElasticsearchQueryValidations, validations); - }, DEBOUNCE_OPTIONS); - useEffect(() => { - userQueryValidation.run(); - }, [elasticsearchQuery, userElasticsearchQuery, userQueryValidation]); -}; diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_validate_playground_view_modes.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_validate_playground_view_modes.ts new file mode 100644 index 000000000000..ea9a6ea1c955 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_validate_playground_view_modes.ts @@ -0,0 +1,28 @@ +/* + * 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 { useEffect } from 'react'; + +import { PLUGIN_ID } from '../../common'; +import { PlaygroundPageMode, PlaygroundViewMode } from '../types'; +import { useKibana } from './use_kibana'; +import { usePlaygroundParameters } from './use_playground_parameters'; + +export const useValidatePlaygroundViewModes = () => { + const { pageMode, viewMode } = usePlaygroundParameters(); + const { application } = useKibana().services; + useEffect(() => { + // Handle Unknown modes + if (!Object.values(PlaygroundPageMode).includes(pageMode)) { + application.navigateToApp(PLUGIN_ID, { path: '/not_found' }); + return; + } + if (!Object.values(PlaygroundViewMode).includes(viewMode)) { + application.navigateToApp(PLUGIN_ID, { path: `/${pageMode}` }); + } + }, [application, pageMode, viewMode]); +}; diff --git a/x-pack/solutions/search/plugins/search_playground/public/layout/page_template.tsx b/x-pack/solutions/search/plugins/search_playground/public/layout/page_template.tsx new file mode 100644 index 000000000000..60389c1042cc --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/public/layout/page_template.tsx @@ -0,0 +1,39 @@ +/* + * 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, { useMemo } from 'react'; +import { KibanaPageTemplate, KibanaPageTemplateProps } from '@kbn/shared-ux-page-kibana-template'; + +import { useKibana } from '../hooks/use_kibana'; + +export const SearchPlaygroundPageTemplate = ({ + children, + ...props +}: Partial) => { + const { + services: { history, console: consolePlugin, searchNavigation }, + } = useKibana(); + const embeddableConsole = useMemo( + () => (consolePlugin?.EmbeddableConsole ? : null), + [consolePlugin] + ); + + const allProps: KibanaPageTemplateProps = { + offset: 0, + restrictWidth: false, + grow: false, + panelled: false, + ...props, + }; + + return ( + + {children} + {embeddableConsole} + + ); +}; diff --git a/x-pack/solutions/search/plugins/search_playground/public/playground_overview.tsx b/x-pack/solutions/search/plugins/search_playground/public/playground_overview.tsx index f7175d7b2c9a..a81b8d4d7f6a 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/playground_overview.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/playground_overview.tsx @@ -5,38 +5,22 @@ * 2.0. */ -import React, { useMemo } from 'react'; -import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; +import React from 'react'; import { UnsavedFormProvider } from './providers/unsaved_form_provider'; -import { useKibana } from './hooks/use_kibana'; import { Playground } from './components/playgorund'; + import { usePlaygroundBreadcrumbs } from './hooks/use_playground_breadcrumbs'; +import { SearchPlaygroundPageTemplate } from './layout/page_template'; export const PlaygroundOverview = () => { - const { - services: { history, console: consolePlugin, searchNavigation }, - } = useKibana(); usePlaygroundBreadcrumbs(); - const embeddableConsole = useMemo( - () => (consolePlugin?.EmbeddableConsole ? : null), - [consolePlugin] - ); - return ( - + - {embeddableConsole} - + ); }; diff --git a/x-pack/solutions/search/plugins/search_playground/public/playground_router.tsx b/x-pack/solutions/search/plugins/search_playground/public/playground_router.tsx index c08b92cbe5db..9bdad1999443 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/playground_router.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/playground_router.tsx @@ -9,9 +9,11 @@ import { Route, Routes } from '@kbn/shared-ux-router'; import React from 'react'; import { Redirect } from 'react-router-dom'; import { PlaygroundOverview } from './playground_overview'; +import { SavedPlaygroundPage } from './saved_playground'; import { ROOT_PATH, + SAVED_PLAYGROUND_PATH, SEARCH_PLAYGROUND_CHAT_PATH, SEARCH_PLAYGROUND_NOT_FOUND, SEARCH_PLAYGROUND_SEARCH_PATH, @@ -28,6 +30,9 @@ export const PlaygroundRouter: React.FC = () => { {!isSearchModeEnabled && ( )} + {isSearchModeEnabled && ( + + )} diff --git a/x-pack/solutions/search/plugins/search_playground/public/providers/saved_playground_provider.tsx b/x-pack/solutions/search/plugins/search_playground/public/providers/saved_playground_provider.tsx new file mode 100644 index 000000000000..a9d56ed41424 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/public/providers/saved_playground_provider.tsx @@ -0,0 +1,86 @@ +/* + * 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, { useEffect } from 'react'; +import { type QueryClient, useQueryClient } from '@tanstack/react-query'; +import type { HttpSetup } from '@kbn/core-http-browser'; +import { FormProvider as ReactHookFormProvider, UseFormReturn, useForm } from 'react-hook-form'; + +import { ROUTE_VERSIONS } from '../../common'; +import { useKibana } from '../hooks/use_kibana'; +import { useLoadFieldsByIndices } from '../hooks/use_load_fields_by_indices'; + +import { LLMS_QUERY_KEY, LLMsQuery } from '../hooks/use_llms_models'; +import { + APIRoutes, + SavedPlaygroundForm, + PlaygroundResponse, + PlaygroundForm, + LLMModel, +} from '../types'; +import { savedPlaygroundFormResolver } from '../utils/playground_form_resolver'; +import { fetchSavedPlaygroundError, parseSavedPlayground } from '../utils/saved_playgrounds'; + +export interface SavedPlaygroundFormProviderProps { + playgroundId: string; +} + +interface FetchSavedPlaygroundOptions { + client: QueryClient; + http: HttpSetup; +} + +const fetchSavedPlayground = + (playgroundId: string, { http, client }: FetchSavedPlaygroundOptions) => + async (): Promise => { + let playgroundResp: PlaygroundResponse; + try { + playgroundResp = await http.get( + APIRoutes.GET_PLAYGROUND.replace('{id}', playgroundId), + { + version: ROUTE_VERSIONS.v1, + } + ); + } catch (e) { + return fetchSavedPlaygroundError(e); + } + let models: LLMModel[]; + try { + models = await client.fetchQuery(LLMS_QUERY_KEY, LLMsQuery(http, client)); + } catch (e) { + models = []; + } + + const result = parseSavedPlayground(playgroundResp, models); + return result; + }; + +export const SavedPlaygroundFormProvider = ({ + children, + playgroundId, +}: React.PropsWithChildren) => { + const client = useQueryClient(); + const { http } = useKibana().services; + const form = useForm({ + defaultValues: fetchSavedPlayground(playgroundId, { http, client }), + resolver: savedPlaygroundFormResolver, + reValidateMode: 'onChange', + context: { http }, + }); + useLoadFieldsByIndices({ + watch: form.watch, + setValue: form.setValue, + getValues: form.getValues, + // casting form handlers here because TS isn't happy with UseFormReturn even though SavedPlaygroundForm extends PlaygroundForm + } as unknown as Pick, 'watch' | 'getValues' | 'setValue'>); + useEffect(() => { + if (form.formState.isLoading) return; + // Trigger validation of existing values after initial loading. + form.trigger(); + }, [form, form.formState.isLoading]); + return {children}; +}; diff --git a/x-pack/solutions/search/plugins/search_playground/public/providers/unsaved_form_provider.test.tsx b/x-pack/solutions/search/plugins/search_playground/public/providers/unsaved_form_provider.test.tsx index d7a790d0a1ca..5dea667bae88 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/providers/unsaved_form_provider.test.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/providers/unsaved_form_provider.test.tsx @@ -54,10 +54,6 @@ const DEFAULT_FORM_STATE: Partial = { indices: [], summarization_model: undefined, user_elasticsearch_query: null, - user_elasticsearch_query_validations: { - isUserCustomized: false, - isValid: false, - }, }; describe('UnsavedFormProvider', () => { diff --git a/x-pack/solutions/search/plugins/search_playground/public/providers/unsaved_form_provider.tsx b/x-pack/solutions/search/plugins/search_playground/public/providers/unsaved_form_provider.tsx index 661631bc3ca3..fc5eb0aaafe0 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/providers/unsaved_form_provider.tsx +++ b/x-pack/solutions/search/plugins/search_playground/public/providers/unsaved_form_provider.tsx @@ -4,28 +4,29 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { FormProvider as ReactHookFormProvider, useForm } from 'react-hook-form'; +import { FormProvider as ReactHookFormProvider, useForm, UseFormGetValues } from 'react-hook-form'; import React, { useEffect, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom-v5-compat'; import { useDebounceFn } from '@kbn/react-hooks'; +import { DEFAULT_CONTEXT_DOCUMENTS } from '../../common'; +import { DEFAULT_LLM_PROMPT } from '../../common/prompt'; import { useIndicesValidation } from '../hooks/use_indices_validation'; import { useLoadFieldsByIndices } from '../hooks/use_load_fields_by_indices'; -import { useUserQueryValidations } from '../hooks/use_user_query_validations'; import { PlaygroundForm, PlaygroundFormFields } from '../types'; import { useLLMsModels } from '../hooks/use_llms_models'; +import { playgroundFormResolver } from '../utils/playground_form_resolver'; type PartialPlaygroundForm = Partial; export const LOCAL_STORAGE_KEY = 'search_playground_session'; -export const LOCAL_STORAGE_DEBOUNCE_OPTIONS = { wait: 100 }; +export const LOCAL_STORAGE_DEBOUNCE_OPTIONS = { wait: 100, maxWait: 500 }; const DEFAULT_FORM_VALUES: PartialPlaygroundForm = { - prompt: 'You are an assistant for question-answering tasks.', - doc_size: 3, + prompt: DEFAULT_LLM_PROMPT, + doc_size: DEFAULT_CONTEXT_DOCUMENTS, source_fields: {}, indices: [], summarization_model: undefined, [PlaygroundFormFields.userElasticsearchQuery]: null, - [PlaygroundFormFields.userElasticsearchQueryValidations]: undefined, }; const getLocalSession = (storage: Storage): PartialPlaygroundForm => { @@ -42,14 +43,10 @@ const getLocalSession = (storage: Storage): PartialPlaygroundForm => { } }; -const setLocalSession = (formState: PartialPlaygroundForm, storage: Storage) => { +const setLocalSession = (getValues: UseFormGetValues, storage: Storage) => { + const formState = getValues(); // omit question and search_query from the session state - const { - question, - search_query: _searchQuery, - [PlaygroundFormFields.userElasticsearchQueryValidations]: _queryValidations, - ...state - } = formState; + const { question, search_query: _searchQuery, ...state } = formState; storage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state)); }; @@ -76,6 +73,8 @@ export const UnsavedFormProvider: React.FC { - const subscription = form.watch((values) => - setLocalSessionDebounce.run(values as PartialPlaygroundForm, storage) + const subscription = form.watch((_values) => + setLocalSessionDebounce.run(form.getValues, storage) ); return () => subscription.unsubscribe(); }, [form, storage, setLocalSessionDebounce]); @@ -111,6 +105,7 @@ export const UnsavedFormProvider: React.FC { if (isValidatedIndices) { form.setValue(PlaygroundFormFields.indices, validIndices); + form.trigger(); } }, [form, isValidatedIndices, validIndices]); diff --git a/x-pack/solutions/search/plugins/search_playground/public/routes.ts b/x-pack/solutions/search/plugins/search_playground/public/routes.ts index 890a2b220ba8..8e79f41e1c30 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/routes.ts +++ b/x-pack/solutions/search/plugins/search_playground/public/routes.ts @@ -13,3 +13,9 @@ export const SEARCH_PLAYGROUND_CHAT_PATH = `${ROOT_PATH}chat`; export const PLAYGROUND_CHAT_QUERY_PATH = `${SEARCH_PLAYGROUND_CHAT_PATH}/query`; export const SEARCH_PLAYGROUND_SEARCH_PATH = `${ROOT_PATH}search`; export const PLAYGROUND_SEARCH_QUERY_PATH = `${SEARCH_PLAYGROUND_SEARCH_PATH}/query`; +export const SAVED_PLAYGROUND_BASE_PATH = `${ROOT_PATH}p/:playgroundId`; +export const SAVED_PLAYGROUND_PATH = `${SAVED_PLAYGROUND_BASE_PATH}/:pageMode?/:viewMode?`; +export const SAVED_PLAYGROUND_CHAT_PATH = `${SAVED_PLAYGROUND_BASE_PATH}/chat`; +export const SAVED_PLAYGROUND_CHAT_QUERY_PATH = `${SAVED_PLAYGROUND_CHAT_PATH}/query`; +export const SAVED_PLAYGROUND_SEARCH_PATH = `${SAVED_PLAYGROUND_BASE_PATH}/search`; +export const SAVED_PLAYGROUND_SEARCH_QUERY_PATH = `${SAVED_PLAYGROUND_SEARCH_PATH}/query`; diff --git a/x-pack/solutions/search/plugins/search_playground/public/saved_playground.tsx b/x-pack/solutions/search/plugins/search_playground/public/saved_playground.tsx new file mode 100644 index 000000000000..e0642e261836 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/public/saved_playground.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { SearchPlaygroundPageTemplate } from './layout/page_template'; +import { SavedPlayground } from './components/saved_playground/saved_playground'; +import { SavedPlaygroundFormProvider } from './providers/saved_playground_provider'; +import { useSavedPlaygroundParameters } from './hooks/use_saved_playground_parameters'; + +export const SavedPlaygroundPage = () => { + const { playgroundId } = useSavedPlaygroundParameters(); + return ( + + + + + + ); +}; diff --git a/x-pack/solutions/search/plugins/search_playground/public/types.ts b/x-pack/solutions/search/plugins/search_playground/public/types.ts index ea41f03af49c..020a28f4e623 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/types.ts +++ b/x-pack/solutions/search/plugins/search_playground/public/types.ts @@ -78,7 +78,6 @@ export enum PlaygroundFormFields { indices = 'indices', elasticsearchQuery = 'elasticsearch_query', userElasticsearchQuery = 'user_elasticsearch_query', - userElasticsearchQueryValidations = 'user_elasticsearch_query_validations', summarizationModel = 'summarization_model', sourceFields = 'source_fields', docSize = 'doc_size', @@ -91,22 +90,29 @@ export interface PlaygroundForm { [PlaygroundFormFields.prompt]: string; [PlaygroundFormFields.citations]: boolean; [PlaygroundFormFields.indices]: string[]; - [PlaygroundFormFields.summarizationModel]: LLMModel; + [PlaygroundFormFields.summarizationModel]: LLMModel | undefined; [PlaygroundFormFields.elasticsearchQuery]: { retriever: any }; // RetrieverContainer leads to "Type instantiation is excessively deep and possibly infinite" error [PlaygroundFormFields.sourceFields]: { [index: string]: string[] }; [PlaygroundFormFields.docSize]: number; [PlaygroundFormFields.queryFields]: { [index: string]: string[] }; [PlaygroundFormFields.searchQuery]: string; [PlaygroundFormFields.userElasticsearchQuery]: string | null | undefined; - [PlaygroundFormFields.userElasticsearchQueryValidations]: UserQueryValidations | undefined; } -export interface UserQueryValidations { - isValid: boolean; - isUserCustomized: boolean; - userQueryErrors?: string[]; +enum SavedPlaygroundFields { + name = 'name', } +export type SavedPlaygroundFormFields = PlaygroundFormFields | SavedPlaygroundFields; +export const SavedPlaygroundFormFields = { ...PlaygroundFormFields, ...SavedPlaygroundFields }; +export interface SavedPlaygroundForm extends PlaygroundForm { + [SavedPlaygroundFields.name]: string; +} + +export type SavedPlaygroundFormFetchError = SavedPlaygroundForm & { + error: Error; +}; + export interface Message { id: string; content: string | ReactNode; @@ -258,3 +264,9 @@ export interface PlaygroundRouterParameters { pageMode: PlaygroundPageMode; viewMode?: PlaygroundViewMode; } + +export interface SavedPlaygroundRouterParameters { + playgroundId: string; + pageMode?: PlaygroundPageMode; + viewMode?: PlaygroundViewMode; +} diff --git a/x-pack/solutions/search/plugins/search_playground/public/utils/playground_connectors.test.ts b/x-pack/solutions/search/plugins/search_playground/public/utils/playground_connectors.test.ts new file mode 100644 index 000000000000..06a220f55771 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/public/utils/playground_connectors.test.ts @@ -0,0 +1,225 @@ +/* + * 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. + */ + +const mockIsInferenceEndpointExists = jest.fn(); +jest.mock('@kbn/inference-endpoint-ui-common', () => ({ + isInferenceEndpointExists: (http: HttpSetup, inferenceId: string) => + mockIsInferenceEndpointExists(http, inferenceId), +})); + +import { + OPENAI_CONNECTOR_ID, + OpenAiProviderType, + BEDROCK_CONNECTOR_ID, + GEMINI_CONNECTOR_ID, + INFERENCE_CONNECTOR_ID, +} from '@kbn/stack-connectors-plugin/public/common'; +import { type ActionConnector } from '../types'; + +import { parsePlaygroundConnectors } from './playground_connectors'; +import { HttpSetup } from '@kbn/core/public'; + +describe('PlaygroundConnector utilities', () => { + const mockHttp = {} as unknown as HttpSetup; + beforeEach(() => { + jest.clearAllMocks(); + mockIsInferenceEndpointExists.mockResolvedValue(true); + }); + describe('parsePlaygroundConnectors', () => { + it('should parse open ai connectors', async () => { + const connectors: ActionConnector[] = [ + { + id: '1', + actionTypeId: OPENAI_CONNECTOR_ID, + isMissingSecrets: false, + config: { apiProvider: OpenAiProviderType.OpenAi }, + } as unknown as ActionConnector, + ]; + + expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([ + { + actionTypeId: '.gen-ai', + config: { + apiProvider: 'OpenAI', + }, + id: '1', + isMissingSecrets: false, + title: 'OpenAI', + type: 'openai', + }, + ]); + }); + it('should parse preconfigured open ai connectors', async () => { + const connectors: ActionConnector[] = [ + { + id: '1', + actionTypeId: OPENAI_CONNECTOR_ID, + isMissingSecrets: false, + isPreconfigured: true, + name: 'OpenAI', + } as unknown as ActionConnector, + ]; + + expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([ + { + actionTypeId: '.gen-ai', + id: '1', + isMissingSecrets: false, + isPreconfigured: true, + name: 'OpenAI', + title: 'OpenAI', + type: 'openai', + }, + ]); + }); + it('should parse azure open ai connectors', async () => { + const connectors: ActionConnector[] = [ + { + id: '3', + actionTypeId: OPENAI_CONNECTOR_ID, + isMissingSecrets: false, + config: { apiProvider: OpenAiProviderType.AzureAi }, + } as unknown as ActionConnector, + ]; + + expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([ + { + actionTypeId: '.gen-ai', + config: { + apiProvider: 'Azure OpenAI', + }, + id: '3', + isMissingSecrets: false, + title: 'OpenAI Azure', + type: 'openai_azure', + }, + ]); + }); + it('should parse other open ai connectors', async () => { + const connectors: ActionConnector[] = [ + { + id: '5', + actionTypeId: OPENAI_CONNECTOR_ID, + isMissingSecrets: false, + config: { apiProvider: OpenAiProviderType.Other }, + } as unknown as ActionConnector, + ]; + + expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([ + { + actionTypeId: '.gen-ai', + config: { + apiProvider: 'Other', + }, + id: '5', + isMissingSecrets: false, + title: 'OpenAI Other', + type: 'openai_other', + }, + ]); + }); + it('should parse bedrock connectors', async () => { + const connectors: ActionConnector[] = [ + { + id: '4', + actionTypeId: BEDROCK_CONNECTOR_ID, + isMissingSecrets: false, + } as unknown as ActionConnector, + ]; + + expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([ + { + actionTypeId: '.bedrock', + id: '4', + isMissingSecrets: false, + title: 'Bedrock', + type: 'bedrock', + }, + ]); + }); + it('should parse gemini connectors', async () => { + const connectors: ActionConnector[] = [ + { + id: '7', + actionTypeId: GEMINI_CONNECTOR_ID, + isMissingSecrets: false, + } as unknown as ActionConnector, + ]; + + expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([ + { + actionTypeId: '.gemini', + id: '7', + isMissingSecrets: false, + title: 'Gemini', + type: 'gemini', + }, + ]); + }); + it('should parse inference api connectors', async () => { + const connectors: ActionConnector[] = [ + { + id: '6', + actionTypeId: INFERENCE_CONNECTOR_ID, + isMissingSecrets: false, + config: { inferenceId: 'unit-test', provider: 'openai', taskType: 'chat_completion' }, + } as unknown as ActionConnector, + ]; + + expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([ + { + actionTypeId: '.inference', + config: { + inferenceId: 'unit-test', + provider: 'openai', + taskType: 'chat_completion', + }, + id: '6', + isMissingSecrets: false, + title: 'AI Connector', + type: 'inference', + }, + ]); + }); + it('should not include inference api connectors with out endpoints', async () => { + mockIsInferenceEndpointExists.mockResolvedValue(false); + const connectors: ActionConnector[] = [ + { + id: '6', + actionTypeId: INFERENCE_CONNECTOR_ID, + isMissingSecrets: false, + config: { inferenceId: 'unit-test', provider: 'openai', taskType: 'chat_completion' }, + } as unknown as ActionConnector, + ]; + + expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([]); + }); + it('should not include connectors with missing secrets', async () => { + const connectors: ActionConnector[] = [ + { + id: '3', + actionTypeId: OPENAI_CONNECTOR_ID, + isMissingSecrets: true, + config: { apiProvider: OpenAiProviderType.AzureAi }, + } as unknown as ActionConnector, + ]; + + expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([]); + }); + it('should not include connectors with out a transform defined', async () => { + const connectors: ActionConnector[] = [ + { + id: '2', + actionTypeId: 'slack', + isMissingSecrets: false, + } as unknown as ActionConnector, + ]; + + expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([]); + }); + }); +}); diff --git a/x-pack/solutions/search/plugins/search_playground/public/utils/playground_connectors.ts b/x-pack/solutions/search/plugins/search_playground/public/utils/playground_connectors.ts new file mode 100644 index 000000000000..97750c3fb3ad --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/public/utils/playground_connectors.ts @@ -0,0 +1,146 @@ +/* + * 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 { + OPENAI_CONNECTOR_ID, + OpenAiProviderType, + BEDROCK_CONNECTOR_ID, + GEMINI_CONNECTOR_ID, + INFERENCE_CONNECTOR_ID, +} from '@kbn/stack-connectors-plugin/public/common'; +import type { HttpSetup } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import { isSupportedConnector } from '@kbn/inference-common'; +import { isInferenceEndpointExists } from '@kbn/inference-endpoint-ui-common'; +import { + LLMs, + type ActionConnector, + type UserConfiguredActionConnector, + type PlaygroundConnector, + InferenceActionConnector, +} from '../types'; + +type OpenAIConnector = UserConfiguredActionConnector< + { apiProvider: OpenAiProviderType }, + Record +>; + +function isOpenAIConnector(connector: ActionConnector): connector is OpenAIConnector { + return connector.actionTypeId === OPENAI_CONNECTOR_ID; +} + +const connectorTypeToLLM: Array<{ + actionId: string; + actionProvider?: string; + match: (connector: ActionConnector) => boolean; + transform: (connector: ActionConnector) => PlaygroundConnector; +}> = [ + { + actionId: OPENAI_CONNECTOR_ID, + actionProvider: OpenAiProviderType.AzureAi, + match: (connector) => + isOpenAIConnector(connector) && connector?.config?.apiProvider === OpenAiProviderType.AzureAi, + transform: (connector) => ({ + ...connector, + title: i18n.translate('xpack.searchPlayground.openAIAzureConnectorTitle', { + defaultMessage: 'OpenAI Azure', + }), + type: LLMs.openai_azure, + }), + }, + { + actionId: OPENAI_CONNECTOR_ID, + match: (connector) => + isOpenAIConnector(connector) && + (connector?.config?.apiProvider === OpenAiProviderType.OpenAi || + Boolean(connector.isPreconfigured)), + transform: (connector) => ({ + ...connector, + title: i18n.translate('xpack.searchPlayground.openAIConnectorTitle', { + defaultMessage: 'OpenAI', + }), + type: LLMs.openai, + }), + }, + { + actionId: OPENAI_CONNECTOR_ID, + actionProvider: OpenAiProviderType.Other, + match: (connector) => + isOpenAIConnector(connector) && connector?.config?.apiProvider === OpenAiProviderType.Other, + transform: (connector) => ({ + ...connector, + title: i18n.translate('xpack.searchPlayground.openAIOtherConnectorTitle', { + defaultMessage: 'OpenAI Other', + }), + type: LLMs.openai_other, + }), + }, + { + actionId: BEDROCK_CONNECTOR_ID, + match: (connector) => connector.actionTypeId === BEDROCK_CONNECTOR_ID, + transform: (connector) => ({ + ...connector, + title: i18n.translate('xpack.searchPlayground.bedrockConnectorTitle', { + defaultMessage: 'Bedrock', + }), + type: LLMs.bedrock, + }), + }, + { + actionId: GEMINI_CONNECTOR_ID, + match: (connector) => connector.actionTypeId === GEMINI_CONNECTOR_ID, + transform: (connector) => ({ + ...connector, + title: i18n.translate('xpack.searchPlayground.geminiConnectorTitle', { + defaultMessage: 'Gemini', + }), + type: LLMs.gemini, + }), + }, + { + actionId: INFERENCE_CONNECTOR_ID, + match: (connector) => + connector.actionTypeId === INFERENCE_CONNECTOR_ID && isSupportedConnector(connector), + transform: (connector) => ({ + ...connector, + title: i18n.translate('xpack.searchPlayground.aiConnectorTitle', { + defaultMessage: 'AI Connector', + }), + type: LLMs.inference, + }), + }, +]; + +function isInferenceActionConnector( + connector: ActionConnector +): connector is InferenceActionConnector { + return connector.actionTypeId === INFERENCE_CONNECTOR_ID && isSupportedConnector(connector); +} + +export async function parsePlaygroundConnectors( + connectors: ActionConnector[], + http: HttpSetup +): Promise { + const playgroundConnectors: PlaygroundConnector[] = []; + for (const connector of connectors) { + const { transform } = connectorTypeToLLM.find(({ match }) => match(connector)) || {}; + if (transform === undefined) continue; + if (connector.isMissingSecrets) continue; + if (!isInferenceActionConnector(connector)) { + playgroundConnectors.push(transform(connector)); + } else { + const connectorInferenceEndpointExists = await isInferenceEndpointExists( + http, + connector.config.inferenceId + ); + if (connectorInferenceEndpointExists) { + playgroundConnectors.push(transform(connector)); + } + } + } + return playgroundConnectors; +} diff --git a/x-pack/solutions/search/plugins/search_playground/public/utils/playground_form_resolver.test.ts b/x-pack/solutions/search/plugins/search_playground/public/utils/playground_form_resolver.test.ts new file mode 100644 index 000000000000..80b2d3d85ef1 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/public/utils/playground_form_resolver.test.ts @@ -0,0 +1,264 @@ +/* + * 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 { ResolverOptions } from 'react-hook-form'; +import { + LLMs, + PlaygroundForm, + PlaygroundFormFields, + SavedPlaygroundForm, + SavedPlaygroundFormFields, +} from '../types'; +import { playgroundFormResolver, savedPlaygroundFormResolver } from './playground_form_resolver'; + +describe('Form Resolvers', () => { + const resolverContext = { http: {} }; + const mockLLM = { + connectorId: 'connectorId1', + connectorName: 'OpenAI Connector', + connectorType: LLMs.openai, + disabled: false, + icon: expect.any(String), + id: 'connectorId1OpenAI GPT-4o ', + name: 'OpenAI GPT-4o ', + showConnectorName: false, + value: 'gpt-4o', + promptTokenLimit: 128000, + }; + const validPlaygroundForm: PlaygroundForm = { + [PlaygroundFormFields.indices]: ['unitTest'], + [PlaygroundFormFields.queryFields]: { + unitTest: ['field1', 'field2'], + }, + [PlaygroundFormFields.sourceFields]: { + unitTest: ['field1', 'field2'], + }, + [PlaygroundFormFields.elasticsearchQuery]: { retriever: {} }, + [PlaygroundFormFields.userElasticsearchQuery]: null, + [PlaygroundFormFields.prompt]: 'This is a prompt', + [PlaygroundFormFields.citations]: false, + [PlaygroundFormFields.docSize]: 3, + [PlaygroundFormFields.summarizationModel]: { ...mockLLM }, + [PlaygroundFormFields.question]: 'Some question', + [PlaygroundFormFields.searchQuery]: 'search', + }; + + describe('playgroundFormResolver', () => { + const resolverOptions: ResolverOptions = { + criteriaMode: 'all', + fields: {}, + shouldUseNativeValidation: false, + }; + it('returns values when there are no errors', async () => { + expect( + playgroundFormResolver(validPlaygroundForm, resolverContext, resolverOptions) + ).resolves.toStrictEqual({ + values: validPlaygroundForm, + errors: {}, + }); + }); + it('validates user Elasticsearch query', async () => { + const values = { + ...validPlaygroundForm, + [PlaygroundFormFields.userElasticsearchQuery]: 'invalid query', + }; + expect( + playgroundFormResolver(values, resolverContext, resolverOptions) + ).resolves.toStrictEqual({ + values: {}, + errors: { + [PlaygroundFormFields.userElasticsearchQuery]: { + type: 'value', + message: expect.any(String), + }, + }, + }); + }); + it('validates summarizationModel', async () => { + const values = { + ...validPlaygroundForm, + [PlaygroundFormFields.summarizationModel]: undefined, + }; + expect( + playgroundFormResolver(values, resolverContext, resolverOptions) + ).resolves.toStrictEqual({ + values: {}, + errors: { + [PlaygroundFormFields.summarizationModel]: { + type: 'required', + }, + }, + }); + }); + it('validates prompt', async () => { + const values = { + ...validPlaygroundForm, + [PlaygroundFormFields.prompt]: '', + }; + expect( + playgroundFormResolver(values, resolverContext, resolverOptions) + ).resolves.toStrictEqual({ + values: {}, + errors: { + [PlaygroundFormFields.prompt]: { + type: 'required', + }, + }, + }); + + values[PlaygroundFormFields.prompt] = ' '; + expect( + playgroundFormResolver(values, resolverContext, resolverOptions) + ).resolves.toStrictEqual({ + values: {}, + errors: { + [PlaygroundFormFields.prompt]: { + type: 'required', + }, + }, + }); + }); + it('validates question', async () => { + const values = { + ...validPlaygroundForm, + [PlaygroundFormFields.question]: '', + }; + expect( + playgroundFormResolver(values, resolverContext, resolverOptions) + ).resolves.toStrictEqual({ + values: {}, + errors: { + [PlaygroundFormFields.question]: { + type: 'required', + }, + }, + }); + + values[PlaygroundFormFields.question] = ' '; + expect( + playgroundFormResolver(values, resolverContext, resolverOptions) + ).resolves.toStrictEqual({ + values: {}, + errors: { + [PlaygroundFormFields.question]: { + type: 'required', + }, + }, + }); + }); + }); + describe('savedPlaygroundFormResolver', () => { + const resolverOptions: ResolverOptions = { + criteriaMode: 'all', + fields: {}, + shouldUseNativeValidation: false, + }; + const validSavedPlaygroundForm: SavedPlaygroundForm = { + ...validPlaygroundForm, + [SavedPlaygroundFormFields.name]: 'my saved playground', + }; + + it('returns values when there are no errors', async () => { + expect( + savedPlaygroundFormResolver(validSavedPlaygroundForm, resolverContext, resolverOptions) + ).resolves.toStrictEqual({ + values: validSavedPlaygroundForm, + errors: {}, + }); + }); + it('validates user Elasticsearch query', async () => { + const values = { + ...validSavedPlaygroundForm, + [PlaygroundFormFields.userElasticsearchQuery]: 'invalid query', + }; + expect( + savedPlaygroundFormResolver(values, resolverContext, resolverOptions) + ).resolves.toStrictEqual({ + values: {}, + errors: { + [PlaygroundFormFields.userElasticsearchQuery]: { + type: 'value', + message: expect.any(String), + }, + }, + }); + }); + + it('validates summarizationModel', async () => { + const values = { + ...validSavedPlaygroundForm, + [PlaygroundFormFields.summarizationModel]: undefined, + }; + expect( + savedPlaygroundFormResolver(values, resolverContext, resolverOptions) + ).resolves.toStrictEqual({ + values: {}, + errors: { + [PlaygroundFormFields.summarizationModel]: { + type: 'required', + }, + }, + }); + }); + it('validates prompt', async () => { + const values = { + ...validSavedPlaygroundForm, + [PlaygroundFormFields.prompt]: '', + }; + expect( + savedPlaygroundFormResolver(values, resolverContext, resolverOptions) + ).resolves.toStrictEqual({ + values: {}, + errors: { + [PlaygroundFormFields.prompt]: { + type: 'required', + }, + }, + }); + + values[PlaygroundFormFields.prompt] = ' '; + expect( + savedPlaygroundFormResolver(values, resolverContext, resolverOptions) + ).resolves.toStrictEqual({ + values: {}, + errors: { + [PlaygroundFormFields.prompt]: { + type: 'required', + }, + }, + }); + }); + it('validates question', async () => { + const values = { + ...validSavedPlaygroundForm, + [PlaygroundFormFields.question]: '', + }; + expect( + savedPlaygroundFormResolver(values, resolverContext, resolverOptions) + ).resolves.toStrictEqual({ + values: {}, + errors: { + [PlaygroundFormFields.question]: { + type: 'required', + }, + }, + }); + + values[PlaygroundFormFields.question] = ' '; + expect( + savedPlaygroundFormResolver(values, resolverContext, resolverOptions) + ).resolves.toStrictEqual({ + values: {}, + errors: { + [PlaygroundFormFields.question]: { + type: 'required', + }, + }, + }); + }); + }); +}); diff --git a/x-pack/solutions/search/plugins/search_playground/public/utils/playground_form_resolver.ts b/x-pack/solutions/search/plugins/search_playground/public/utils/playground_form_resolver.ts new file mode 100644 index 000000000000..5bd5d77a7e57 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/public/utils/playground_form_resolver.ts @@ -0,0 +1,64 @@ +/* + * 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 { FieldErrors, Resolver, ResolverOptions } from 'react-hook-form'; +import { type PlaygroundForm, type SavedPlaygroundForm, PlaygroundFormFields } from '../types'; +import { validateUserElasticsearchQuery } from './user_query'; + +const REQUIRED_ERROR = { type: 'required' }; + +const hasErrors = (errors: FieldErrors): boolean => Object.keys(errors).length > 0; + +export const playgroundFormResolver: Resolver = async (values) => { + const errors: FieldErrors = {}; + + if (!values[PlaygroundFormFields.summarizationModel]) { + errors[PlaygroundFormFields.summarizationModel] = REQUIRED_ERROR; + } + if (!values[PlaygroundFormFields.prompt]) { + errors[PlaygroundFormFields.prompt] = REQUIRED_ERROR; + } else if (!values[PlaygroundFormFields.prompt].trim()) { + errors[PlaygroundFormFields.prompt] = REQUIRED_ERROR; + } + if (!values[PlaygroundFormFields.question]) { + errors[PlaygroundFormFields.question] = REQUIRED_ERROR; + } else if (!values[PlaygroundFormFields.question].trim()) { + errors[PlaygroundFormFields.question] = REQUIRED_ERROR; + } + const userQueryError = validateUserElasticsearchQuery( + values[PlaygroundFormFields.userElasticsearchQuery] + ); + if (userQueryError) { + errors[PlaygroundFormFields.userElasticsearchQuery] = userQueryError; + } + + if (hasErrors(errors)) { + return { + values: {}, + errors, + }; + } + + return { + values, + errors: {}, + }; +}; + +export const savedPlaygroundFormResolver: Resolver = async ( + values, + context, + options +) => { + const baseResult = await playgroundFormResolver( + values, + context, + options as unknown as ResolverOptions + ); + + return baseResult; +}; diff --git a/x-pack/solutions/search/plugins/search_playground/public/utils/saved_playgrounds.ts b/x-pack/solutions/search/plugins/search_playground/public/utils/saved_playgrounds.ts new file mode 100644 index 000000000000..db652563552e --- /dev/null +++ b/x-pack/solutions/search/plugins/search_playground/public/utils/saved_playgrounds.ts @@ -0,0 +1,89 @@ +/* + * 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 { DEFAULT_CONTEXT_DOCUMENTS } from '../../common'; +import { DEFAULT_LLM_PROMPT } from '../../common/prompt'; + +import { + LLMModel, + PlaygroundResponse, + PlaygroundSavedObject, + SavedPlaygroundForm, + SavedPlaygroundFormFetchError, + SavedPlaygroundFormFields, +} from '../types'; + +function parseSummarizationModel( + model: PlaygroundSavedObject['summarizationModel'], + models: LLMModel[] +): LLMModel | undefined { + if (!model) { + return undefined; + } + + if (model.modelId) { + const exactMatch = models.find( + (llm) => llm.connectorId === model.connectorId && llm.name === model.modelId + ); + if (exactMatch) { + return exactMatch; + } + } + return models.find((llm) => llm.connectorId === model.connectorId); +} + +export function parseSavedPlayground( + playground: PlaygroundResponse, + models: LLMModel[] +): SavedPlaygroundForm { + return { + [SavedPlaygroundFormFields.name]: playground.data.name, + [SavedPlaygroundFormFields.indices]: playground.data.indices, + [SavedPlaygroundFormFields.queryFields]: playground.data.queryFields as { + [index: string]: string[]; + }, + [SavedPlaygroundFormFields.elasticsearchQuery]: JSON.parse( + playground.data.elasticsearchQueryJSON + ) as { retriever: any }, // TODO: replace with function + [SavedPlaygroundFormFields.userElasticsearchQuery]: + playground.data.userElasticsearchQueryJSON ?? null, + [SavedPlaygroundFormFields.prompt]: playground.data.prompt ?? DEFAULT_LLM_PROMPT, + [SavedPlaygroundFormFields.citations]: playground.data.citations ?? false, + [SavedPlaygroundFormFields.sourceFields]: + (playground.data.context?.sourceFields as + | { + [index: string]: string[]; + } + | undefined) ?? {}, + [SavedPlaygroundFormFields.docSize]: + playground.data.context?.docSize ?? DEFAULT_CONTEXT_DOCUMENTS, + [SavedPlaygroundFormFields.summarizationModel]: parseSummarizationModel( + playground.data.summarizationModel, + models + ), + [SavedPlaygroundFormFields.question]: '', + [SavedPlaygroundFormFields.searchQuery]: '', + }; +} + +export function fetchSavedPlaygroundError(e: unknown): SavedPlaygroundFormFetchError { + return { + [SavedPlaygroundFormFields.name]: '', + [SavedPlaygroundFormFields.indices]: [], + [SavedPlaygroundFormFields.queryFields]: {}, + [SavedPlaygroundFormFields.elasticsearchQuery]: { retriever: {} }, + [SavedPlaygroundFormFields.userElasticsearchQuery]: null, + [SavedPlaygroundFormFields.prompt]: '', + [SavedPlaygroundFormFields.citations]: false, + [SavedPlaygroundFormFields.sourceFields]: {}, + [SavedPlaygroundFormFields.docSize]: 0, + [SavedPlaygroundFormFields.summarizationModel]: undefined, + [SavedPlaygroundFormFields.question]: '', + [SavedPlaygroundFormFields.searchQuery]: '', + error: e instanceof Error ? e : new Error(String(e)), + }; +} diff --git a/x-pack/solutions/search/plugins/search_playground/public/utils/user_query.test.ts b/x-pack/solutions/search/plugins/search_playground/public/utils/user_query.test.ts index 2215f48039f9..9d8d5b73093a 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/utils/user_query.test.ts +++ b/x-pack/solutions/search/plugins/search_playground/public/utils/user_query.test.ts @@ -5,27 +5,12 @@ * 2.0. */ -import { validateUserElasticSearchQuery, disableExecuteQuery } from './user_query'; +import { validateUserElasticsearchQuery, disableExecuteQuery } from './user_query'; describe('User Query utilities', () => { - describe('validateUserElasticSearchQuery', () => { - const sampleGeneratedElasticsearchQuery = { - retriever: { - standard: { - query: { - multi_match: { - query: '{query}', - fields: ['foo', 'bar', 'baz'], - }, - }, - }, - }, - }; - it('should return false if userQuery is null', () => { - expect(validateUserElasticSearchQuery(null, sampleGeneratedElasticsearchQuery)).toEqual({ - isValid: false, - isUserCustomized: false, - }); + describe('validateUserElasticsearchQuery', () => { + it('should return no error if userQuery is null', () => { + expect(validateUserElasticsearchQuery(null)).toEqual(undefined); }); it('should return valid false if userQuery is not a valid JSON', () => { const userQuery = `{ @@ -40,50 +25,27 @@ describe('User Query utilities', () => { } } }`; - expect(validateUserElasticSearchQuery(userQuery, sampleGeneratedElasticsearchQuery)).toEqual({ - isValid: false, - isUserCustomized: true, + expect(validateUserElasticsearchQuery(userQuery)).toEqual({ + type: 'validate', + message: expect.any(String), }); }); it('should return valid false if userQuery is empty', () => { - expect(validateUserElasticSearchQuery('', sampleGeneratedElasticsearchQuery)).toEqual({ - isValid: false, - isUserCustomized: true, - userQueryErrors: [expect.any(String)], + expect(validateUserElasticsearchQuery('')).toEqual({ + type: 'required', + message: expect.any(String), + types: { + value: expect.any(String), + required: expect.any(String), + }, }); - expect(validateUserElasticSearchQuery(' ', sampleGeneratedElasticsearchQuery)).toEqual({ - isValid: false, - isUserCustomized: true, - userQueryErrors: [expect.any(String)], - }); - }); - it('should return customized false if queries are equal', () => { - expect( - validateUserElasticSearchQuery( - JSON.stringify(sampleGeneratedElasticsearchQuery, null, 4), - sampleGeneratedElasticsearchQuery - ) - ).toEqual({ - isValid: true, - isUserCustomized: false, - }); - }); - it('should return customized false if queries are functionally equal', () => { - const userQuery = `{ - "retriever": { - "standard": { - "query": { - "multi_match": { - "fields": ["foo", "bar", "baz"], - "query": "{query}" - } - } - } - } -}`; - expect(validateUserElasticSearchQuery(userQuery, sampleGeneratedElasticsearchQuery)).toEqual({ - isValid: true, - isUserCustomized: false, + expect(validateUserElasticsearchQuery(' ')).toEqual({ + type: 'required', + message: expect.any(String), + types: { + value: expect.any(String), + required: expect.any(String), + }, }); }); it('should return valid false if user query removes {query} placeholder', () => { @@ -99,59 +61,41 @@ describe('User Query utilities', () => { } } }`; - expect(validateUserElasticSearchQuery(userQuery, sampleGeneratedElasticsearchQuery)).toEqual({ - isValid: false, - isUserCustomized: true, - userQueryErrors: ['User query must contain "{query}"'], + expect(validateUserElasticsearchQuery(userQuery)).toEqual({ + type: 'value', + message: expect.any(String), }); }); it('should include {query} placeholder error even when query is not valid JSON', () => { const userQuery = `invalid`; - expect(validateUserElasticSearchQuery(userQuery, sampleGeneratedElasticsearchQuery)).toEqual({ - isValid: false, - isUserCustomized: true, - userQueryErrors: [expect.any(String)], + expect(validateUserElasticsearchQuery(userQuery)).toEqual({ + type: 'value', + message: expect.any(String), }); }); }); describe('disableExecuteQuery', () => { it('should return true if query is null', () => { - expect(disableExecuteQuery(undefined, null)).toEqual(true); + expect(disableExecuteQuery(false, null)).toEqual(true); }); it('should return true if query is empty', () => { - expect(disableExecuteQuery(undefined, '')).toEqual(true); - expect(disableExecuteQuery(undefined, ' ')).toEqual(true); + expect(disableExecuteQuery(false, '')).toEqual(true); + expect(disableExecuteQuery(false, ' ')).toEqual(true); }); it('should return true if query is undefined', () => { - expect(disableExecuteQuery(undefined, undefined)).toEqual(true); + expect(disableExecuteQuery(false, undefined)).toEqual(true); }); it('should return true if query is invalid', () => { - const validations = { - isValid: false, - isUserCustomized: true, - }; - expect(disableExecuteQuery(validations, 'test')).toEqual(true); + expect(disableExecuteQuery(false, 'test')).toEqual(true); }); it('should return false if query is valid', () => { - const validations = { - isValid: true, - isUserCustomized: true, - }; - expect(disableExecuteQuery(validations, 'test')).toEqual(false); + expect(disableExecuteQuery(true, 'test')).toEqual(false); }); it('should return false if query is valid and userCustomized is false', () => { - const validations = { - isValid: true, - isUserCustomized: false, - }; - expect(disableExecuteQuery(validations, 'test')).toEqual(false); + expect(disableExecuteQuery(true, 'test')).toEqual(false); }); it('should return false if query is valid and userCustomized is true', () => { - const validations = { - isValid: true, - isUserCustomized: true, - }; - expect(disableExecuteQuery(validations, 'test')).toEqual(false); + expect(disableExecuteQuery(true, 'test')).toEqual(false); }); }); }); diff --git a/x-pack/solutions/search/plugins/search_playground/public/utils/user_query.ts b/x-pack/solutions/search/plugins/search_playground/public/utils/user_query.ts index 7d97224f213e..a6bd36ad156c 100644 --- a/x-pack/solutions/search/plugins/search_playground/public/utils/user_query.ts +++ b/x-pack/solutions/search/plugins/search_playground/public/utils/user_query.ts @@ -5,68 +5,73 @@ * 2.0. */ -import deepEqual from 'fast-deep-equal'; +import type { FieldError } from 'react-hook-form'; import { i18n } from '@kbn/i18n'; -import type { PlaygroundForm, PlaygroundFormFields, UserQueryValidations } from '../types'; +import type { PlaygroundForm, PlaygroundFormFields } from '../types'; -export const validateUserElasticSearchQuery = ( - userQuery: PlaygroundForm[PlaygroundFormFields.userElasticsearchQuery], - elasticsearchQuery: PlaygroundForm[PlaygroundFormFields.elasticsearchQuery] -): UserQueryValidations => { - if (userQuery === null || userQuery === undefined || typeof userQuery !== 'string') { - return { isValid: false, isUserCustomized: false }; +const USER_QUERY_PLACEHOLDER_MISSING_ERROR = i18n.translate( + 'xpack.searchPlayground.userQuery.errors.queryPlaceholder', + { + defaultMessage: 'Elasticsearch query must contain "{query}"', + values: { query: '{query}' }, } - let userQueryErrors: string[] | undefined; - if (!userQuery.includes('{query}')) { - userQueryErrors = [ - i18n.translate('xpack.searchPlayground.userQuery.errors.queryPlaceholder', { - defaultMessage: 'User query must contain "{query}"', - values: { query: '{query}' }, - }), - ]; +); +const USER_QUERY_CANNOT_BE_EMPTY_ERROR = i18n.translate( + 'xpack.searchPlayground.userQuery.errors.queryCannotBeEmpty', + { + defaultMessage: 'Elasticsearch query cannot be empty', } +); +const USER_QUERY_MUST_BE_VALID_JSON_ERROR = i18n.translate( + 'xpack.searchPlayground.userQuery.errors.queryMustBeValidJson', + { + defaultMessage: 'Elasticsearch query must be valid JSON', + } +); + +export const validateUserElasticsearchQuery = ( + userQuery: PlaygroundForm[PlaygroundFormFields.userElasticsearchQuery] +): FieldError | undefined => { + if (userQuery === null || userQuery === undefined || typeof userQuery !== 'string') + return undefined; + if (userQuery.trim().length === 0) { - return { isValid: false, isUserCustomized: true, userQueryErrors }; - } - let userQueryObject: {} = {}; - try { - userQueryObject = JSON.parse(userQuery); - } catch (e) { - return { isValid: false, isUserCustomized: true, userQueryErrors }; - } - if (deepEqual(userQueryObject, elasticsearchQuery)) { - return { isValid: true, isUserCustomized: false }; - } - if (userQueryErrors && userQueryErrors.length > 0) { return { - isValid: false, - isUserCustomized: true, - userQueryErrors, + type: 'required', + message: USER_QUERY_CANNOT_BE_EMPTY_ERROR, + types: { + value: USER_QUERY_PLACEHOLDER_MISSING_ERROR, + required: USER_QUERY_CANNOT_BE_EMPTY_ERROR, + }, }; } - return { isValid: true, isUserCustomized: true }; + if (!userQuery.includes('{query}')) { + return { type: 'value', message: USER_QUERY_PLACEHOLDER_MISSING_ERROR }; + } + try { + JSON.parse(userQuery); + } catch (e) { + return { type: 'validate', message: USER_QUERY_MUST_BE_VALID_JSON_ERROR }; // return query must be valid JSON error + } + return undefined; }; export const disableExecuteQuery = ( - validations: UserQueryValidations | undefined, + validElasticsearchQuery: boolean, query: string | null | undefined ): boolean => { - return ( - (validations?.isUserCustomized === true && validations?.isValid === false) || - !query || - query.trim().length === 0 - ); + return !validElasticsearchQuery || !query || query.trim().length === 0; }; export const elasticsearchQueryString = ( elasticsearchQuery: PlaygroundForm[PlaygroundFormFields.elasticsearchQuery], userElasticsearchQuery: PlaygroundForm[PlaygroundFormFields.userElasticsearchQuery], - userElasticsearchQueryValidations: PlaygroundForm[PlaygroundFormFields.userElasticsearchQueryValidations] + userElasticsearchQueryError: FieldError | undefined ) => { - if (!userElasticsearchQuery || userElasticsearchQueryValidations?.isUserCustomized === false) { + if (!userElasticsearchQuery) { return JSON.stringify(elasticsearchQuery); } - if (userElasticsearchQueryValidations?.isValid === true) { + if (userElasticsearchQueryError === undefined) { return userElasticsearchQuery; } return JSON.stringify(elasticsearchQuery); @@ -75,12 +80,12 @@ export const elasticsearchQueryString = ( export const elasticsearchQueryObject = ( elasticsearchQuery: PlaygroundForm[PlaygroundFormFields.elasticsearchQuery], userElasticsearchQuery: PlaygroundForm[PlaygroundFormFields.userElasticsearchQuery], - userElasticsearchQueryValidations: PlaygroundForm[PlaygroundFormFields.userElasticsearchQueryValidations] + userElasticsearchQueryError: FieldError | undefined ): { retriever: any } => { - if (!userElasticsearchQuery || userElasticsearchQueryValidations?.isUserCustomized === false) { + if (!userElasticsearchQuery) { return elasticsearchQuery; } - if (userElasticsearchQueryValidations?.isValid === true) { + if (userElasticsearchQueryError === undefined) { return JSON.parse(userElasticsearchQuery); } return elasticsearchQuery; diff --git a/x-pack/solutions/search/plugins/search_playground/server/playground_saved_object/schema/v1/v1.ts b/x-pack/solutions/search/plugins/search_playground/server/playground_saved_object/schema/v1/v1.ts index fd3abb3eee7b..326fbb366277 100644 --- a/x-pack/solutions/search/plugins/search_playground/server/playground_saved_object/schema/v1/v1.ts +++ b/x-pack/solutions/search/plugins/search_playground/server/playground_saved_object/schema/v1/v1.ts @@ -8,7 +8,7 @@ import { schema } from '@kbn/config-schema'; export const playgroundAttributesSchema = schema.object({ - name: schema.string(), + name: schema.string({ minLength: 1, maxLength: 50 }), // Common fields indices: schema.arrayOf(schema.string(), { minSize: 1 }), queryFields: schema.recordOf(schema.string(), schema.arrayOf(schema.string(), { minSize: 1 })), diff --git a/x-pack/solutions/search/plugins/search_playground/tsconfig.json b/x-pack/solutions/search/plugins/search_playground/tsconfig.json index b1cda6726494..a7ad7c531619 100644 --- a/x-pack/solutions/search/plugins/search_playground/tsconfig.json +++ b/x-pack/solutions/search/plugins/search_playground/tsconfig.json @@ -29,7 +29,6 @@ "@kbn/triggers-actions-ui-plugin", "@kbn/langchain", "@kbn/logging", - "@kbn/react-kibana-context-render", "@kbn/doc-links", "@kbn/core-logging-server-mocks", "@kbn/analytics", diff --git a/x-pack/solutions/search/plugins/serverless_search/public/navigation_tree.ts b/x-pack/solutions/search/plugins/serverless_search/public/navigation_tree.ts index c1a396bc02ce..54c03231f41a 100644 --- a/x-pack/solutions/search/plugins/serverless_search/public/navigation_tree.ts +++ b/x-pack/solutions/search/plugins/serverless_search/public/navigation_tree.ts @@ -101,6 +101,7 @@ export const navigationTree = ({ isAppRegistered }: ApplicationStart): Navigatio defaultMessage: 'Playground', }), link: 'searchPlayground' as AppDeepLinkId, + breadcrumbStatus: 'hidden' as 'hidden', }), ], }, diff --git a/x-pack/solutions/search/test/functional_search/apps/search_playground/saved_playgrounds.ts b/x-pack/solutions/search/test/functional_search/apps/search_playground/saved_playgrounds.ts new file mode 100644 index 000000000000..253c45359f01 --- /dev/null +++ b/x-pack/solutions/search/test/functional_search/apps/search_playground/saved_playgrounds.ts @@ -0,0 +1,123 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +import { createPlayground, deletePlayground } from './utils/create_playground'; + +const archivedBooksIndex = 'x-pack/test/functional_search/fixtures/search-books'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const pageObjects = getPageObjects(['common', 'searchPlayground', 'solutionNavigation']); + const log = getService('log'); + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const createIndices = async () => { + await esArchiver.load(archivedBooksIndex); + }; + const deleteIndices = async () => { + await esArchiver.unload(archivedBooksIndex); + }; + + describe('Search Playground - Saved Playgrounds', function () { + let testPlaygroundId: string; + before(async () => { + await createIndices(); + // Note: replace with creating playground via UI once thats supported + testPlaygroundId = await createPlayground( + { + name: 'FTR Search Playground', + indices: ['search-books'], + queryFields: { 'search-books': ['name', 'author'] }, + elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["name","author"]}}}}}`, + }, + { log, supertest } + ); + }); + after(async () => { + if (testPlaygroundId) { + await deletePlayground(testPlaygroundId, { log, supertest }); + } + await deleteIndices(); + }); + describe('View a Saved Playground', function () { + it('should open saved playground', async () => { + expect(testPlaygroundId).not.to.be(undefined); + + await pageObjects.common.navigateToUrl('searchPlayground', `p/${testPlaygroundId}`, { + shouldUseHashForSubUrl: false, + }); + await pageObjects.searchPlayground.SavedPlaygroundPage.expectPlaygroundNameHeader( + 'FTR Search Playground' + ); + const { solutionNavigation } = pageObjects; + await solutionNavigation.breadcrumbs.expectBreadcrumbExists({ text: 'Build' }); + await solutionNavigation.breadcrumbs.expectBreadcrumbExists({ text: 'Playground' }); + await solutionNavigation.breadcrumbs.expectBreadcrumbExists({ + text: 'FTR Search Playground', + }); + }); + it('should be able to search index', async () => { + // Should default to search mode for this playground + await pageObjects.searchPlayground.expectPageModeToBeSelected('search'); + await pageObjects.searchPlayground.PlaygroundSearchPage.hasModeSelectors(); + // Preview mode enum shares data test subj with chat page mode + await pageObjects.searchPlayground.PlaygroundSearchPage.expectModeIsSelected('chatMode'); + + await pageObjects.searchPlayground.PlaygroundSearchPage.expectSearchBarToExist(); + await pageObjects.searchPlayground.PlaygroundSearchPage.executeSearchQuery('Neal'); + await pageObjects.searchPlayground.PlaygroundSearchPage.expectSearchResultsToExist(); + await pageObjects.searchPlayground.PlaygroundSearchPage.executeSearchQuery('gibberish'); + await pageObjects.searchPlayground.PlaygroundSearchPage.expectSearchResultsNotToExist(); + await pageObjects.searchPlayground.PlaygroundSearchPage.clearSearchInput(); + }); + it('should have query mode', async () => { + await pageObjects.searchPlayground.PlaygroundSearchPage.selectPageMode( + 'queryMode', + testPlaygroundId + ); + await pageObjects.searchPlayground.PlaygroundSearchPage.expectQueryModeComponentsToExist(); + await pageObjects.searchPlayground.PlaygroundSearchPage.expectQueryModeResultsEmptyState(); + }); + it('should support changing fields to search', async () => { + await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldToBeSelected('author'); + await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldToBeSelected('name'); + const queryEditorTextBefore = + await pageObjects.searchPlayground.PlaygroundSearchPage.getQueryEditorText(); + expect(queryEditorTextBefore).to.contain(`"author"`); + expect(queryEditorTextBefore).to.contain('"name"'); + + await pageObjects.searchPlayground.PlaygroundSearchPage.clickFieldSwitch('name', true); + await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldNotToBeSelected('name'); + + let queryEditorText = + await pageObjects.searchPlayground.PlaygroundSearchPage.getQueryEditorText(); + expect(queryEditorText).to.contain('"author"'); + expect(queryEditorText).not.to.contain('"name"'); + + await pageObjects.searchPlayground.PlaygroundSearchPage.clickFieldSwitch('name', false); + await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldToBeSelected('name'); + await pageObjects.searchPlayground.PlaygroundSearchPage.clickFieldSwitch('author', true); + await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldNotToBeSelected( + 'author' + ); + + queryEditorText = + await pageObjects.searchPlayground.PlaygroundSearchPage.getQueryEditorText(); + expect(queryEditorText).not.to.contain('"author"'); + + await pageObjects.searchPlayground.PlaygroundSearchPage.clickFieldSwitch('author', false); + }); + it('should support running query in query mode', async () => { + await pageObjects.searchPlayground.PlaygroundSearchPage.runQueryInQueryMode('atwood'); + await pageObjects.searchPlayground.PlaygroundSearchPage.expectQueryModeResultsCodeEditor(); + }); + }); + }); +} diff --git a/x-pack/solutions/search/test/functional_search/apps/search_playground/utils/create_playground.ts b/x-pack/solutions/search/test/functional_search/apps/search_playground/utils/create_playground.ts new file mode 100644 index 000000000000..2d758fc1c8e7 --- /dev/null +++ b/x-pack/solutions/search/test/functional_search/apps/search_playground/utils/create_playground.ts @@ -0,0 +1,71 @@ +/* + * 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 expect from 'expect'; +import type SuperTest from 'supertest'; +import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; +import { + type PlaygroundSavedObject, + APIRoutes, + ROUTE_VERSIONS, +} from '@kbn/search-playground/common'; +import { ToolingLog } from '@kbn/tooling-log'; + +function prefixApiRouteWithSpace(route: string, space?: string) { + if (!space) return route; + return `/s/${space}${route}`; +} + +export async function createPlayground( + playground: PlaygroundSavedObject, + { + log, + supertest, + space, + }: { + log: ToolingLog; + supertest: SuperTest.Agent; + space?: string; + } +): Promise { + const { body } = await supertest + .put(prefixApiRouteWithSpace(APIRoutes.PUT_PLAYGROUND_CREATE, space)) + .set('kbn-xsrf', 'xxx') + .set(ELASTIC_HTTP_VERSION_HEADER, ROUTE_VERSIONS.v1) + .send(playground) + .expect(200); + + expect(body).toBeDefined(); + expect(body._meta).toBeDefined(); + expect(body._meta.id).toBeDefined(); + + log.info( + `Created saved playground [${playground.name}] - ${space ? space + '/' : ''}${body._meta.id}` + ); + log.debug(`Saved playground: ${JSON.stringify(playground)}`); + return body._meta.id; +} + +export async function deletePlayground( + id: string, + { + log, + supertest, + space, + }: { + log: ToolingLog; + supertest: SuperTest.Agent; + space?: string; + } +): Promise { + await supertest + .delete(prefixApiRouteWithSpace(APIRoutes.DELETE_PLAYGROUND.replace('{id}', id), space)) + .set('kbn-xsrf', 'xxx') + .set(ELASTIC_HTTP_VERSION_HEADER, ROUTE_VERSIONS.v1) + .expect(200); + log.info(`Deleted saved playground [${space ? space + '/' : ''}${id}]`); +} diff --git a/x-pack/solutions/search/test/functional_search/apps/shared/solution_tour.ts b/x-pack/solutions/search/test/functional_search/apps/shared/solution_tour.ts new file mode 100644 index 000000000000..70df5ec57dcd --- /dev/null +++ b/x-pack/solutions/search/test/functional_search/apps/shared/solution_tour.ts @@ -0,0 +1,29 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const PageObjects = getPageObjects(['common']); + const log = getService('log'); + const testSubjects = getService('testSubjects'); + + describe('Close Solution Tour', function () { + // This is a test used to close the global solution tour when we + // use elasticsearch as the default solution. Solution tour is saved in a + // global setting index so we need to utilize the space tour close button + // or manually update the index. + it('should close the solution tour if its visible', async () => { + await PageObjects.common.navigateToApp('spaceSelector'); + if (await testSubjects.exists('spaceSolutionTour')) { + log.info('Found the solution tour open, closing it'); + await testSubjects.click('closeTourBtn'); // close the tour + await PageObjects.common.sleep(1000); // wait to save the setting + } + }); + }); +} diff --git a/x-pack/solutions/search/test/functional_search/config/config.feature_flags.ts b/x-pack/solutions/search/test/functional_search/config/config.feature_flags.ts new file mode 100644 index 000000000000..707e9c4a9fee --- /dev/null +++ b/x-pack/solutions/search/test/functional_search/config/config.feature_flags.ts @@ -0,0 +1,38 @@ +/* + * 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 { FtrConfigProviderContext } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const xpackFunctionalConfig = await readConfigFile( + require.resolve('@kbn/test-suites-xpack/functional/config.base') + ); + + return { + ...xpackFunctionalConfig.getAll(), + junit: { + reportName: 'Search Solution UI Functional Tests w/ Feature Flagged Features', + }, + esTestCluster: { + ...xpackFunctionalConfig.get('esTestCluster'), + serverArgs: [ + ...xpackFunctionalConfig.get('esTestCluster.serverArgs'), + 'xpack.security.enabled=true', + ], + }, + kbnTestServer: { + ...xpackFunctionalConfig.get('kbnTestServer'), + serverArgs: [ + ...xpackFunctionalConfig.get('kbnTestServer.serverArgs'), + '--xpack.spaces.defaultSolution=es', // Default to Search Solution + `--uiSettings.overrides.searchPlayground:searchModeEnabled=true`, + ], + }, + // load tests in the index file + testFiles: [require.resolve('../index.feature_flags.ts')], + }; +} diff --git a/x-pack/solutions/search/test/functional_search/ftr_provider_context.d.ts b/x-pack/solutions/search/test/functional_search/ftr_provider_context.d.ts new file mode 100644 index 000000000000..3ab5c00f1c35 --- /dev/null +++ b/x-pack/solutions/search/test/functional_search/ftr_provider_context.d.ts @@ -0,0 +1,13 @@ +/* + * 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 { GenericFtrProviderContext } from '@kbn/test'; + +import { services } from '@kbn/test-suites-xpack/functional/services'; +import { pageObjects } from '@kbn/test-suites-xpack/functional/page_objects'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/solutions/search/test/functional_search/index.feature_flags.ts b/x-pack/solutions/search/test/functional_search/index.feature_flags.ts new file mode 100644 index 000000000000..7945e4037852 --- /dev/null +++ b/x-pack/solutions/search/test/functional_search/index.feature_flags.ts @@ -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. + */ +/* eslint-disable import/no-default-export */ + +import { FtrProviderContext } from './ftr_provider_context'; + +export default ({ loadTestFile }: FtrProviderContext): void => { + describe('Search solution FF tests', function () { + // Shared test file to close the global solution tour + loadTestFile(require.resolve('./apps/shared/solution_tour')); + // add tests that require feature flags, defined in config.feature_flags.ts + loadTestFile(require.resolve('./apps/search_playground/saved_playgrounds')); + }); +}; diff --git a/x-pack/solutions/search/test/tsconfig.json b/x-pack/solutions/search/test/tsconfig.json index 7da76ddbc301..e1dd392c45e9 100644 --- a/x-pack/solutions/search/test/tsconfig.json +++ b/x-pack/solutions/search/test/tsconfig.json @@ -27,5 +27,8 @@ "@kbn/enterprise-search-plugin", "@kbn/test-suites-src", "@kbn/guided-onboarding", + "@kbn/search-playground", + "@kbn/tooling-log", + "@kbn/test-suites-xpack", ] } diff --git a/x-pack/test/functional/page_objects/search_playground_page.ts b/x-pack/test/functional/page_objects/search_playground_page.ts index 0cf09e0a4667..088add49ef09 100644 --- a/x-pack/test/functional/page_objects/search_playground_page.ts +++ b/x-pack/test/functional/page_objects/search_playground_page.ts @@ -220,12 +220,26 @@ export function SearchPlaygroundPageProvider({ getService }: FtrProviderContext) }, async expectChatWindowLoaded() { expect(await testSubjects.getAttribute('viewModeSelector', 'disabled')).to.be(null); - expect(await testSubjects.isEnabled('dataSourceActionButton')).to.be(true); - expect(await testSubjects.isEnabled('viewCodeActionButton')).to.be(true); - - expect(await testSubjects.isEnabled('regenerateActionButton')).to.be(false); - expect(await testSubjects.isEnabled('clearChatActionButton')).to.be(false); - expect(await testSubjects.isEnabled('sendQuestionButton')).to.be(false); + expect(await testSubjects.isEnabled('dataSourceActionButton')).to.equal( + true, + 'dataSourceActionButton isEnabled should be true' + ); + expect(await testSubjects.isEnabled('viewCodeActionButton')).to.equal( + true, + 'viewCodeActionButton isEnabled should be true' + ); + expect(await testSubjects.isEnabled('regenerateActionButton')).to.equal( + false, + 'regenerateActionButton isEnabled should be false' + ); + expect(await testSubjects.isEnabled('clearChatActionButton')).to.equal( + false, + 'clearChatActionButton isEnabled should be false' + ); + expect(await testSubjects.isEnabled('sendQuestionButton')).to.equal( + false, + 'sendQuestionButton isEnabled should be false' + ); await testSubjects.existOrFail('questionInput'); const model = await testSubjects.find('summarizationModelSelect'); @@ -429,17 +443,29 @@ export function SearchPlaygroundPageProvider({ getService }: FtrProviderContext) const modeSelectedValue = await testSubjects.getAttribute(mode, 'aria-pressed'); expect(modeSelectedValue).to.be('true'); }, - async selectPageMode(mode: 'chatMode' | 'queryMode') { + async selectPageMode(mode: 'chatMode' | 'queryMode', playgroundId?: string) { await testSubjects.existOrFail(mode); await testSubjects.click(mode); switch (mode) { case 'queryMode': - expect(await browser.getCurrentUrl()).contain('/app/search_playground/search/query'); + expect(await browser.getCurrentUrl()).contain( + playgroundId + ? `/app/search_playground/p/${playgroundId}/search/query` + : '/app/search_playground/search/query' + ); break; case 'chatMode': const url = await browser.getCurrentUrl(); - expect(url).contain('/app/search_playground/search'); - expect(url).not.contain('/app/search_playground/search/query'); + expect(url).contain( + playgroundId + ? `/app/search_playground/p/${playgroundId}/search` + : '/app/search_playground/search' + ); + expect(url).not.contain( + playgroundId + ? `/app/search_playground/p/${playgroundId}/search/query` + : '/app/search_playground/search/query' + ); break; } }, @@ -540,5 +566,12 @@ export function SearchPlaygroundPageProvider({ getService }: FtrProviderContext) expect(queryResponse).to.contain(text); }, }, + SavedPlaygroundPage: { + async expectPlaygroundNameHeader(name: string) { + await testSubjects.existOrFail('playgroundName'); + const nameTitle = await testSubjects.find('playgroundName'); + expect(await nameTitle.getVisibleText()).to.be(name); + }, + }, }; } diff --git a/x-pack/test_serverless/functional/test_suites/search/index.feature_flags.ts b/x-pack/test_serverless/functional/test_suites/search/index.feature_flags.ts index acd369e19a18..40fd0debb74d 100644 --- a/x-pack/test_serverless/functional/test_suites/search/index.feature_flags.ts +++ b/x-pack/test_serverless/functional/test_suites/search/index.feature_flags.ts @@ -13,5 +13,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./search_synonyms/search_synonyms_overview')); loadTestFile(require.resolve('./search_synonyms/search_synonym_detail')); loadTestFile(require.resolve('./search_playground/search_relevance')); + loadTestFile(require.resolve('./search_playground/saved_playgrounds')); }); } diff --git a/x-pack/test_serverless/functional/test_suites/search/search_playground/saved_playgrounds.ts b/x-pack/test_serverless/functional/test_suites/search/search_playground/saved_playgrounds.ts new file mode 100644 index 000000000000..e79f1372e695 --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/search/search_playground/saved_playgrounds.ts @@ -0,0 +1,136 @@ +/* + * 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 expect from '@kbn/expect'; +import type { SupertestWithRoleScopeType } from '@kbn/test-suites-xpack-platform/api_integration_deployment_agnostic/services'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +import { createPlayground, deletePlayground } from './utils/create_playground'; + +const archivedBooksIndex = 'x-pack/test/functional_search/fixtures/search-books'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const pageObjects = getPageObjects([ + 'common', + 'svlCommonPage', + 'searchPlayground', + 'solutionNavigation', + ]); + const log = getService('log'); + const roleScopedSupertest = getService('roleScopedSupertest'); + const esArchiver = getService('esArchiver'); + + const createIndices = async () => { + await esArchiver.load(archivedBooksIndex); + }; + const deleteIndices = async () => { + await esArchiver.unload(archivedBooksIndex); + }; + + describe('Search Playground - Saved Playgrounds', function () { + let supertest: SupertestWithRoleScopeType; + let testPlaygroundId: string; + before(async () => { + supertest = await roleScopedSupertest.getSupertestWithRoleScope('developer', { + useCookieHeader: true, + withInternalHeaders: true, + }); + await createIndices(); + // Note: replace with creating playground via UI once thats supported + testPlaygroundId = await createPlayground( + { + name: 'FTR Search Playground', + indices: ['search-books'], + queryFields: { 'search-books': ['name', 'author'] }, + elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["name","author"]}}}}}`, + }, + { log, supertest } + ); + + await pageObjects.svlCommonPage.loginWithRole('developer'); + }); + after(async () => { + if (testPlaygroundId) { + await deletePlayground(testPlaygroundId, { log, supertest }); + } + await deleteIndices(); + }); + describe('View a Saved Playground', function () { + it('should open saved playground', async () => { + expect(testPlaygroundId).not.to.be(undefined); + + await pageObjects.common.navigateToUrl('searchPlayground', `p/${testPlaygroundId}`, { + shouldUseHashForSubUrl: false, + }); + await pageObjects.searchPlayground.SavedPlaygroundPage.expectPlaygroundNameHeader( + 'FTR Search Playground' + ); + const { solutionNavigation } = pageObjects; + await solutionNavigation.breadcrumbs.expectBreadcrumbExists({ text: 'Build' }); + await solutionNavigation.breadcrumbs.expectBreadcrumbExists({ text: 'Playground' }); + await solutionNavigation.breadcrumbs.expectBreadcrumbExists({ + text: 'FTR Search Playground', + }); + }); + it('should be able to search index', async () => { + // Should default to search mode for this playground + await pageObjects.searchPlayground.expectPageModeToBeSelected('search'); + await pageObjects.searchPlayground.PlaygroundSearchPage.hasModeSelectors(); + // Preview mode enum shares data test subj with chat page mode + await pageObjects.searchPlayground.PlaygroundSearchPage.expectModeIsSelected('chatMode'); + + await pageObjects.searchPlayground.PlaygroundSearchPage.expectSearchBarToExist(); + await pageObjects.searchPlayground.PlaygroundSearchPage.executeSearchQuery('Neal'); + await pageObjects.searchPlayground.PlaygroundSearchPage.expectSearchResultsToExist(); + await pageObjects.searchPlayground.PlaygroundSearchPage.executeSearchQuery('gibberish'); + await pageObjects.searchPlayground.PlaygroundSearchPage.expectSearchResultsNotToExist(); + await pageObjects.searchPlayground.PlaygroundSearchPage.clearSearchInput(); + }); + it('should have query mode', async () => { + await pageObjects.searchPlayground.PlaygroundSearchPage.selectPageMode( + 'queryMode', + testPlaygroundId + ); + await pageObjects.searchPlayground.PlaygroundSearchPage.expectQueryModeComponentsToExist(); + await pageObjects.searchPlayground.PlaygroundSearchPage.expectQueryModeResultsEmptyState(); + }); + it('should support changing fields to search', async () => { + await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldToBeSelected('author'); + await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldToBeSelected('name'); + const queryEditorTextBefore = + await pageObjects.searchPlayground.PlaygroundSearchPage.getQueryEditorText(); + expect(queryEditorTextBefore).to.contain(`"author"`); + expect(queryEditorTextBefore).to.contain('"name"'); + + await pageObjects.searchPlayground.PlaygroundSearchPage.clickFieldSwitch('name', true); + await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldNotToBeSelected('name'); + + let queryEditorText = + await pageObjects.searchPlayground.PlaygroundSearchPage.getQueryEditorText(); + expect(queryEditorText).to.contain('"author"'); + expect(queryEditorText).not.to.contain('"name"'); + + await pageObjects.searchPlayground.PlaygroundSearchPage.clickFieldSwitch('name', false); + await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldToBeSelected('name'); + await pageObjects.searchPlayground.PlaygroundSearchPage.clickFieldSwitch('author', true); + await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldNotToBeSelected( + 'author' + ); + + queryEditorText = + await pageObjects.searchPlayground.PlaygroundSearchPage.getQueryEditorText(); + expect(queryEditorText).not.to.contain('"author"'); + + await pageObjects.searchPlayground.PlaygroundSearchPage.clickFieldSwitch('author', false); + }); + it('should support running query in query mode', async () => { + await pageObjects.searchPlayground.PlaygroundSearchPage.runQueryInQueryMode('atwood'); + await pageObjects.searchPlayground.PlaygroundSearchPage.expectQueryModeResultsCodeEditor(); + }); + }); + }); +} diff --git a/x-pack/test_serverless/functional/test_suites/search/search_playground/utils/create_playground.ts b/x-pack/test_serverless/functional/test_suites/search/search_playground/utils/create_playground.ts new file mode 100644 index 000000000000..767a935bad75 --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/search/search_playground/utils/create_playground.ts @@ -0,0 +1,67 @@ +/* + * 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 expect from 'expect'; +import type SuperTest from 'supertest'; +import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; +import { ToolingLog } from '@kbn/tooling-log'; +import type { SupertestWithRoleScopeType } from '@kbn/test-suites-xpack-platform/api_integration_deployment_agnostic/services'; + +function prefixApiRouteWithSpace(route: string, space?: string) { + if (!space) return route; + return `/s/${space}${route}`; +} + +export async function createPlayground( + playground: any, + { + log, + supertest, + space, + }: { + log: ToolingLog; + supertest: SuperTest.Agent | SupertestWithRoleScopeType; + space?: string; + } +): Promise { + const { body } = await supertest + .put(prefixApiRouteWithSpace(`/internal/search_playground/playgrounds`, space)) + .set('kbn-xsrf', 'xxx') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .send(playground) + .expect(200); + + expect(body).toBeDefined(); + expect(body._meta).toBeDefined(); + expect(body._meta.id).toBeDefined(); + + log.info( + `Created saved playground [${playground.name}] - ${space ? space + '/' : ''}${body._meta.id}` + ); + log.debug(`Saved playground: ${JSON.stringify(playground)}`); + return body._meta.id; +} + +export async function deletePlayground( + id: string, + { + log, + supertest, + space, + }: { + log: ToolingLog; + supertest: SuperTest.Agent | SupertestWithRoleScopeType; + space?: string; + } +): Promise { + await supertest + .delete(prefixApiRouteWithSpace(`/internal/search_playground/playgrounds/${id}`, space)) + .set('kbn-xsrf', 'xxx') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .expect(200); + log.info(`Deleted saved playground [${space ? space + '/' : ''}${id}]`); +}