[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:<PASSWORD>' \
     -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/<ID_RETURNED_FROM_CURL>`

## 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>
This commit is contained in:
Rodney Norris 2025-06-18 14:58:57 -05:00 committed by GitHub
parent fa214dcf1c
commit 575e80bccc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 2385 additions and 737 deletions

View file

@ -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

View file

@ -142,6 +142,7 @@ export const getNavigationTreeDefinition = ({
}),
},
{
breadcrumbStatus: 'hidden',
link: 'searchPlayground',
},
{

View file

@ -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;

View file

@ -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.';

View file

@ -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(
<KibanaRenderContextProvider {...core}>
core.rendering.addContext(
<KibanaContextProvider services={{ ...core, ...services }}>
<I18nProvider>
<Router history={services.history}>
@ -34,7 +33,7 @@ export const renderApp = async (
</Router>
</I18nProvider>
</KibanaContextProvider>
</KibanaRenderContextProvider>,
),
element
);

View file

@ -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<PlaygroundForm>
): 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 }) => (
<QuestionInput
value={field.value}

View file

@ -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 from 'react';
import type { FieldError } from 'react-hook-form';
import { EuiCallOut } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
function errorMessageFromType(type: FieldError['type']): string {
switch (type) {
case 'required':
return i18n.translate('xpack.searchPlayground.formErrors.required', {
defaultMessage: 'Required',
});
default:
return i18n.translate('xpack.searchPlayground.formErrors.fallbackErrorMessage', {
defaultMessage: 'Invalid input',
});
}
}
export interface FieldErrorCalloutProps {
error: FieldError;
}
export const FieldErrorCallout = ({ error }: FieldErrorCalloutProps) => {
return (
<EuiCallOut
color="danger"
iconType="error"
title={error.message ?? errorMessageFromType(error.type)}
size="s"
/>
);
};

View file

@ -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(
<IntlProvider locale="en">
<MockPlaygroundForm handleSubmit={() => {}}>
<Header onModeChange={() => {}} onSelectPageModeChange={() => {}} />
<Header
pageMode={PlaygroundPageMode.chat}
viewMode={PlaygroundViewMode.preview}
onModeChange={() => {}}
onSelectPageModeChange={() => {}}
/>
</MockPlaygroundForm>
</IntlProvider>
);
@ -85,14 +81,15 @@ describe('Header', () => {
});
it('renders correctly with preview mode', () => {
mockUsePlaygroundParameters.mockReturnValue({
pageMode: PlaygroundPageMode.search,
viewMode: PlaygroundViewMode.preview,
});
render(
<IntlProvider locale="en">
<MockPlaygroundForm handleSubmit={() => {}}>
<Header onModeChange={() => {}} onSelectPageModeChange={() => {}} />
<Header
pageMode={PlaygroundPageMode.search}
viewMode={PlaygroundViewMode.preview}
onModeChange={() => {}}
onSelectPageModeChange={() => {}}
/>
</MockPlaygroundForm>
</IntlProvider>
);

View file

@ -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<HeaderProps> = ({
pageMode,
viewMode,
onModeChange,
showDocs = false,
isActionsDisabled = false,
onSelectPageModeChange,
playgroundName,
hasChanges,
}) => {
const { pageMode, viewMode } = usePlaygroundParameters();
const isSearchModeEnabled = useSearchPlaygroundFeatureFlag();
const { euiTheme } = useEuiTheme();
const options: Array<EuiButtonGroupOptionProps & { id: PlaygroundViewMode }> = [
@ -74,15 +81,25 @@ export const Header: React.FC<HeaderProps> = ({
>
<EuiPageHeaderSection>
<EuiFlexGroup gutterSize="s" alignItems="center">
{playgroundName === undefined ? (
<EuiTitle
css={{ whiteSpace: 'nowrap' }}
data-test-subj="chat-playground-home-page-title"
size="xs"
>
<h2>
<FormattedMessage id="xpack.searchPlayground.pageTitle" defaultMessage="Playground" />
<FormattedMessage
id="xpack.searchPlayground.pageTitle"
defaultMessage="Playground"
/>
</h2>
</EuiTitle>
) : (
<EuiTitle css={{ whiteSpace: 'nowrap' }} data-test-subj="playgroundName" size="xs">
<h2>{playgroundName}</h2>
</EuiTitle>
)}
{isSearchModeEnabled && (
<EuiSelect
data-test-subj="page-mode-select"
@ -94,6 +111,14 @@ export const Header: React.FC<HeaderProps> = ({
onChange={(e) => onSelectPageModeChange(e.target.value as PlaygroundPageMode)}
/>
)}
{isSearchModeEnabled && playgroundName !== undefined && hasChanges ? (
<EuiBadge color="warning">
<FormattedMessage
id="xpack.searchPlayground.header.unsavedChangesBadge"
defaultMessage="Unsaved changes"
/>
</EuiBadge>
) : null}
</EuiFlexGroup>
</EuiPageHeaderSection>
<EuiPageHeaderSection>

View file

@ -52,7 +52,7 @@ export const PlaygroundRouteNotFound = () => {
</p>
}
actions={
<EuiButton onClick={goToPlayground} fill>
<EuiButton data-test-subj="playgroundRouteNotFoundCTA" onClick={goToPlayground} fill>
{i18n.translate('xpack.searchPlayground.notFound.action1', {
defaultMessage: 'Back to Playground',
})}

View file

@ -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<AppProps> = ({ showDocs = false }) => {
useValidatePlaygroundViewModes();
const isSearchModeEnabled = useSearchPlaygroundFeatureFlag();
const location = useLocation();
const { pageMode, viewMode } = usePlaygroundParameters();
@ -66,7 +68,7 @@ export const Playground: React.FC<AppProps> = ({ 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<AppProps> = ({ showDocs = false }) => {
return (
<>
<Header
pageMode={pageMode}
viewMode={viewMode}
showDocs={showDocs}
onModeChange={handleModeChange}
isActionsDisabled={showSetupPage}

View file

@ -23,7 +23,11 @@ export const ChatPrompt = ({ isLoading }: { isLoading: boolean }) => {
<EuiFieldText
data-test-subj="searchPlaygroundChatQuestionFieldText"
prepend="{query}"
{...field}
name={field.name}
onBlur={field.onBlur}
onChange={field.onChange}
value={field.value}
inputRef={field.ref}
fullWidth
placeholder={i18n.translate(
'xpack.searchPlayground.searchMode.queryView.chatQuestion.placeholder',

View file

@ -49,15 +49,10 @@ export const QuerySidePanel = ({
name: PlaygroundFormFields.elasticsearchQuery,
});
const {
field: { onChange: userElasticsearchQueryChange },
field: { value: userElasticsearchQuery, onChange: userElasticsearchQueryChange },
} = useController<PlaygroundForm, PlaygroundFormFields.userElasticsearchQuery>({
name: PlaygroundFormFields.userElasticsearchQuery,
});
const {
field: { value: userElasticsearchQueryValidations },
} = useController<PlaygroundForm, PlaygroundFormFields.userElasticsearchQueryValidations>({
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}
/>
</EuiFlexItem>
))}

View file

@ -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<boolean>(false);
const { control } = useFormContext<PlaygroundForm>();
const { trigger } = useFormContext<PlaygroundForm>();
const {
field: { value: elasticsearchQuery },
} = useController<PlaygroundForm, PlaygroundFormFields.elasticsearchQuery>({
@ -49,14 +49,10 @@ export const ElasticsearchQueryViewer = ({
});
const {
field: { value: userElasticsearchQuery, onChange: onChangeUserQuery },
fieldState: { error: userElasticsearchQueryError },
} = useController<PlaygroundForm, PlaygroundFormFields.userElasticsearchQuery>({
name: PlaygroundFormFields.userElasticsearchQuery,
});
const {
field: { value: userElasticsearchQueryValidations },
} = useController<PlaygroundForm, PlaygroundFormFields.userElasticsearchQueryValidations>({
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 (
<EuiSplitPanel.Outer grow hasBorder css={FullHeight}>
@ -95,7 +93,7 @@ export const ElasticsearchQueryViewer = ({
/>
</h5>
</EuiText>
{userElasticsearchQueryValidations?.isUserCustomized ? (
{userElasticsearchQueryIsCustomized ? (
<EuiBadge color="primary">
<FormattedMessage
id="xpack.searchPlayground.viewQuery.userCustomized.badge"
@ -112,7 +110,7 @@ export const ElasticsearchQueryViewer = ({
)}
</EuiFlexGroup>
</EuiFlexItem>
{userElasticsearchQueryValidations?.isUserCustomized ? (
{userElasticsearchQuery !== null ? (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
@ -151,29 +149,15 @@ export const ElasticsearchQueryViewer = ({
</EuiFlexItem>
</EuiFlexGroup>
</EuiSplitPanel.Inner>
{userElasticsearchQueryValidations?.isUserCustomized &&
(userElasticsearchQueryValidations?.userQueryErrors?.length ?? 0) > 0 ? (
{userElasticsearchQueryIsCustomized && userElasticsearchQueryError !== undefined ? (
<EuiSplitPanel.Inner grow={false}>
{userElasticsearchQueryValidations.userQueryErrors!.map((error, errorIndex) => (
<EuiCallOut
key={`user.query.error.${errorIndex}`}
color="danger"
iconType="error"
title={error}
size="s"
/>
))}
<FieldErrorCallout error={userElasticsearchQueryError} />
</EuiSplitPanel.Inner>
) : null}
<EuiSplitPanel.Inner paddingSize="none" css={PanelFillContainer}>
<Controller
control={control}
name={PlaygroundFormFields.userElasticsearchQuery}
render={({ field }) => (
<CodeEditor
dataTestSubj="ViewElasticsearchQueryResult"
languageId="json"
{...field}
onChange={onEditorChange}
value={userElasticsearchQuery ?? generatedEsQuery}
options={{
@ -184,8 +168,6 @@ export const ElasticsearchQueryViewer = ({
fullWidth
isCopyable
/>
)}
/>
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
);

View file

@ -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<PlaygroundForm, PlaygroundFormFields.searchQuery>({
name: PlaygroundFormFields.searchQuery,
@ -29,8 +28,11 @@ export const SearchQuery = ({ isLoading }: { isLoading: boolean }) => {
<EuiFieldText
data-test-subj="searchPlaygroundSearchModeFieldText"
prepend="{query}"
{...field}
value={searchBarValue}
name={field.name}
onBlur={field.onBlur}
onChange={field.onChange}
value={field.value}
inputRef={field.ref}
icon="search"
fullWidth
placeholder={i18n.translate(

View file

@ -49,12 +49,13 @@ export const SearchQueryMode = ({ pageMode }: { pageMode: PlaygroundPageMode })
name: PlaygroundFormFields.question,
});
const {
field: { value: userElasticsearchQueryValidations },
} = useController<PlaygroundForm, PlaygroundFormFields.userElasticsearchQueryValidations>({
name: PlaygroundFormFields.userElasticsearchQueryValidations,
field: { value: userElasticsearchQuery },
fieldState: { invalid: userElasticsearchQueryInvalid },
} = useController<PlaygroundForm, PlaygroundFormFields.userElasticsearchQuery>({
name: PlaygroundFormFields.userElasticsearchQuery,
});
const executeQueryDisabled = disableExecuteQuery(
userElasticsearchQueryValidations,
userElasticsearchQuery === null || !userElasticsearchQueryInvalid,
pageMode === PlaygroundPageMode.chat ? question : searchQuery
);
const isLoading = fetchStatus !== 'idle';

View file

@ -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<SavedPlaygroundForm>();
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 (
<KibanaPageTemplate.Section>
<EuiFlexGroup justifyContent="center">
<EuiLoadingSpinner />
</EuiFlexGroup>
</KibanaPageTemplate.Section>
);
}
if (playgroundName.length === 0 && playgroundIndices.length === 0) {
return <SavedPlaygroundFetchError />;
}
return (
<>
<Header
playgroundName={playgroundName}
hasChanges={false}
pageMode={pageMode}
viewMode={viewMode}
showDocs={false}
onModeChange={handleModeChange}
isActionsDisabled={false}
onSelectPageModeChange={handlePageModeChange}
/>
<Routes>
{showSetupPage ? (
<>
<Route path={SAVED_PLAYGROUND_CHAT_PATH} component={ChatSetupPage} />
{/* {isSearchModeEnabled && (
// TODO: This should be impossible
)} */}
</>
) : (
<>
<Route exact path={SAVED_PLAYGROUND_CHAT_PATH} component={Chat} />
<Route
exact
path={SAVED_PLAYGROUND_CHAT_QUERY_PATH}
render={() => <SearchQueryMode pageMode={pageMode} />}
/>
<Route exact path={SAVED_PLAYGROUND_SEARCH_PATH} component={SearchMode} />
<Route
exact
path={SAVED_PLAYGROUND_SEARCH_QUERY_PATH}
render={() => <SearchQueryMode pageMode={pageMode} />}
/>
</>
)}
</Routes>
</>
);
};

View file

@ -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<SavedPlaygroundFormFetchError>();
const formData = useMemo(() => getValues(), [getValues]);
const goToPlayground = useCallback(() => {
application.navigateToApp(PLUGIN_ID);
}, [application]);
return (
<KibanaPageTemplate.EmptyPrompt
iconType="logoElasticsearch"
title={
<h1>
{i18n.translate('xpack.searchPlayground.savedPlayground.fetchError.title', {
defaultMessage: 'Error loading playground',
})}
</h1>
}
body={<p>{formData.error.message}</p>}
actions={
<EuiButton data-test-subj="savedPlaygroundFetchErrorCTA" onClick={goToPlayground} fill>
{i18n.translate('xpack.searchPlayground.savedPlayground.fetchError.action1', {
defaultMessage: 'Back to Playgrounds',
})}
</EuiButton>
}
/>
);
};

View file

@ -23,7 +23,6 @@ export const SummarizationPanel: React.FC = () => {
<EuiPanel data-test-subj="summarizationPanel">
<Controller
name={PlaygroundFormFields.summarizationModel}
rules={{ required: true }}
control={control}
render={({ field }) => (
<SummarizationModel
@ -37,7 +36,6 @@ export const SummarizationPanel: React.FC = () => {
<Controller
name={PlaygroundFormFields.prompt}
control={control}
rules={{ required: true }}
defaultValue="You are an assistant for question-answering tasks."
render={({ field }) => <InstructionsField value={field.value} onChange={field.onChange} />}
/>

View file

@ -13,18 +13,20 @@ import { PlaygroundForm, PlaygroundFormFields } from '../../../types';
import { elasticsearchQueryObject } from '../../../utils/user_query';
export const DevToolsCode: React.FC = () => {
const { getValues } = useFormContext<PlaygroundForm>();
const {
getValues,
formState: { errors: formErrors },
} = useFormContext<PlaygroundForm>();
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 ?? ''

View file

@ -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();
});

View file

@ -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<PlaygroundForm>,
clientDetails: string
) => (
<EuiCodeBlock language="py" isCopyable overflowHeight="100%">
{`## 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,
})}

View file

@ -26,7 +26,11 @@ describe('LangchainPythonExmaple component', () => {
const clientDetails = ES_CLIENT_DETAILS('http://my-local-cloud-instance');
const { container } = render(
<LangchainPythonExmaple formValues={formValues} clientDetails={clientDetails} />
<LangchainPythonExmaple
formValues={formValues}
formErrors={{}}
clientDetails={clientDetails}
/>
);
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(
<LangchainPythonExmaple formValues={formValues} clientDetails={clientDetails} />
<LangchainPythonExmaple
formValues={formValues}
formErrors={{}}
clientDetails={clientDetails}
/>
);
expect(container.firstChild?.textContent).toMatchSnapshot();

View file

@ -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<PlaygroundForm>;
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 (
<EuiCodeBlock language="py" isCopyable overflowHeight="100%">
{`## Install the required packages

View file

@ -46,7 +46,10 @@ es_client = Elasticsearch(
export const ViewCodeFlyout: React.FC<ViewCodeFlyoutProps> = ({ onClose, selectedPageMode }) => {
const usageTracker = useUsageTracker();
const [selectedLanguage, setSelectedLanguage] = useState('py-es-client');
const { getValues } = useFormContext<PlaygroundForm>();
const {
getValues,
formState: { errors: formErrors },
} = useFormContext<PlaygroundForm>();
const formValues = getValues();
const {
services: { cloud, http },
@ -60,8 +63,14 @@ export const ViewCodeFlyout: React.FC<ViewCodeFlyoutProps> = ({ onClose, selecte
const CLIENT_STEP = ES_CLIENT_DETAILS(elasticsearchUrl);
const steps: Record<string, React.ReactElement> = {
'lc-py': <LangchainPythonExmaple formValues={formValues} clientDetails={CLIENT_STEP} />,
'py-es-client': PY_LANG_CLIENT(formValues, CLIENT_STEP),
'lc-py': (
<LangchainPythonExmaple
formValues={formValues}
formErrors={formErrors}
clientDetails={CLIENT_STEP}
/>
),
'py-es-client': PY_LANG_CLIENT(formValues, formErrors, CLIENT_STEP),
};
const handleLanguageChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedLanguage(e.target.value);

View file

@ -19,13 +19,16 @@ import { elasticsearchQueryString } from '../utils/user_query';
export const useElasticsearchQuery = (pageMode: PlaygroundPageMode) => {
const { http } = useKibana().services;
const { getValues } = useFormContext<PlaygroundForm>();
const {
getValues,
formState: { errors: formErrors },
} = useFormContext<PlaygroundForm>();
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,

View file

@ -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([]);
});
});

View file

@ -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<{}>) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
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([]);
});
});

View file

@ -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,24 +99,25 @@ const mapLlmToModels: Record<
},
};
export const useLLMsModels = (): LLMModel[] => {
const { data: connectors } = useLoadConnectors();
export const LLMS_QUERY_KEY = ['search-playground', 'llms-models'];
const mapConnectorTypeToCount = useMemo(
() =>
connectors?.reduce<Partial<Record<LLMs, number>>>(
(result, connector) => ({
...result,
[connector.type]: (result[connector.type as LLMs] || 0) + 1,
}),
export const LLMsQuery =
(http: HttpSetup, client: QueryClient) => async (): Promise<LLMModel[]> => {
const connectors = await client.fetchQuery<PlaygroundConnector[]>({
queryKey: LOAD_CONNECTORS_QUERY_KEY,
queryFn: LoadConnectorsQuery(http),
retry: false,
});
const mapConnectorTypeToCount = connectors.reduce<Partial<Record<LLMs, number>>>(
(result, connector) => {
result[connector.type] = (result[connector.type] || 0) + 1;
return result;
},
{}
),
[connectors]
);
return useMemo(
() =>
connectors?.reduce<LLMModel[]>((result, connector) => {
const models = connectors.reduce<LLMModel[]>((result, connector) => {
const connectorType = connector.type as LLMs;
const llmParams = mapLlmToModels[connectorType];
@ -123,9 +127,7 @@ export const useLLMsModels = (): LLMModel[] => {
const showConnectorName = Number(mapConnectorTypeToCount?.[connectorType]) > 1;
return [
...result,
...llmParams
llmParams
.getModels(connector.name, false)
.map(({ label, value, promptTokenLimit }) => ({
id: connector?.id + label,
@ -134,14 +136,29 @@ export const useLLMsModels = (): LLMModel[] => {
connectorType: connector.type,
connectorName: connector.name,
showConnectorName,
icon:
typeof llmParams.icon === 'function' ? llmParams.icon(connector) : llmParams.icon,
icon: typeof llmParams.icon === 'function' ? llmParams.icon(connector) : llmParams.icon,
disabled: !connector,
connectorId: connector.id,
promptTokenLimit,
})),
];
}, []) || [],
[connectors, mapConnectorTypeToCount]
);
}))
.forEach((model) => result.push(model));
return result;
}, []);
return models;
};
export const useLLMsModels = (): LLMModel[] => {
const client = useQueryClient();
const {
services: { http },
} = useKibana();
const { data } = useQuery(LLMS_QUERY_KEY, LLMsQuery(http, client), {
keepPreviousData: true,
retry: false,
});
return data || [];
};

View file

@ -8,147 +8,25 @@
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<string, unknown>
>;
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<PlaygroundConnector[], IHttpFetchError> => {
const {
services: { http, notifications },
} = useKibana();
return useQuery(
QUERY_KEY,
async () => {
const queryResult = await loadConnectors({ http });
return queryResult.reduce<Promise<PlaygroundConnector[]>>(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<ResponseErrorBody>) => {
@ -164,6 +42,5 @@ export const useLoadConnectors = (): UseQueryResult<PlaygroundConnector[], IHttp
);
}
},
}
);
});
};

View file

@ -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(
[
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,
},
],
{ forClassicChromeStyle: true }
);
...(playgroundName !== undefined
? [
{
text: playgroundName,
},
]
: []),
]);
return () => {
// Clear breadcrumbs on unmount;
searchNavigation?.breadcrumbs.clearBreadcrumbs();
};
}, [searchNavigation]);
}, [http, searchNavigation, isServerless, playgroundName]);
};

View file

@ -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<SavedPlaygroundRouterParameters>();
return {
playgroundId,
pageMode,
viewMode: viewMode ?? PlaygroundViewMode.preview,
};
};

View file

@ -71,7 +71,10 @@ export const useSearchPreview = ({
const {
services: { http },
} = useKibana();
const { getValues } = useFormContext<PlaygroundForm>();
const {
getValues,
formState: { errors: formErrors },
} = useFormContext<PlaygroundForm>();
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,

View file

@ -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,
};
};

View file

@ -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<UseFormReturn<PlaygroundForm>, '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]);
};

View file

@ -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]);
};

View file

@ -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<KibanaPageTemplateProps>) => {
const {
services: { history, console: consolePlugin, searchNavigation },
} = useKibana();
const embeddableConsole = useMemo(
() => (consolePlugin?.EmbeddableConsole ? <consolePlugin.EmbeddableConsole /> : null),
[consolePlugin]
);
const allProps: KibanaPageTemplateProps = {
offset: 0,
restrictWidth: false,
grow: false,
panelled: false,
...props,
};
return (
<KibanaPageTemplate {...allProps} solutionNav={searchNavigation?.useClassicNavigation(history)}>
{children}
{embeddableConsole}
</KibanaPageTemplate>
);
};

View file

@ -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 ? <consolePlugin.EmbeddableConsole /> : null),
[consolePlugin]
);
return (
<KibanaPageTemplate
offset={0}
restrictWidth={false}
data-test-subj="svlPlaygroundPage"
grow={false}
panelled={false}
solutionNav={searchNavigation?.useClassicNavigation(history)}
>
<SearchPlaygroundPageTemplate data-test-subj="svlPlaygroundPage">
<UnsavedFormProvider>
<Playground showDocs />
</UnsavedFormProvider>
{embeddableConsole}
</KibanaPageTemplate>
</SearchPlaygroundPageTemplate>
);
};

View file

@ -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 && (
<Redirect from={SEARCH_PLAYGROUND_SEARCH_PATH} to={SEARCH_PLAYGROUND_CHAT_PATH} />
)}
{isSearchModeEnabled && (
<Route path={SAVED_PLAYGROUND_PATH} component={SavedPlaygroundPage} />
)}
<Route exact path={SEARCH_PLAYGROUND_NOT_FOUND} component={PlaygroundRouteNotFound} />
<Route path={`/:pageMode/:viewMode?`} component={PlaygroundOverview} />
</Routes>

View file

@ -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<SavedPlaygroundForm> => {
let playgroundResp: PlaygroundResponse;
try {
playgroundResp = await http.get<PlaygroundResponse>(
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<SavedPlaygroundFormProviderProps>) => {
const client = useQueryClient();
const { http } = useKibana().services;
const form = useForm<SavedPlaygroundForm>({
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<SavedPlaygroundForm> even though SavedPlaygroundForm extends PlaygroundForm
} as unknown as Pick<UseFormReturn<PlaygroundForm>, 'watch' | 'getValues' | 'setValue'>);
useEffect(() => {
if (form.formState.isLoading) return;
// Trigger validation of existing values after initial loading.
form.trigger();
}, [form, form.formState.isLoading]);
return <ReactHookFormProvider {...form}>{children}</ReactHookFormProvider>;
};

View file

@ -54,10 +54,6 @@ const DEFAULT_FORM_STATE: Partial<PlaygroundForm> = {
indices: [],
summarization_model: undefined,
user_elasticsearch_query: null,
user_elasticsearch_query_validations: {
isUserCustomized: false,
isValid: false,
},
};
describe('UnsavedFormProvider', () => {

View file

@ -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<PlaygroundForm>;
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<PlaygroundForm>, 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<React.PropsWithChildren<UnsavedFormPr
indices: [],
search_query: '',
},
resolver: playgroundFormResolver,
reValidateMode: 'onChange',
});
const { isValidated: isValidatedIndices, validIndices } = useIndicesValidation(
defaultIndex || sessionState.indices || []
@ -85,16 +84,11 @@ export const UnsavedFormProvider: React.FC<React.PropsWithChildren<UnsavedFormPr
setValue: form.setValue,
getValues: form.getValues,
});
useUserQueryValidations({
watch: form.watch,
setValue: form.setValue,
getValues: form.getValues,
});
const setLocalSessionDebounce = useDebounceFn(setLocalSession, LOCAL_STORAGE_DEBOUNCE_OPTIONS);
useEffect(() => {
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<React.PropsWithChildren<UnsavedFormPr
useEffect(() => {
if (isValidatedIndices) {
form.setValue(PlaygroundFormFields.indices, validIndices);
form.trigger();
}
}, [form, isValidatedIndices, validIndices]);

View file

@ -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`;

View file

@ -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 (
<SearchPlaygroundPageTemplate data-test-subj="savedPlaygroundPage">
<SavedPlaygroundFormProvider playgroundId={playgroundId}>
<SavedPlayground />
</SavedPlaygroundFormProvider>
</SearchPlaygroundPageTemplate>
);
};

View file

@ -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;
}

View file

@ -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([]);
});
});
});

View file

@ -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<string, unknown>
>;
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<PlaygroundConnector[]> {
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;
}

View file

@ -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<PlaygroundForm> = {
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<SavedPlaygroundForm> = {
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',
},
},
});
});
});
});

View file

@ -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<PlaygroundForm>): boolean => Object.keys(errors).length > 0;
export const playgroundFormResolver: Resolver<PlaygroundForm> = async (values) => {
const errors: FieldErrors<PlaygroundForm> = {};
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<SavedPlaygroundForm> = async (
values,
context,
options
) => {
const baseResult = await playgroundFormResolver(
values,
context,
options as unknown as ResolverOptions<PlaygroundForm>
);
return baseResult;
};

View file

@ -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)),
};
}

View file

@ -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);
});
});
});

View file

@ -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 };
}
let userQueryErrors: string[] | undefined;
if (!userQuery.includes('{query}')) {
userQueryErrors = [
i18n.translate('xpack.searchPlayground.userQuery.errors.queryPlaceholder', {
defaultMessage: 'User query must contain "{query}"',
const USER_QUERY_PLACEHOLDER_MISSING_ERROR = i18n.translate(
'xpack.searchPlayground.userQuery.errors.queryPlaceholder',
{
defaultMessage: 'Elasticsearch 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;

View file

@ -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 })),

View file

@ -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",

View file

@ -101,6 +101,7 @@ export const navigationTree = ({ isAppRegistered }: ApplicationStart): Navigatio
defaultMessage: 'Playground',
}),
link: 'searchPlayground' as AppDeepLinkId,
breadcrumbStatus: 'hidden' as 'hidden',
}),
],
},

View file

@ -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();
});
});
});
}

View file

@ -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<string> {
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<void> {
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}]`);
}

View file

@ -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
}
});
});
}

View file

@ -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')],
};
}

View file

@ -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<typeof services, typeof pageObjects>;

View file

@ -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'));
});
};

View file

@ -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",
]
}

View file

@ -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);
},
},
};
}

View file

@ -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'));
});
}

View file

@ -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();
});
});
});
}

View file

@ -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<string> {
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<void> {
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}]`);
}