mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[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  Chat - Query  Search - Query  ### 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:
parent
fa214dcf1c
commit
575e80bccc
66 changed files with 2385 additions and 737 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -142,6 +142,7 @@ export const getNavigationTreeDefinition = ({
|
|||
}),
|
||||
},
|
||||
{
|
||||
breadcrumbStatus: 'hidden',
|
||||
link: 'searchPlayground',
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.';
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
})}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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} />}
|
||||
/>
|
||||
|
|
|
@ -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 ?? ''
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
})}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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([]);
|
||||
});
|
||||
});
|
|
@ -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([]);
|
||||
});
|
||||
});
|
|
@ -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 || [];
|
||||
};
|
||||
|
|
|
@ -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
|
|||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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]);
|
||||
};
|
|
@ -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]);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>;
|
||||
};
|
|
@ -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', () => {
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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`;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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)),
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 })),
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -101,6 +101,7 @@ export const navigationTree = ({ isAppRegistered }: ApplicationStart): Navigatio
|
|||
defaultMessage: 'Playground',
|
||||
}),
|
||||
link: 'searchPlayground' as AppDeepLinkId,
|
||||
breadcrumbStatus: 'hidden' as 'hidden',
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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}]`);
|
||||
}
|
|
@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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')],
|
||||
};
|
||||
}
|
13
x-pack/solutions/search/test/functional_search/ftr_provider_context.d.ts
vendored
Normal file
13
x-pack/solutions/search/test/functional_search/ftr_provider_context.d.ts
vendored
Normal 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>;
|
|
@ -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'));
|
||||
});
|
||||
};
|
|
@ -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",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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}]`);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue