[8.15] [Search][Playground] Update UI (#187608) (#187974)

# Backport

This will backport the following commits from `main` to `8.15`:
- [[Search][Playground] Update UI
(#187608)](https://github.com/elastic/kibana/pull/187608)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Yan
Savitski","email":"yan.savitski@elastic.co"},"sourceCommit":{"committedDate":"2024-07-10T13:05:59Z","message":"[Search][Playground]
Update UI (#187608)\n\n## Summary\r\n\r\nSummarize your PR. If it
involves visual changes include a screenshot or\r\ngif.\r\n\r\n\r\n###
Checklist\r\n\r\nDelete any items that are not applicable to this
PR.\r\n\r\n- [ ] Any text added follows [EUI's
writing\r\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\r\nsentence case text and includes
[i18n\r\nsupport](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)\r\n-
[
]\r\n[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)\r\nwas
added for features that require explanation or tutorials\r\n- [ ] [Unit
or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n- [ ] [Flaky
Test\r\nRunner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1)
was\r\nused on any tests changed\r\n- [ ] Any UI touched in this PR is
usable by keyboard only (learn more\r\nabout [keyboard
accessibility](https://webaim.org/techniques/keyboard/))\r\n- [ ] Any UI
touched in this PR does not create any new axe failures\r\n(run axe in
browser:\r\n[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),\r\n[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))\r\n-
[ ] If a plugin configuration key changed, check if it needs to
be\r\nallowlisted in the cloud and added to the
[docker\r\nlist](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)\r\n-
[ ] This renders correctly on smaller devices using a
responsive\r\nlayout. (You can test this [in
your\r\nbrowser](https://www.browserstack.com/guide/responsive-testing-on-local-server))\r\n-
[ ] This was checked for
[cross-browser\r\ncompatibility](https://www.elastic.co/support/matrix#matrix_browsers)\r\n\r\n\r\n###
Risk Matrix\r\n\r\nDelete this section if it is not applicable to this
PR.\r\n\r\nBefore closing this PR, invite QA, stakeholders, and other
developers to\r\nidentify risks that should be tested prior to the
change/feature\r\nrelease.\r\n\r\nWhen forming the risk matrix, consider
some of the following examples\r\nand how they may potentially impact
the change:\r\n\r\n| Risk | Probability | Severity | Mitigation/Notes
|\r\n\r\n|---------------------------|-------------|----------|-------------------------|\r\n|
Multiple Spaces&mdash;unexpected behavior in non-default Kibana
Space.\r\n| Low | High | Integration tests will verify that all features
are still\r\nsupported in non-default Kibana Space and when user
switches between\r\nspaces. |\r\n| Multiple nodes&mdash;Elasticsearch
polling might have race conditions\r\nwhen multiple Kibana nodes are
polling for the same tasks. | High | Low\r\n| Tasks are idempotent, so
executing them multiple times will not result\r\nin logical error, but
will degrade performance. To test for this case we\r\nadd plenty of unit
tests around this logic and document manual testing\r\nprocedure. |\r\n|
Code should gracefully handle cases when feature X or plugin Y
are\r\ndisabled. | Medium | High | Unit tests will verify that any
feature flag\r\nor plugin combination still results in our service
operational. |\r\n| [See more potential
risk\r\nexamples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx)
|\r\n\r\n\r\n### For maintainers\r\n\r\n- [ ] This was checked for
breaking API changes and was
[labeled\r\nappropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\r\n\r\n---------\r\n\r\nCo-authored-by:
Joseph McElroy
<joseph.mcelroy@elastic.co>","sha":"77267b28ba6be1a85c7a2ee1169db48864b52ef0","branchLabelMapping":{"^v8.16.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:EnterpriseSearch","v8.15.0","v8.16.0"],"title":"[Search][Playground]
Update
UI","number":187608,"url":"https://github.com/elastic/kibana/pull/187608","mergeCommit":{"message":"[Search][Playground]
Update UI (#187608)\n\n## Summary\r\n\r\nSummarize your PR. If it
involves visual changes include a screenshot or\r\ngif.\r\n\r\n\r\n###
Checklist\r\n\r\nDelete any items that are not applicable to this
PR.\r\n\r\n- [ ] Any text added follows [EUI's
writing\r\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\r\nsentence case text and includes
[i18n\r\nsupport](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)\r\n-
[
]\r\n[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)\r\nwas
added for features that require explanation or tutorials\r\n- [ ] [Unit
or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n- [ ] [Flaky
Test\r\nRunner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1)
was\r\nused on any tests changed\r\n- [ ] Any UI touched in this PR is
usable by keyboard only (learn more\r\nabout [keyboard
accessibility](https://webaim.org/techniques/keyboard/))\r\n- [ ] Any UI
touched in this PR does not create any new axe failures\r\n(run axe in
browser:\r\n[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),\r\n[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))\r\n-
[ ] If a plugin configuration key changed, check if it needs to
be\r\nallowlisted in the cloud and added to the
[docker\r\nlist](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)\r\n-
[ ] This renders correctly on smaller devices using a
responsive\r\nlayout. (You can test this [in
your\r\nbrowser](https://www.browserstack.com/guide/responsive-testing-on-local-server))\r\n-
[ ] This was checked for
[cross-browser\r\ncompatibility](https://www.elastic.co/support/matrix#matrix_browsers)\r\n\r\n\r\n###
Risk Matrix\r\n\r\nDelete this section if it is not applicable to this
PR.\r\n\r\nBefore closing this PR, invite QA, stakeholders, and other
developers to\r\nidentify risks that should be tested prior to the
change/feature\r\nrelease.\r\n\r\nWhen forming the risk matrix, consider
some of the following examples\r\nand how they may potentially impact
the change:\r\n\r\n| Risk | Probability | Severity | Mitigation/Notes
|\r\n\r\n|---------------------------|-------------|----------|-------------------------|\r\n|
Multiple Spaces&mdash;unexpected behavior in non-default Kibana
Space.\r\n| Low | High | Integration tests will verify that all features
are still\r\nsupported in non-default Kibana Space and when user
switches between\r\nspaces. |\r\n| Multiple nodes&mdash;Elasticsearch
polling might have race conditions\r\nwhen multiple Kibana nodes are
polling for the same tasks. | High | Low\r\n| Tasks are idempotent, so
executing them multiple times will not result\r\nin logical error, but
will degrade performance. To test for this case we\r\nadd plenty of unit
tests around this logic and document manual testing\r\nprocedure. |\r\n|
Code should gracefully handle cases when feature X or plugin Y
are\r\ndisabled. | Medium | High | Unit tests will verify that any
feature flag\r\nor plugin combination still results in our service
operational. |\r\n| [See more potential
risk\r\nexamples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx)
|\r\n\r\n\r\n### For maintainers\r\n\r\n- [ ] This was checked for
breaking API changes and was
[labeled\r\nappropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\r\n\r\n---------\r\n\r\nCo-authored-by:
Joseph McElroy
<joseph.mcelroy@elastic.co>","sha":"77267b28ba6be1a85c7a2ee1169db48864b52ef0"}},"sourceBranch":"main","suggestedTargetBranches":["8.15"],"targetPullRequestStates":[{"branch":"8.15","label":"v8.15.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.16.0","branchLabelMappingKey":"^v8.16.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/187608","number":187608,"mergeCommit":{"message":"[Search][Playground]
Update UI (#187608)\n\n## Summary\r\n\r\nSummarize your PR. If it
involves visual changes include a screenshot or\r\ngif.\r\n\r\n\r\n###
Checklist\r\n\r\nDelete any items that are not applicable to this
PR.\r\n\r\n- [ ] Any text added follows [EUI's
writing\r\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\r\nsentence case text and includes
[i18n\r\nsupport](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)\r\n-
[
]\r\n[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)\r\nwas
added for features that require explanation or tutorials\r\n- [ ] [Unit
or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n- [ ] [Flaky
Test\r\nRunner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1)
was\r\nused on any tests changed\r\n- [ ] Any UI touched in this PR is
usable by keyboard only (learn more\r\nabout [keyboard
accessibility](https://webaim.org/techniques/keyboard/))\r\n- [ ] Any UI
touched in this PR does not create any new axe failures\r\n(run axe in
browser:\r\n[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),\r\n[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))\r\n-
[ ] If a plugin configuration key changed, check if it needs to
be\r\nallowlisted in the cloud and added to the
[docker\r\nlist](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)\r\n-
[ ] This renders correctly on smaller devices using a
responsive\r\nlayout. (You can test this [in
your\r\nbrowser](https://www.browserstack.com/guide/responsive-testing-on-local-server))\r\n-
[ ] This was checked for
[cross-browser\r\ncompatibility](https://www.elastic.co/support/matrix#matrix_browsers)\r\n\r\n\r\n###
Risk Matrix\r\n\r\nDelete this section if it is not applicable to this
PR.\r\n\r\nBefore closing this PR, invite QA, stakeholders, and other
developers to\r\nidentify risks that should be tested prior to the
change/feature\r\nrelease.\r\n\r\nWhen forming the risk matrix, consider
some of the following examples\r\nand how they may potentially impact
the change:\r\n\r\n| Risk | Probability | Severity | Mitigation/Notes
|\r\n\r\n|---------------------------|-------------|----------|-------------------------|\r\n|
Multiple Spaces&mdash;unexpected behavior in non-default Kibana
Space.\r\n| Low | High | Integration tests will verify that all features
are still\r\nsupported in non-default Kibana Space and when user
switches between\r\nspaces. |\r\n| Multiple nodes&mdash;Elasticsearch
polling might have race conditions\r\nwhen multiple Kibana nodes are
polling for the same tasks. | High | Low\r\n| Tasks are idempotent, so
executing them multiple times will not result\r\nin logical error, but
will degrade performance. To test for this case we\r\nadd plenty of unit
tests around this logic and document manual testing\r\nprocedure. |\r\n|
Code should gracefully handle cases when feature X or plugin Y
are\r\ndisabled. | Medium | High | Unit tests will verify that any
feature flag\r\nor plugin combination still results in our service
operational. |\r\n| [See more potential
risk\r\nexamples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx)
|\r\n\r\n\r\n### For maintainers\r\n\r\n- [ ] This was checked for
breaking API changes and was
[labeled\r\nappropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\r\n\r\n---------\r\n\r\nCo-authored-by:
Joseph McElroy
<joseph.mcelroy@elastic.co>","sha":"77267b28ba6be1a85c7a2ee1169db48864b52ef0"}}]}]
BACKPORT-->

Co-authored-by: Yan Savitski <yan.savitski@elastic.co>
This commit is contained in:
Kibana Machine 2024-07-10 16:39:29 +02:00 committed by GitHub
parent fc2898c058
commit 0cc8062cee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 1489 additions and 1878 deletions

View file

@ -9,11 +9,8 @@ import React from 'react';
import { useValues } from 'kea';
import { EuiBetaBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { KibanaLogic } from '../../../shared/kibana';
import { EnterpriseSearchApplicationsPageTemplate } from '../layout/page_template';
@ -31,32 +28,9 @@ export const Playground: React.FC = () => {
defaultMessage: 'Playground',
}),
]}
pageHeader={{
pageTitle: (
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<FormattedMessage
id="xpack.enterpriseSearch.content.playground.headerTitle"
defaultMessage="Playground"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBetaBadge
label={i18n.translate(
'xpack.enterpriseSearch.content.playground.headerTitle.techPreview',
{
defaultMessage: 'TECH PREVIEW',
}
)}
color="hollow"
/>
</EuiFlexItem>
</EuiFlexGroup>
),
rightSideItems: [<searchPlayground.PlaygroundToolbar />],
}}
pageViewTelemetry="Playground"
restrictWidth={false}
panelled={false}
customPageSections
bottomBorder="extended"
docLink="playground"

View file

@ -12,7 +12,6 @@ export type Start = jest.Mocked<SearchPlaygroundPluginStart>;
const createStartMock = (): Start => {
const startContract: Start = {
PlaygroundProvider: jest.fn(),
PlaygroundToolbar: jest.fn(),
Playground: jest.fn(),
PlaygroundHeaderDocs: jest.fn(),
};

View file

@ -13,24 +13,21 @@ export enum AnalyticsEvents {
chatRegenerateMessages = 'chat_regenerate_messages',
citationDetailsExpanded = 'citation_details_expanded',
citationDetailsCollapsed = 'citation_details_collapsed',
editContextFlyoutOpened = 'edit_context_flyout_opened',
editContextFieldToggled = 'edit_context_field_toggled',
editContextDocSizeChanged = 'edit_context_doc_size_changed',
editContextSaved = 'edit_context_saved',
genAiConnectorAdded = 'gen_ai_connector_added',
genAiConnectorCreated = 'gen_ai_connector_created',
genAiConnectorExists = 'gen_ai_connector_exists',
genAiConnectorSetup = 'gen_ai_connector_setup',
includeCitations = 'include_citations',
instructionsFieldChanged = 'instructions_field_changed',
queryFieldsUpdated = 'view_query_fields_updated',
queryModeLoaded = 'query_mode_loaded',
modelSelected = 'model_selected',
retrievalDocsFlyoutOpened = 'retrieval_docs_flyout_opened',
sourceFieldsLoaded = 'source_fields_loaded',
sourceIndexUpdated = 'source_index_updated',
startNewChatPageLoaded = 'start_new_chat_page_loaded',
viewQueryFlyoutOpened = 'view_query_flyout_opened',
viewQueryFieldsUpdated = 'view_query_fields_updated',
viewQuerySaved = 'view_query_saved',
setupChatPageLoaded = 'start_new_chat_page_loaded',
viewCodeFlyoutOpened = 'view_code_flyout_opened',
viewCodeLanguageChange = 'view_code_language_change',
}

View file

@ -5,15 +5,13 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React, { useMemo } from 'react';
import { EuiBetaBadge, EuiFlexGroup, EuiFlexItem, EuiPageTemplate, EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiPageTemplate } from '@elastic/eui';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './utils/query_client';
import { PlaygroundProvider } from './providers/playground_provider';
import { App } from './components/app';
import { PlaygroundToolbar } from './embeddable';
import { PlaygroundHeaderDocs } from './components/playground_header_docs';
import { useKibana } from './hooks/use_kibana';
export const ChatPlaygroundOverview: React.FC = () => {
@ -27,46 +25,19 @@ export const ChatPlaygroundOverview: React.FC = () => {
);
return (
<PlaygroundProvider>
<EuiPageTemplate
offset={0}
restrictWidth={false}
data-test-subj="svlPlaygroundPage"
grow={false}
>
<EuiPageTemplate.Header
css={{ '.euiPageHeaderContent > .euiFlexGroup': { flexWrap: 'wrap' } }}
pageTitle={
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiTitle
css={{ whiteSpace: 'nowrap' }}
data-test-subj="chat-playground-home-page-title"
>
<h2>
<FormattedMessage
id="xpack.searchPlayground.pageTitle"
defaultMessage="Playground"
/>
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBetaBadge
label={i18n.translate('xpack.searchPlayground.pageTitle.techPreview', {
defaultMessage: 'TECH PREVIEW',
})}
color="hollow"
/>
</EuiFlexItem>
</EuiFlexGroup>
}
data-test-subj="chat-playground-home-page"
rightSideItems={[<PlaygroundHeaderDocs />, <PlaygroundToolbar />]}
/>
<App />
{embeddableConsole}
</EuiPageTemplate>
</PlaygroundProvider>
<QueryClientProvider client={queryClient}>
<PlaygroundProvider>
<EuiPageTemplate
offset={0}
restrictWidth={false}
data-test-subj="svlPlaygroundPage"
grow={false}
panelled={false}
>
<App showDocs />
{embeddableConsole}
</EuiPageTemplate>
</PlaygroundProvider>
</QueryClientProvider>
);
};

View file

@ -5,28 +5,61 @@
* 2.0.
*/
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { StartNewChat } from './start_new_chat';
import { useFormContext } from 'react-hook-form';
import { QueryMode } from './query_mode/query_mode';
import { SetupPage } from './setup_page/setup_page';
import { Header } from './header';
import { useLoadConnectors } from '../hooks/use_load_connectors';
import { ChatForm, ChatFormFields } from '../types';
import { Chat } from './chat';
export const App: React.FC = () => {
const [showStartPage, setShowStartPage] = useState(true);
export interface AppProps {
showDocs?: boolean;
}
export enum ViewMode {
chat = 'chat',
query = 'query',
}
export const App: React.FC<AppProps> = ({ showDocs = false }) => {
const [showSetupPage, setShowSetupPage] = useState(true);
const [selectedMode, setSelectedMode] = useState<ViewMode>(ViewMode.chat);
const { watch } = useFormContext<ChatForm>();
const { data: connectors } = useLoadConnectors();
const hasSelectedIndices = watch(ChatFormFields.indices).length;
const handleModeChange = (id: string) => setSelectedMode(id as ViewMode);
useEffect(() => {
if (showSetupPage && connectors?.length && hasSelectedIndices) {
setShowSetupPage(false);
}
}, [connectors, hasSelectedIndices, showSetupPage]);
return (
<KibanaPageTemplate.Section
alignment="top"
restrictWidth={false}
grow
css={{
position: 'relative',
}}
contentProps={{ css: { display: 'flex', flexGrow: 1, position: 'absolute', inset: 0 } }}
paddingSize="none"
className="eui-fullHeight"
>
{showStartPage ? <StartNewChat onStartClick={() => setShowStartPage(false)} /> : <Chat />}
</KibanaPageTemplate.Section>
<>
<Header
showDocs={showDocs}
onModeChange={handleModeChange}
selectedMode={selectedMode}
isActionsDisabled={showSetupPage}
/>
<KibanaPageTemplate.Section
alignment="top"
restrictWidth={false}
grow
css={{
position: 'relative',
}}
contentProps={{ css: { display: 'flex', flexGrow: 1, position: 'absolute', inset: 0 } }}
paddingSize="none"
className="eui-fullHeight"
>
{showSetupPage ? <SetupPage /> : selectedMode === ViewMode.chat ? <Chat /> : <QueryMode />}
</KibanaPageTemplate.Section>
</>
);
};

View file

@ -13,6 +13,7 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiHideFor,
EuiHorizontalRule,
EuiSpacer,
useEuiTheme,
@ -50,14 +51,12 @@ export const Chat = () => {
const { euiTheme } = useEuiTheme();
const {
control,
watch,
formState: { isValid, isSubmitting },
resetField,
handleSubmit,
getValues,
} = useFormContext<ChatForm>();
const { messages, append, stop: stopRequest, setMessages, reload, error } = useChat();
const selectedIndicesCount = watch(ChatFormFields.indices, []).length;
const messagesRef = useAutoBottomScroll();
const [isRegenerating, setIsRegenerating] = useState<boolean>(false);
const usageTracker = useUsageTracker();
@ -123,7 +122,6 @@ export const Chat = () => {
<EuiFlexItem
grow={2}
css={{
borderRight: euiTheme.border.thin,
paddingTop: euiTheme.size.l,
paddingBottom: euiTheme.size.l,
// don't allow the chat to shrink below 66.6% of the screen
@ -149,14 +147,15 @@ export const Chat = () => {
>
<EuiHorizontalRule margin="none" />
<EuiSpacer size="m" />
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="sparkles"
disabled={isToolBarActionsDisabled}
onClick={regenerateMessages}
size="xs"
data-test-subj="regenerateActionButton"
>
<FormattedMessage
@ -170,6 +169,7 @@ export const Chat = () => {
iconType="refresh"
disabled={isToolBarActionsDisabled}
onClick={handleClearChat}
size="xs"
data-test-subj="clearChatActionButton"
>
<FormattedMessage
@ -180,7 +180,7 @@ export const Chat = () => {
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiSpacer size="s" />
<Controller
name={ChatFormFields.question}
@ -234,9 +234,11 @@ export const Chat = () => {
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={1} css={{ flexBasis: 0, minWidth: '33.3%' }}>
<ChatSidebar selectedIndicesCount={selectedIndicesCount} />
</EuiFlexItem>
<EuiHideFor sizes={['xs', 's']}>
<EuiFlexItem grow={1} css={{ flexBasis: 0, minWidth: '33.3%' }}>
<ChatSidebar />
</EuiFlexItem>
</EuiHideFor>
</EuiFlexGroup>
</EuiForm>
);

View file

@ -6,84 +6,91 @@
*/
import {
EuiAccordion,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiText,
EuiLink,
EuiTitle,
useEuiTheme,
useGeneratedHtmlId,
} from '@elastic/eui';
import React, { useState } from 'react';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { SourcesPanelSidebar } from './sources_panel/sources_panel_sidebar';
import { FormattedMessage } from '@kbn/i18n-react';
import { useWatch } from 'react-hook-form';
import { docLinks } from '../../common/doc_links';
import { EditContextPanel } from './edit_context/edit_context_panel';
import { ChatForm, ChatFormFields } from '../types';
import { useManagementLink } from '../hooks/use_management_link';
import { SummarizationPanel } from './summarization_panel/summarization_panel';
interface ChatSidebarProps {
selectedIndicesCount: number;
}
export const ChatSidebar: React.FC<ChatSidebarProps> = ({ selectedIndicesCount }) => {
export const ChatSidebar: React.FC = () => {
const { euiTheme } = useEuiTheme();
const accordions = [
const selectedModel = useWatch<ChatForm, ChatFormFields.summarizationModel>({
name: ChatFormFields.summarizationModel,
});
const managementLink = useManagementLink(selectedModel?.connectorId);
const panels = [
{
id: useGeneratedHtmlId({ prefix: 'summarizationAccordion' }),
title: i18n.translate('xpack.searchPlayground.sidebar.summarizationTitle', {
defaultMessage: 'Model settings',
}),
children: <SummarizationPanel />,
dataTestId: 'summarizationAccordion',
extraAction: (
<EuiButtonEmpty
target="_blank"
href={managementLink}
data-test-subj="manageConnectorsLink"
iconType="wrench"
size="s"
aria-label={i18n.translate(
'xpack.searchPlayground.sidebar.summarizationModel.manageConnectorLink',
{
defaultMessage: 'Manage connector',
}
)}
>
<FormattedMessage
id="xpack.searchPlayground.sidebar.summarizationModel.manageConnectorTooltip"
defaultMessage="Manage"
/>
</EuiButtonEmpty>
),
},
{
id: useGeneratedHtmlId({ prefix: 'sourcesAccordion' }),
title: i18n.translate('xpack.searchPlayground.sidebar.sourceTitle', {
defaultMessage: 'Indices',
title: i18n.translate('xpack.searchPlayground.sidebar.contextTitle', {
defaultMessage: 'Context',
}),
extraAction: !!selectedIndicesCount && (
<EuiText size="xs">
<p>
{i18n.translate('xpack.searchPlayground.sidebar.sourceIndicesCount', {
defaultMessage: '{count, number} {count, plural, one {Index} other {Indices}}',
values: { count: Number(selectedIndicesCount) },
})}
</p>
</EuiText>
extraAction: (
<EuiLink
href={docLinks.context}
target="_blank"
data-test-subj="hidden-fields-documentation-link"
>
<FormattedMessage
id="xpack.searchPlayground.sidebar.contextLearnMore"
defaultMessage="Learn more"
/>
</EuiLink>
),
children: <SourcesPanelSidebar />,
dataTestId: 'sourcesAccordion',
children: <EditContextPanel />,
},
];
const [openAccordionId, setOpenAccordionId] = useState(accordions[0].id);
return (
<EuiFlexGroup direction="column" className="eui-yScroll" gutterSize="none">
{accordions.map(({ id, title, extraAction, children, dataTestId }, index) => (
<EuiFlexItem
key={id}
css={{
borderBottom: index === accordions.length - 1 ? 'none' : euiTheme.border.thin,
padding: `0 ${euiTheme.size.l}`,
flexGrow: openAccordionId === id ? 1 : 0,
transition: `${euiTheme.animation.normal} ease-in-out`,
}}
>
<EuiAccordion
id={id}
buttonContent={
<EuiTitle size="xs">
<h5>{title}</h5>
</EuiTitle>
}
extraAction={extraAction}
buttonProps={{ paddingSize: 'l' }}
forceState={openAccordionId === id ? 'open' : 'closed'}
onToggle={() => setOpenAccordionId(openAccordionId === id ? '' : id)}
data-test-subj={dataTestId}
>
{children}
<EuiSpacer size="l" />
</EuiAccordion>
{panels?.map(({ title, children, extraAction }) => (
<EuiFlexItem key={title} grow={false}>
<EuiFlexGroup direction="column" css={{ padding: euiTheme.size.l }} gutterSize="s">
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="spaceBetween" gutterSize="none" alignItems="center">
<EuiTitle size="xs">
<h4>{title}</h4>
</EuiTitle>
{extraAction && <EuiFlexItem grow={false}>{extraAction}</EuiFlexItem>}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>{children}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
))}
</EuiFlexGroup>

View file

@ -0,0 +1,37 @@
/*
* 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, { useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiButton } from '@elastic/eui';
import { useWatch } from 'react-hook-form';
import { ChatForm, ChatFormFields } from '../types';
import { SelectIndicesFlyout } from './select_indices_flyout';
export const DataActionButton: React.FC = () => {
const selectedIndices = useWatch<ChatForm, ChatFormFields.indices>({
name: ChatFormFields.indices,
});
const [showFlyout, setShowFlyout] = useState(false);
const handleFlyoutClose = () => setShowFlyout(false);
const handleShowFlyout = () => setShowFlyout(true);
return (
<>
{showFlyout && <SelectIndicesFlyout onClose={handleFlyoutClose} />}
<EuiButton
size="s"
iconType="database"
onClick={handleShowFlyout}
disabled={!selectedIndices?.length}
data-test-subj="dataSourceActionButton"
>
<FormattedMessage id="xpack.searchPlayground.dataActionButton" defaultMessage="Data" />
</EuiButton>
</>
);
};

View file

@ -1,37 +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 React, { useState } from 'react';
import { EuiButtonEmpty } from '@elastic/eui';
import { useFormContext } from 'react-hook-form';
import { FormattedMessage } from '@kbn/i18n-react';
import { ChatForm } from '../../types';
import { EditContextFlyout } from './edit_context_flyout';
export const EditContextAction: React.FC = () => {
const { watch } = useFormContext<ChatForm>();
const [showFlyout, setShowFlyout] = useState(false);
const selectedIndices: string[] = watch('indices');
const closeFlyout = () => setShowFlyout(false);
return (
<>
{showFlyout && <EditContextFlyout onClose={closeFlyout} />}
<EuiButtonEmpty
onClick={() => setShowFlyout(true)}
disabled={selectedIndices?.length === 0}
data-test-subj="editContextActionButton"
>
<FormattedMessage
id="xpack.searchPlayground.editContext.actionButtonLabel"
defaultMessage="Edit context"
/>
</EuiButtonEmpty>
</>
);
};

View file

@ -1,211 +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 React, { useEffect, useState } from 'react';
import {
EuiFlyout,
EuiFlyoutHeader,
EuiFlyoutBody,
EuiTitle,
EuiFlexItem,
EuiFlexGroup,
EuiButtonEmpty,
EuiButton,
EuiFlyoutFooter,
EuiLink,
EuiSpacer,
EuiText,
EuiSelect,
EuiSuperSelect,
EuiFormRow,
} from '@elastic/eui';
import { useController, useFormContext } from 'react-hook-form';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { docLinks } from '../../../common/doc_links';
import { useUsageTracker } from '../../hooks/use_usage_tracker';
import { ChatForm, ChatFormFields } from '../../types';
import { useIndicesFields } from '../../hooks/use_indices_fields';
import { getDefaultSourceFields } from '../../utils/create_query';
import { AnalyticsEvents } from '../../analytics/constants';
interface EditContextFlyoutProps {
onClose: () => void;
}
export const EditContextFlyout: React.FC<EditContextFlyoutProps> = ({ onClose }) => {
const usageTracker = useUsageTracker();
const { getValues } = useFormContext<ChatForm>();
const selectedIndices: string[] = getValues(ChatFormFields.indices);
const { fields } = useIndicesFields(selectedIndices);
const defaultFields = getDefaultSourceFields(fields);
const {
field: { onChange: onChangeSize, value: docSizeInitialValue },
} = useController({
name: ChatFormFields.docSize,
});
const [docSize, setDocSize] = useState(docSizeInitialValue);
const {
field: { onChange: onChangeSourceFields, value: sourceFields },
} = useController({
name: ChatFormFields.sourceFields,
defaultValue: defaultFields,
});
const [tempSourceFields, setTempSourceFields] = useState(sourceFields);
const updateSourceField = (index: string, field: string) => {
setTempSourceFields({
...tempSourceFields,
[index]: [field],
});
usageTracker?.click(AnalyticsEvents.editContextFieldToggled);
};
const saveSourceFields = () => {
usageTracker?.click(AnalyticsEvents.editContextSaved);
onChangeSourceFields(tempSourceFields);
onChangeSize(docSize);
onClose();
};
const handleDocSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
usageTracker?.click(AnalyticsEvents.editContextDocSizeChanged);
setDocSize(Number(e.target.value));
};
useEffect(() => {
usageTracker?.load(AnalyticsEvents.editContextFlyoutOpened);
}, [usageTracker]);
return (
<EuiFlyout ownFocus onClose={onClose} size="s" data-test-subj="editContextFlyout">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
<FormattedMessage
id="xpack.searchPlayground.editContext.flyout.title"
defaultMessage="Edit context"
/>
</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText color="subdued">
<p>
<FormattedMessage
id="xpack.searchPlayground.editContext.flyout.description"
defaultMessage="Context is the information you provide to the LLM, by selecting fields from your Elasticsearch documents. Optimize context for better results."
/>
<EuiLink
href={docLinks.context}
target="_blank"
data-test-subj="context-optimization-documentation-link"
>
<FormattedMessage
id="xpack.searchPlayground.editContext.flyout.learnMoreLink"
defaultMessage=" Learn more."
/>
</EuiLink>
</p>
</EuiText>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiFlexGroup>
<EuiFlexItem grow={3}>
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexItem grow={false}>
<EuiFormRow
label={i18n.translate(
'xpack.searchPlayground.editContext.flyout.docsRetrievedCount',
{
defaultMessage: 'Number of documents sent to LLM',
}
)}
>
<EuiSelect
options={[
{
value: 1,
text: '1',
},
{
value: 3,
text: '3',
},
{
value: 5,
text: '5',
},
{
value: 10,
text: '10',
},
]}
value={docSize}
onChange={handleDocSizeChange}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem>
<EuiText>
<h5>
<FormattedMessage
id="xpack.searchPlayground.editContext.flyout.table.title"
defaultMessage="Select fields"
/>
</h5>
</EuiText>
</EuiFlexItem>
{Object.entries(fields).map(([index, group]) => (
<EuiFlexItem grow={false} key={index}>
<EuiFormRow label={index}>
<EuiSuperSelect
data-test-subj={`contextFieldsSelectable_${index}`}
options={group.source_fields.map((field) => ({
value: field,
inputDisplay: field,
'data-test-subj': 'contextField',
}))}
valueOfSelected={tempSourceFields[index]?.[0]}
onChange={(value) => updateSourceField(index, value)}
/>
</EuiFormRow>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={onClose} flush="left">
<FormattedMessage
id="xpack.searchPlayground.editContext.flyout.closeButton"
defaultMessage="Close"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton onClick={saveSourceFields} fill>
<FormattedMessage
id="xpack.searchPlayground.editContext.flyout.saveButton"
defaultMessage="Save changes"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -7,12 +7,13 @@
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import { EditContextFlyout } from './edit_context_flyout';
import { EditContextPanel } from './edit_context_panel';
import { FormProvider, useForm } from 'react-hook-form';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { ChatFormFields } from '../../types';
jest.mock('../../hooks/use_indices_fields', () => ({
useIndicesFields: () => ({
jest.mock('../../hooks/use_source_indices_field', () => ({
useSourceIndicesFields: () => ({
fields: {
index1: {
elser_query_fields: [],
@ -43,9 +44,9 @@ jest.mock('../../hooks/use_usage_tracker', () => ({
const MockFormProvider = ({ children }: { children: React.ReactElement }) => {
const methods = useForm({
values: {
indices: ['index1'],
docSize: 1,
sourceFields: {
[ChatFormFields.indices]: ['index1'],
[ChatFormFields.docSize]: 1,
[ChatFormFields.sourceFields]: {
index1: ['context_field1'],
index2: ['context_field2'],
},
@ -55,27 +56,20 @@ const MockFormProvider = ({ children }: { children: React.ReactElement }) => {
};
describe('EditContextFlyout component tests', () => {
const onCloseMock = jest.fn();
beforeEach(() => {
render(
<IntlProvider locale="en">
<MockFormProvider>
<EditContextFlyout onClose={onCloseMock} />
<EditContextPanel />
</MockFormProvider>
</IntlProvider>
);
});
it('calls onClose when the close button is clicked', () => {
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onCloseMock).toHaveBeenCalledTimes(1);
});
it('should see the context fields', async () => {
expect(screen.getByTestId('contextFieldsSelectable_index1')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('contextFieldsSelectable_index1'));
expect(screen.getByRole('option', { name: 'context_field1' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'context_field2' })).toBeInTheDocument();
expect(screen.getByTestId('contextFieldsSelectable-0')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('contextFieldsSelectable-0'));
const fields = await screen.findAllByTestId('contextField');
expect(fields.length).toBe(2);
});
});

View file

@ -0,0 +1,137 @@
/*
* 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 {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiPanel,
EuiSelect,
EuiSuperSelect,
EuiText,
EuiCallOut,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
import { useController } from 'react-hook-form';
import { useSourceIndicesFields } from '../../hooks/use_source_indices_field';
import { useUsageTracker } from '../../hooks/use_usage_tracker';
import { ChatForm, ChatFormFields } from '../../types';
import { AnalyticsEvents } from '../../analytics/constants';
export const EditContextPanel: React.FC = () => {
const usageTracker = useUsageTracker();
const { fields } = useSourceIndicesFields();
const {
field: { onChange: onChangeSize, value: docSize },
} = useController({
name: ChatFormFields.docSize,
});
const {
field: { onChange: onChangeSourceFields, value: sourceFields },
} = useController<ChatForm, ChatFormFields.sourceFields>({
name: ChatFormFields.sourceFields,
});
const updateSourceField = (index: string, field: string) => {
onChangeSourceFields({
...sourceFields,
[index]: [field],
});
usageTracker?.click(AnalyticsEvents.editContextFieldToggled);
};
const handleDocSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
usageTracker?.click(AnalyticsEvents.editContextDocSizeChanged);
onChangeSize(Number(e.target.value));
};
return (
<EuiPanel data-test-subj="editContextPanel">
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexItem grow={false}>
<EuiFormRow
label={i18n.translate('xpack.searchPlayground.editContext.docsRetrievedCount', {
defaultMessage: 'Number of documents sent to LLM',
})}
fullWidth
>
<EuiSelect
options={[
{
value: 1,
text: '1',
},
{
value: 3,
text: '3',
},
{
value: 5,
text: '5',
},
{
value: 10,
text: '10',
},
]}
value={docSize}
onChange={handleDocSizeChange}
fullWidth
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem>
<EuiText>
<h5>
<FormattedMessage
id="xpack.searchPlayground.editContext.table.title"
defaultMessage="Context fields"
/>
</h5>
</EuiText>
</EuiFlexItem>
{Object.entries(fields).map(([index, group], indexNum) => (
<EuiFlexItem grow={false} key={index}>
<EuiFormRow label={index} fullWidth>
{!!group.source_fields?.length ? (
<EuiSuperSelect
data-test-subj={`contextFieldsSelectable-${indexNum}`}
options={group.source_fields.map((field) => ({
value: field,
inputDisplay: field,
'data-test-subj': 'contextField',
}))}
valueOfSelected={sourceFields[index]?.[0]}
onChange={(value) => updateSourceField(index, value)}
fullWidth
/>
) : (
<EuiCallOut
title={i18n.translate(
'xpack.searchPlayground.editContext.noSourceFieldWarning',
{ defaultMessage: 'No source fields found' }
)}
color="warning"
iconType="warning"
size="s"
/>
)}
</EuiFormRow>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiBetaBadge,
EuiButtonGroup,
EuiFlexGroup,
EuiPageHeaderSection,
EuiPageTemplate,
EuiTitle,
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
import { PlaygroundHeaderDocs } from './playground_header_docs';
import { Toolbar } from './toolbar';
import { ViewMode } from './app';
interface HeaderProps {
showDocs?: boolean;
selectedMode: string;
onModeChange: (mode: string) => void;
isActionsDisabled?: boolean;
}
export const Header: React.FC<HeaderProps> = ({
selectedMode,
onModeChange,
showDocs = false,
isActionsDisabled = false,
}) => {
const { euiTheme } = useEuiTheme();
const options = [
{
id: ViewMode.chat,
label: i18n.translate('xpack.searchPlayground.header.view.chat', {
defaultMessage: 'Chat',
}),
'data-test-subj': 'chatMode',
},
{
id: ViewMode.query,
label: i18n.translate('xpack.searchPlayground.header.view.query', {
defaultMessage: 'Query',
}),
'data-test-subj': 'queryMode',
},
];
return (
<EuiPageTemplate.Header
css={{
'.euiPageHeaderContent > .euiFlexGroup': { flexWrap: 'wrap' },
backgroundColor: euiTheme.colors.ghost,
}}
paddingSize="s"
data-test-subj="chat-playground-home-page"
>
<EuiPageHeaderSection>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiTitle
css={{ whiteSpace: 'nowrap' }}
data-test-subj="chat-playground-home-page-title"
size="xs"
>
<h2>
<FormattedMessage id="xpack.searchPlayground.pageTitle" defaultMessage="Playground" />
</h2>
</EuiTitle>
<EuiBetaBadge
label={i18n.translate('xpack.searchPlayground.pageTitle.techPreview', {
defaultMessage: 'TECH PREVIEW',
})}
color="hollow"
alignment="middle"
/>
</EuiFlexGroup>
</EuiPageHeaderSection>
<EuiPageHeaderSection>
<EuiButtonGroup
legend="viewMode"
options={options}
idSelected={selectedMode}
onChange={onModeChange}
buttonSize="compressed"
isDisabled={isActionsDisabled}
data-test-subj="viewModeSelector"
/>
</EuiPageHeaderSection>
<EuiPageHeaderSection>
<EuiFlexGroup alignItems="center">
{showDocs && <PlaygroundHeaderDocs />}
<Toolbar />
</EuiFlexGroup>
</EuiPageHeaderSection>
</EuiPageTemplate.Header>
);
};

View file

@ -17,6 +17,7 @@ import {
EuiSpacer,
EuiText,
EuiTitle,
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -42,6 +43,7 @@ const AIMessageCSS = css`
`;
export const AssistantMessage: React.FC<AssistantMessageProps> = ({ message }) => {
const { euiTheme } = useEuiTheme();
const [isDocsFlyoutOpen, setIsDocsFlyoutOpen] = useState(false);
const { content, createdAt, citations, retrievalDocs, inputTokens } = message;
const username = i18n.translate('xpack.searchPlayground.chat.message.assistant.username', {
@ -55,6 +57,14 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({ message }) =
username={username}
timelineAvatar="dot"
data-test-subj="retrieval-docs-comment"
eventColor="subdued"
css={{
'.euiAvatar': { backgroundColor: euiTheme.colors.ghost },
'.euiCommentEvent': {
border: euiTheme.border.thin,
borderRadius: euiTheme.border.radius.medium,
},
}}
event={
<>
<EuiText size="s">
@ -96,6 +106,14 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({ message }) =
username={username}
timelineAvatar="dot"
data-test-subj="retrieval-docs-comment-no-docs"
eventColor="subdued"
css={{
'.euiAvatar': { backgroundColor: euiTheme.colors.ghost },
'.euiCommentEvent': {
border: euiTheme.border.thin,
borderRadius: euiTheme.border.radius.medium,
},
}}
event={
<>
<EuiText size="s">
@ -144,6 +162,13 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({ message }) =
},
})
}
css={{
'.euiAvatar': { backgroundColor: euiTheme.colors.ghost },
'.euiCommentEvent__body': {
backgroundColor: euiTheme.colors.ghost,
},
}}
eventColor="subdued"
timelineAvatar="compute"
timelineAvatarAriaLabel={i18n.translate(
'xpack.searchPlayground.chat.message.assistant.avatarAriaLabel',

View file

@ -7,7 +7,7 @@
import React from 'react';
import { EuiComment } from '@elastic/eui';
import { EuiComment, useEuiTheme } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
interface SystemMessageProps {
@ -15,6 +15,8 @@ interface SystemMessageProps {
}
export const SystemMessage: React.FC<SystemMessageProps> = ({ content }) => {
const { euiTheme } = useEuiTheme();
return (
<EuiComment
username={i18n.translate('xpack.searchPlayground.chat.message.system.username', {
@ -29,6 +31,13 @@ export const SystemMessage: React.FC<SystemMessageProps> = ({ content }) => {
event={content}
timelineAvatar="dot"
eventColor="subdued"
css={{
'.euiAvatar': { backgroundColor: euiTheme.colors.ghost },
'.euiCommentEvent': {
border: euiTheme.border.thin,
borderRadius: euiTheme.border.radius.medium,
},
}}
/>
);
};

View file

@ -9,7 +9,7 @@ import React from 'react';
import moment from 'moment';
import { EuiComment, EuiText } from '@elastic/eui';
import { EuiComment, EuiText, useEuiTheme } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { UserAvatar } from '@kbn/user-profile-components';
@ -26,10 +26,17 @@ const UserMessageCSS = css`
`;
export const UserMessage: React.FC<UserMessageProps> = ({ content, createdAt }) => {
const { euiTheme } = useEuiTheme();
const currentUserProfile = useUserProfile();
return (
<EuiComment
eventColor="subdued"
css={{
'.euiCommentEvent__body': {
backgroundColor: euiTheme.colors.ghost,
},
}}
username={i18n.translate('xpack.searchPlayground.chat.message.user.name', {
defaultMessage: 'You',
})}

View file

@ -20,6 +20,7 @@ export const PlaygroundHeaderDocs: React.FC = () => (
href={docLinks.chatPlayground}
target="_blank"
iconType="documents"
size="s"
>
{i18n.translate('xpack.searchPlayground.pageTitle.header.docLink', {
defaultMessage: 'Playground Docs',

View file

@ -6,14 +6,14 @@
*/
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import { ViewQueryFlyout } from './view_query_flyout';
import { render, screen } from '@testing-library/react';
import { QueryMode } from './query_mode';
import { FormProvider, useForm } from 'react-hook-form';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { ChatFormFields } from '../../types';
jest.mock('../../hooks/use_indices_fields', () => ({
useIndicesFields: () => ({
jest.mock('../../hooks/use_source_indices_field', () => ({
useSourceIndicesFields: () => ({
fields: {
index1: {
elser_query_fields: [],
@ -30,6 +30,7 @@ jest.mock('../../hooks/use_indices_fields', () => ({
semantic_fields: [],
},
},
isFieldsLoading: false,
}),
}));
@ -45,6 +46,7 @@ const MockFormProvider = ({ children }: { children: React.ReactElement }) => {
const methods = useForm({
values: {
[ChatFormFields.indices]: ['index1', 'index2'],
[ChatFormFields.queryFields]: { index1: ['field1'], index2: ['field1'] },
[ChatFormFields.sourceFields]: {
index1: ['field1'],
index2: ['field1'],
@ -54,24 +56,17 @@ const MockFormProvider = ({ children }: { children: React.ReactElement }) => {
return <FormProvider {...methods}>{children}</FormProvider>;
};
describe('ViewQueryFlyout component tests', () => {
const onCloseMock = jest.fn();
describe('QueryMode component tests', () => {
beforeEach(() => {
render(
<IntlProvider locale="en">
<MockFormProvider>
<ViewQueryFlyout onClose={onCloseMock} />
<QueryMode />
</MockFormProvider>
</IntlProvider>
);
});
it('calls onClose when the close button is clicked', () => {
fireEvent.click(screen.getByTestId('euiFlyoutCloseButton'));
expect(onCloseMock).toHaveBeenCalledTimes(1);
});
it('should see the view elasticsearch query', async () => {
expect(screen.getByTestId('ViewElasticsearchQueryResult')).toBeInTheDocument();
expect(screen.getByTestId('ViewElasticsearchQueryResult')).toHaveTextContent(
@ -80,14 +75,14 @@ describe('ViewQueryFlyout component tests', () => {
});
it('displays query fields and indicates hidden fields', () => {
expect(screen.getByTestId('queryFieldsSelectable_index1')).toBeInTheDocument();
expect(screen.getByTestId('queryFieldsSelectable_index2')).toBeInTheDocument();
expect(screen.getByTestId('fieldsAccordion-0')).toBeInTheDocument();
expect(screen.getByTestId('fieldsAccordion-1')).toBeInTheDocument();
// Check if hidden fields indicator is shown
expect(screen.getByTestId('skipped_fields_index1')).toBeInTheDocument();
expect(screen.getByTestId('skipped_fields_index1')).toHaveTextContent('1 fields are hidden.');
expect(screen.getByTestId('skippedFields-0')).toBeInTheDocument();
expect(screen.getByTestId('skippedFields-0')).toHaveTextContent('1 fields are hidden.');
// Check if hidden fields indicator is shown
expect(screen.queryByTestId('skipped_fields_index2')).not.toBeInTheDocument();
expect(screen.queryByTestId('skippedFields-1')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,210 @@
/*
* 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 {
EuiAccordion,
EuiBasicTable,
EuiCodeBlock,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiLink,
EuiPanel,
EuiSpacer,
EuiSwitch,
EuiText,
useEuiTheme,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useEffect, useMemo } from 'react';
import { useController, useWatch } from 'react-hook-form';
import { useSourceIndicesFields } from '../../hooks/use_source_indices_field';
import { useUsageTracker } from '../../hooks/use_usage_tracker';
import { ChatForm, ChatFormFields } from '../../types';
import { AnalyticsEvents } from '../../analytics/constants';
import { docLinks } from '../../../common/doc_links';
import { createQuery } from '../../utils/create_query';
const isQueryFieldSelected = (
queryFields: ChatForm[ChatFormFields.queryFields],
index: string,
field: string
): boolean => {
return Boolean(queryFields[index]?.includes(field));
};
export const QueryMode: React.FC = () => {
const { euiTheme } = useEuiTheme();
const usageTracker = useUsageTracker();
const { fields, isFieldsLoading } = useSourceIndicesFields();
const sourceFields = useWatch<ChatForm, ChatFormFields.sourceFields>({
name: ChatFormFields.sourceFields,
});
const {
field: { onChange: queryFieldsOnChange, value: queryFields },
} = useController<ChatForm, ChatFormFields.queryFields>({
name: ChatFormFields.queryFields,
});
const {
field: { onChange: elasticsearchQueryChange },
} = useController({
name: ChatFormFields.elasticsearchQuery,
});
const updateFields = (index: string, fieldName: string, checked: boolean) => {
const currentIndexFields = checked
? [...queryFields[index], fieldName]
: queryFields[index].filter((field) => fieldName !== field);
const updatedQueryFields = { ...queryFields, [index]: currentIndexFields };
queryFieldsOnChange(updatedQueryFields);
elasticsearchQueryChange(createQuery(updatedQueryFields, sourceFields, fields));
usageTracker?.count(AnalyticsEvents.queryFieldsUpdated, currentIndexFields.length);
};
useEffect(() => {
usageTracker?.load(AnalyticsEvents.queryModeLoaded);
}, [usageTracker]);
const query = useMemo(
() =>
!isFieldsLoading && JSON.stringify(createQuery(queryFields, sourceFields, fields), null, 2),
[isFieldsLoading, queryFields, sourceFields, fields]
);
return (
<EuiFlexGroup>
<EuiFlexItem
grow={6}
className="eui-yScroll"
css={{ padding: euiTheme.size.l, paddingRight: 0 }}
>
<EuiCodeBlock
language="json"
fontSize="m"
paddingSize="none"
lineNumbers
transparentBackground
data-test-subj="ViewElasticsearchQueryResult"
>
{query}
</EuiCodeBlock>
</EuiFlexItem>
<EuiFlexItem
grow={3}
className="eui-yScroll"
css={{ padding: euiTheme.size.l, paddingLeft: 0 }}
>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiText>
<h5>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.table.title"
defaultMessage="Fields to search (per index)"
/>
</h5>
</EuiText>
{Object.entries(fields).map(([index, group], indexNum) => (
<EuiFlexItem grow={false} key={index}>
<EuiPanel grow={false} hasShadow={false} hasBorder>
<EuiAccordion
id={index}
buttonContent={
<EuiText>
<h5>{index}</h5>
</EuiText>
}
initialIsOpen
data-test-subj={`fieldsAccordion-${indexNum}`}
>
<EuiSpacer size="s" />
<EuiBasicTable
tableCaption="Query Model table"
items={[
...group.semantic_fields,
...group.elser_query_fields,
...group.dense_vector_query_fields,
...group.bm25_query_fields,
].map((field) => ({
name: typeof field === 'string' ? field : field.field,
checked: isQueryFieldSelected(
queryFields,
index,
typeof field === 'string' ? field : field.field
),
}))}
rowHeader="name"
columns={[
{
field: 'name',
name: 'Field',
'data-test-subj': 'fieldName',
},
{
field: 'checked',
name: 'Enabled',
align: 'right',
render: (checked, field) => {
return (
<EuiSwitch
showLabel={false}
label={field.name}
checked={checked}
onChange={(e) => updateFields(index, field.name, e.target.checked)}
compressed
/>
);
},
},
]}
/>
{group.skipped_fields > 0 && (
<>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiText
size="s"
color="subdued"
data-test-subj={`skippedFields-${indexNum}`}
>
<EuiIcon type="eyeClosed" />
{` `}
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.hiddenFields"
defaultMessage="{skippedFields} fields are hidden."
values={{ skippedFields: group.skipped_fields }}
/>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink
href={docLinks.hiddenFields}
target="_blank"
data-test-subj="hidden-fields-documentation-link"
>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.learnMoreLink"
defaultMessage="Learn more."
/>
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
</EuiAccordion>
</EuiPanel>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,112 @@
/*
* 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, { FC, PropsWithChildren } from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { SelectIndicesFlyout } from './select_indices_flyout';
import { useSourceIndicesFields } from '../hooks/use_source_indices_field';
import { useQueryIndices } from '../hooks/use_query_indices';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
jest.mock('../hooks/use_source_indices_field');
jest.mock('../hooks/use_query_indices');
jest.mock('../hooks/use_indices_fields', () => ({
useIndicesFields: () => ({ fields: {} }),
}));
const Wrapper: FC<PropsWithChildren<unknown>> = ({ children }) => {
return (
<>
<IntlProvider locale="en">{children}</IntlProvider>
</>
);
};
const mockedUseSourceIndicesFields = useSourceIndicesFields as jest.MockedFunction<
typeof useSourceIndicesFields
>;
const mockedUseQueryIndices = useQueryIndices as jest.MockedFunction<typeof useQueryIndices>;
describe('SelectIndicesFlyout', () => {
const onCloseMock = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockedUseSourceIndicesFields.mockReturnValue({
indices: ['index1', 'index2'],
setIndices: jest.fn(),
fields: {},
loading: false,
addIndex: () => {},
removeIndex: () => {},
isFieldsLoading: false,
});
mockedUseQueryIndices.mockReturnValue({
indices: ['index1', 'index2', 'index3'],
isLoading: false,
});
});
it('renders correctly', () => {
const { getByTestId } = render(<SelectIndicesFlyout onClose={onCloseMock} />, {
wrapper: Wrapper,
});
expect(getByTestId('indicesTable')).toBeInTheDocument();
expect(getByTestId('saveButton')).toBeInTheDocument();
expect(getByTestId('closeButton')).toBeInTheDocument();
});
it('selecting indices and saving', async () => {
const { getByTestId } = render(<SelectIndicesFlyout onClose={onCloseMock} />, {
wrapper: Wrapper,
});
fireEvent.click(getByTestId('sourceIndex-2'));
fireEvent.click(getByTestId('saveButton'));
await waitFor(() => {
expect(mockedUseSourceIndicesFields().setIndices).toHaveBeenCalledWith([
'index1',
'index2',
'index3',
]);
expect(onCloseMock).toHaveBeenCalled();
});
});
it('closing flyout without saving', () => {
const { getByTestId } = render(<SelectIndicesFlyout onClose={onCloseMock} />, {
wrapper: Wrapper,
});
fireEvent.click(getByTestId('closeButton'));
expect(onCloseMock).toHaveBeenCalled();
});
it('save button is disabled when no indices are selected', () => {
mockedUseSourceIndicesFields.mockReturnValueOnce({
indices: [],
setIndices: jest.fn(),
fields: {},
loading: false,
addIndex: () => {},
removeIndex: () => {},
isFieldsLoading: false,
});
const { getByTestId } = render(<SelectIndicesFlyout onClose={onCloseMock} />, {
wrapper: Wrapper,
});
const saveButton = getByTestId('saveButton');
expect(saveButton).toBeDisabled();
});
});

View file

@ -0,0 +1,172 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiSelectable,
EuiSpacer,
EuiTabbedContent,
EuiTitle,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option';
import { getIndicesWithNoSourceFields } from '../utils/create_query';
import { useIndicesFields } from '../hooks/use_indices_fields';
import { useSourceIndicesFields } from '../hooks/use_source_indices_field';
import { useQueryIndices } from '../hooks/use_query_indices';
interface SelectIndicesFlyout {
onClose: () => void;
}
export const SelectIndicesFlyout: React.FC<SelectIndicesFlyout> = ({ onClose }) => {
const [query, setQuery] = useState<string>('');
const { indices, isLoading: isIndicesLoading } = useQueryIndices(query);
const { indices: selectedIndices, setIndices: setSelectedIndices } = useSourceIndicesFields();
const [selectedTempIndices, setSelectedTempIndices] = useState<string[]>(selectedIndices);
const handleSelectOptions = (options: EuiSelectableOption[]) => {
setSelectedTempIndices(
options.filter((option) => option.checked === 'on').map((option) => option.label)
);
};
const handleSearchChange = (searchValue: string) => {
setQuery(searchValue);
};
const handleSaveQuery = () => {
setSelectedIndices(selectedTempIndices);
onClose();
};
const tabs = [
{
id: 'indices',
name: i18n.translate('xpack.searchPlayground.setupPage.addDataSource.flyout.tabName', {
defaultMessage: 'Indices',
}),
content: (
<>
<EuiSpacer />
<EuiSelectable
searchable
searchProps={{
onChange: handleSearchChange,
}}
options={[
{
label: i18n.translate(
'xpack.searchPlayground.setupPage.addDataSource.flyout.groupOption',
{
defaultMessage: 'Available indices',
}
),
isGroupLabel: true,
},
...indices.map(
(index, num) =>
({
label: index,
checked: selectedTempIndices.includes(index) ? 'on' : '',
'data-test-subj': `sourceIndex-${num}`,
} as EuiSelectableOption)
),
]}
onChange={handleSelectOptions}
listProps={{
showIcons: true,
bordered: false,
}}
isLoading={isIndicesLoading}
renderOption={undefined}
>
{(list, search) => (
<>
{search}
{list}
</>
)}
</EuiSelectable>
</>
),
},
];
const { fields, isLoading: isFieldsLoading } = useIndicesFields(selectedTempIndices);
const noSourceFieldsWarning = getIndicesWithNoSourceFields(fields);
return (
<EuiFlyout size="s" ownFocus onClose={onClose} data-test-subj="selectIndicesFlyout">
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2>
<FormattedMessage
id="xpack.searchPlayground.addDataSource.flyout.title"
defaultMessage="Add data to query"
/>
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiTabbedContent
tabs={tabs}
initialSelectedTab={tabs[0]}
autoFocus="selected"
data-test-subj="indicesTable"
/>
{!isFieldsLoading && !!noSourceFieldsWarning && (
<EuiCallOut color="warning" iconType="warning" data-test-subj="NoIndicesFieldsMessage">
<p>
<FormattedMessage
id="xpack.searchPlayground.addDataSource.flyout.warningCallout"
defaultMessage="No fields found for {errorMessage}. Try adding data to these indices."
values={{ errorMessage: noSourceFieldsWarning }}
/>
</p>
</EuiCallOut>
)}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
onClick={onClose}
flush="left"
data-test-subj="closeButton"
>
<FormattedMessage
id="xpack.searchPlayground.addDataSource.flyout.closeButton"
defaultMessage="Close"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={handleSaveQuery}
data-test-subj="saveButton"
fill
disabled={!selectedTempIndices.length}
>
<FormattedMessage
id="xpack.searchPlayground.setupPage.addDataSource.flyout.saveButton"
defaultMessage="Save and continue"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -1,111 +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 React, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiButton, EuiCallOut, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { GenerativeAIForSearchPlaygroundConnectorFeatureId } from '@kbn/actions-plugin/common';
import { AnalyticsEvents } from '../analytics/constants';
import { useUsageTracker } from '../hooks/use_usage_tracker';
import { useKibana } from '../hooks/use_kibana';
import { useLoadConnectors } from '../hooks/use_load_connectors';
import { StartChatPanel } from './start_chat_panel';
export const SetUpConnectorPanelForStartChat: React.FC = () => {
const [connectorFlyoutOpen, setConnectorFlyoutOpen] = useState(false);
const [showCallout, setShowAddedCallout] = useState(false);
const {
services: {
triggersActionsUi: { getAddConnectorFlyout: ConnectorFlyout },
},
} = useKibana();
const {
data: connectors,
refetch: refetchConnectors,
isLoading: isConnectorListLoading,
} = useLoadConnectors();
const usageTracker = useUsageTracker();
const handleConnectorCreated = () => {
refetchConnectors();
setShowAddedCallout(true);
setConnectorFlyoutOpen(false);
};
const handleSetupGenAiConnector = () => {
usageTracker?.click(AnalyticsEvents.genAiConnectorCreated);
setConnectorFlyoutOpen(true);
};
useEffect(() => {
if (connectors?.length) {
if (showCallout) {
usageTracker?.load(AnalyticsEvents.genAiConnectorAdded);
} else {
usageTracker?.load(AnalyticsEvents.genAiConnectorExists);
}
} else {
usageTracker?.load(AnalyticsEvents.genAiConnectorSetup);
}
}, [connectors?.length, showCallout, usageTracker]);
if (isConnectorListLoading) {
return null;
}
return connectors?.length ? (
showCallout ? (
<EuiCallOut
title={i18n.translate('xpack.searchPlayground.emptyPrompts.setUpConnector.settled', {
defaultMessage: '{connectorName} connector added',
values: {
connectorName: connectors![0].title,
},
})}
iconType="check"
color="success"
data-test-subj="addedConnectorCallout"
/>
) : null
) : (
<>
<StartChatPanel
title={i18n.translate('xpack.searchPlayground.emptyPrompts.setUpConnector.title', {
defaultMessage: 'Connect to LLM',
})}
description={
<FormattedMessage
id="xpack.searchPlayground.emptyPrompts.setUpConnector.description"
defaultMessage="You need to connect to a large-language model to use this feature. Start by adding connection details for your LLM provider."
/>
}
dataTestSubj="connectToLLMChatPanel"
>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
color="primary"
data-test-subj="setupGenAIConnectorButton"
onClick={handleSetupGenAiConnector}
>
<FormattedMessage
id="xpack.searchPlayground.emptyPrompts.setUpConnector.btn"
defaultMessage="Connect"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</StartChatPanel>
{connectorFlyoutOpen && (
<ConnectorFlyout
featureId={GenerativeAIForSearchPlaygroundConnectorFeatureId}
onConnectorCreated={handleConnectorCreated}
onClose={() => setConnectorFlyoutOpen(false)}
/>
)}
</>
);
};

View file

@ -0,0 +1,53 @@
/*
* 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, { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiButton, EuiButtonEmpty } from '@elastic/eui';
import { ChatForm, ChatFormFields } from '../../types';
import { SelectIndicesFlyout } from '../select_indices_flyout';
export const AddDataSources: React.FC = () => {
const [showFlyout, setShowFlyout] = useState(false);
const { getValues } = useFormContext<ChatForm>();
const hasSelectedIndices: boolean = !!getValues(ChatFormFields.indices)?.length;
const handleFlyoutClose = () => {
setShowFlyout(false);
};
return (
<>
{showFlyout && <SelectIndicesFlyout onClose={handleFlyoutClose} />}
{hasSelectedIndices ? (
<EuiButtonEmpty
iconType="check"
color="success"
onClick={() => setShowFlyout(true)}
data-test-subj="dataSourcesSuccessButton"
>
<FormattedMessage
id="xpack.searchPlayground.setupPage.addedDataSourcesLabel"
defaultMessage="Data sources added"
/>
</EuiButtonEmpty>
) : (
<EuiButton
fill
iconType="plusInCircle"
onClick={() => setShowFlyout(true)}
data-test-subj="addDataSourcesButton"
>
<FormattedMessage
id="xpack.searchPlayground.setupPage.addDataSourcesLabel"
defaultMessage="Add data sources"
/>
</EuiButton>
)}
</>
);
};

View file

@ -7,17 +7,17 @@
import React from 'react';
import { fireEvent, render as testingLibraryRender, waitFor } from '@testing-library/react';
import { SetUpConnectorPanelForStartChat } from './set_up_connector_panel_for_start_chat';
import { useKibana } from '../hooks/use_kibana';
import { useLoadConnectors } from '../hooks/use_load_connectors';
import { ConnectLLMButton } from './connect_llm_button';
import { useKibana } from '../../hooks/use_kibana';
import { useLoadConnectors } from '../../hooks/use_load_connectors';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
const render = (children: React.ReactNode) =>
testingLibraryRender(<IntlProvider locale="en">{children}</IntlProvider>);
jest.mock('../hooks/use_kibana');
jest.mock('../hooks/use_load_connectors');
jest.mock('../hooks/use_usage_tracker', () => ({
jest.mock('../../hooks/use_kibana');
jest.mock('../../hooks/use_load_connectors');
jest.mock('../../hooks/use_usage_tracker', () => ({
useUsageTracker: () => ({
count: jest.fn(),
load: jest.fn(),
@ -30,7 +30,7 @@ const mockConnectors = {
'2': { title: 'Connector 2' },
};
describe('SetUpConnectorPanelForStartChat', () => {
describe('ConnectLLMButton', () => {
beforeEach(() => {
(useKibana as jest.Mock).mockReturnValue({
services: {
@ -59,8 +59,8 @@ describe('SetUpConnectorPanelForStartChat', () => {
isLoading: false,
isSuccess: true,
});
const { getByTestId } = render(<SetUpConnectorPanelForStartChat />);
expect(getByTestId('setupGenAIConnectorButton')).toBeInTheDocument();
const { getByTestId } = render(<ConnectLLMButton />);
expect(getByTestId('connectLLMButton')).toBeInTheDocument();
});
it('show the flyout when the button is clicked', async () => {
@ -69,11 +69,22 @@ describe('SetUpConnectorPanelForStartChat', () => {
isLoading: false,
isSuccess: true,
});
const { getByTestId, queryByTestId } = render(<SetUpConnectorPanelForStartChat />);
const { getByTestId, queryByTestId } = render(<ConnectLLMButton />);
expect(queryByTestId('addConnectorFlyout')).not.toBeInTheDocument();
fireEvent.click(getByTestId('setupGenAIConnectorButton'));
fireEvent.click(getByTestId('connectLLMButton'));
await waitFor(() => expect(getByTestId('addConnectorFlyout')).toBeInTheDocument());
});
it('show success button when connector exists', async () => {
(useLoadConnectors as jest.Mock).mockReturnValue({
data: [{}],
isLoading: false,
isSuccess: true,
});
const { queryByTestId } = render(<ConnectLLMButton />);
expect(queryByTestId('successConnectLLMButton')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,85 @@
/*
* 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 { EuiButton, EuiButtonEmpty } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useEffect, useState } from 'react';
import { GenerativeAIForSearchPlaygroundConnectorFeatureId } from '@kbn/actions-plugin/common';
import { useKibana } from '../../hooks/use_kibana';
import { useLoadConnectors } from '../../hooks/use_load_connectors';
import { useUsageTracker } from '../../hooks/use_usage_tracker';
import { AnalyticsEvents } from '../../analytics/constants';
export const ConnectLLMButton: React.FC = () => {
const [connectorFlyoutOpen, setConnectorFlyoutOpen] = useState(false);
const [showCallout, setShowAddedCallout] = useState(false);
const {
services: {
triggersActionsUi: { getAddConnectorFlyout: ConnectorFlyout },
},
} = useKibana();
const { data: connectors, refetch: refetchConnectors } = useLoadConnectors();
const usageTracker = useUsageTracker();
const handleConnectorCreated = () => {
refetchConnectors();
setShowAddedCallout(true);
setConnectorFlyoutOpen(false);
};
const handleSetupGenAiConnector = () => {
usageTracker?.click(AnalyticsEvents.genAiConnectorCreated);
setConnectorFlyoutOpen(true);
};
useEffect(() => {
if (connectors?.length) {
if (showCallout) {
usageTracker?.load(AnalyticsEvents.genAiConnectorAdded);
} else {
usageTracker?.load(AnalyticsEvents.genAiConnectorExists);
}
} else {
usageTracker?.load(AnalyticsEvents.genAiConnectorSetup);
}
}, [connectors?.length, showCallout, usageTracker]);
return (
<>
{connectors?.length ? (
<EuiButtonEmpty
iconType="check"
color="success"
onClick={handleSetupGenAiConnector}
data-test-subj="successConnectLLMButton"
>
<FormattedMessage
id="xpack.searchPlayground.setupPage.llmConnectedButtonLabel"
defaultMessage="LLM connected"
/>
</EuiButtonEmpty>
) : (
<EuiButton
fill
iconType="link"
data-test-subj="connectLLMButton"
onClick={handleSetupGenAiConnector}
>
<FormattedMessage
id="xpack.searchPlayground.setupPage.connectLLMButtonLabel"
defaultMessage="Connect to an LLM"
/>
</EuiButton>
)}
{connectorFlyoutOpen && (
<ConnectorFlyout
featureId={GenerativeAIForSearchPlaygroundConnectorFeatureId}
onConnectorCreated={handleConnectorCreated}
onClose={() => setConnectorFlyoutOpen(false)}
/>
)}
</>
);
};

View file

@ -7,9 +7,9 @@
import React, { FC, PropsWithChildren } from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { CreateIndexCallout } from './create_index_callout';
import { useKibana } from '../../hooks/use_kibana';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { CreateIndexButton } from './create_index_button';
// Mocking the useKibana hook
jest.mock('../../hooks/use_kibana', () => ({
@ -37,9 +37,9 @@ const Wrapper: FC<PropsWithChildren<unknown>> = ({ children }) => {
);
};
describe('CreateIndexCallout', () => {
describe('CreateIndexButton', () => {
it('renders correctly when there is no locator', async () => {
const { queryByTestId } = render(<CreateIndexCallout />, { wrapper: Wrapper });
const { queryByTestId } = render(<CreateIndexButton />, { wrapper: Wrapper });
expect(queryByTestId('createIndexButton')).not.toBeInTheDocument();
});
@ -64,7 +64,7 @@ describe('CreateIndexCallout', () => {
},
}));
const { getByTestId } = render(<CreateIndexCallout />, { wrapper: Wrapper });
const { getByTestId } = render(<CreateIndexButton />, { wrapper: Wrapper });
const createIndexButton = getByTestId('createIndexButton');
expect(createIndexButton).toBeInTheDocument();

View file

@ -0,0 +1,53 @@
/*
* 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 { EuiButton, EuiCallOut } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../../hooks/use_kibana';
export const CreateIndexButton: React.FC = () => {
const {
services: { application, share },
} = useKibana();
const createIndexLocator = useMemo(
() => share.url.locators.get('CREATE_INDEX_LOCATOR_ID'),
[share.url.locators]
);
const handleNavigateToIndex = useCallback(async () => {
const createIndexUrl = await createIndexLocator?.getUrl({});
if (createIndexUrl) {
application?.navigateToUrl(createIndexUrl);
}
}, [application, createIndexLocator]);
return createIndexLocator ? (
<EuiButton
color="primary"
iconType="plusInCircle"
fill
onClick={handleNavigateToIndex}
data-test-subj="createIndexButton"
>
<FormattedMessage
id="xpack.searchPlayground.createIndexButton"
defaultMessage="Create an index"
/>
</EuiButton>
) : (
<EuiCallOut
title={i18n.translate('xpack.searchPlayground.createIndexCallout', {
defaultMessage: 'You need to create an index first',
})}
size="s"
color="warning"
data-test-subj="createIndexCallout"
/>
);
};

View file

@ -0,0 +1,109 @@
/*
* 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 {
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiLoadingSpinner,
EuiTitle,
} from '@elastic/eui';
import React, { useEffect, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { CreateIndexButton } from './create_index_button';
import { useQueryIndices } from '../../hooks/use_query_indices';
import { docLinks } from '../../../common/doc_links';
import { useSourceIndicesFields } from '../../hooks/use_source_indices_field';
import { useUsageTracker } from '../../hooks/use_usage_tracker';
import { AnalyticsEvents } from '../../analytics/constants';
import { ConnectLLMButton } from './connect_llm_button';
import { AddDataSources } from './add_data_sources';
export const SetupPage: React.FC = () => {
const usageTracker = useUsageTracker();
const [searchParams] = useSearchParams();
const { indices, isLoading: isIndicesLoading } = useQueryIndices();
const index = useMemo(() => searchParams.get('default-index'), [searchParams]);
const { addIndex } = useSourceIndicesFields();
useEffect(() => {
if (index) {
addIndex(index);
}
}, [index, addIndex]);
useEffect(() => {
usageTracker?.load(AnalyticsEvents.setupChatPageLoaded);
}, [usageTracker]);
return (
<EuiEmptyPrompt
iconType="discuss"
data-test-subj="setupPage"
title={
<h2>
<FormattedMessage
id="xpack.searchPlayground.setupPage.title"
defaultMessage="Setup a chat experience"
/>
</h2>
}
body={
<>
<p>
<FormattedMessage
id="xpack.searchPlayground.setupPage.description"
defaultMessage="Experiment with combining your Elasticsearch data with powerful large language models (LLMs) using Playground for retrieval augmented generation (RAG)."
/>
</p>
<p>
<FormattedMessage
id="xpack.searchPlayground.setupPage.descriptionLLM"
defaultMessage="Connect to your LLM provider and select your data sources to get started."
/>
</p>
</>
}
actions={
<EuiFlexGroup justifyContent="center">
{isIndicesLoading ? (
<EuiLoadingSpinner />
) : (
<>
<EuiFlexItem grow={false}>
<ConnectLLMButton />
</EuiFlexItem>
<EuiFlexItem grow={false}>
{indices.length ? <AddDataSources /> : <CreateIndexButton />}
</EuiFlexItem>
</>
)}
</EuiFlexGroup>
}
footer={
<>
<EuiTitle size="xxs">
<span>
<FormattedMessage
id="xpack.searchPlayground.setupPage.learnMore"
defaultMessage="Want to learn more?"
/>
</span>
</EuiTitle>{' '}
<EuiLink href={docLinks.chatPlayground} target="_blank" external>
<FormattedMessage
id="xpack.searchPlayground.setupPage.documentationLink"
defaultMessage="Read documentation"
/>
</EuiLink>
</>
}
/>
);
};

View file

@ -1,65 +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 { EuiComboBox, EuiFormRow } from '@elastic/eui';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types';
import { IndexName } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { useQueryIndices } from '../../hooks/use_query_indices';
interface AddIndicesFieldProps {
selectedIndices: IndexName[];
onIndexSelect: (index: IndexName) => void;
loading: boolean;
}
export const AddIndicesField: React.FC<AddIndicesFieldProps> = ({
selectedIndices,
onIndexSelect,
loading,
}) => {
const [query, setQuery] = useState<string>('');
const { indices, isLoading } = useQueryIndices(query);
const handleChange = (value: Array<EuiComboBoxOptionOption<IndexName>>) => {
if (value?.[0]?.label) {
onIndexSelect(value[0].label);
}
};
const handleSearchChange = (searchValue: string) => {
setQuery(searchValue);
};
return (
<EuiFormRow
fullWidth
label={i18n.translate('xpack.searchPlayground.sources.addIndex.label', {
defaultMessage: 'Add index',
})}
labelType="legend"
>
<EuiComboBox
singleSelection={{ asPlainText: true }}
placeholder={i18n.translate('xpack.searchPlayground.sources.addIndex.placeholder', {
defaultMessage: 'Search for index',
})}
async
isLoading={isLoading}
onChange={handleChange}
onSearchChange={handleSearchChange}
fullWidth
isDisabled={loading}
options={indices.map((index) => ({
label: index,
disabled: selectedIndices.includes(index),
}))}
isClearable={false}
data-test-subj="selectIndicesComboBox"
/>
</EuiFormRow>
);
};

View file

@ -1,65 +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 { EuiButton, EuiCallOut, EuiSpacer, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useCallback, useMemo } from 'react';
import { useKibana } from '../../hooks/use_kibana';
export const CreateIndexCallout: React.FC = () => {
const {
services: { application, share },
} = useKibana();
const createIndexLocator = useMemo(
() => share.url.locators.get('CREATE_INDEX_LOCATOR_ID'),
[share.url.locators]
);
const handleNavigateToIndex = useCallback(async () => {
const createIndexUrl = await createIndexLocator?.getUrl({});
if (createIndexUrl) {
application?.navigateToUrl(createIndexUrl);
}
}, [application, createIndexLocator]);
return (
<EuiCallOut
title={i18n.translate('xpack.searchPlayground.sources.createIndexCallout.headerText', {
defaultMessage: 'Create an index',
})}
color="primary"
iconType="iInCircle"
data-test-subj="createIndexCallout"
>
<EuiText size="s">
<p>
<FormattedMessage
id="xpack.searchPlayground.sources.createIndexCallout.description"
defaultMessage="You need at least one index with data to search. "
/>
</p>
</EuiText>
<EuiSpacer size="l" />
{createIndexLocator && (
<EuiButton
color="primary"
iconType="plusInCircle"
fill
size="s"
onClick={handleNavigateToIndex}
data-test-subj="createIndexButton"
>
<FormattedMessage
id="xpack.searchPlayground.sources.createIndexCallout."
defaultMessage="Create an index"
/>
</EuiButton>
)}
</EuiCallOut>
);
};

View file

@ -1,73 +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 React, { FC, PropsWithChildren } from 'react';
import { render, screen } from '@testing-library/react';
import { SourcesPanelSidebar } from './sources_panel_sidebar';
import { useSourceIndicesFields } from '../../hooks/use_source_indices_field';
import { useQueryIndices } from '../../hooks/use_query_indices';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
jest.mock('../../hooks/use_source_indices_field', () => ({
useSourceIndicesFields: jest.fn(),
}));
jest.mock('../../hooks/use_query_indices', () => ({
useQueryIndices: jest.fn(),
}));
const Wrapper: FC<PropsWithChildren<unknown>> = ({ children }) => {
return (
<>
<IntlProvider locale="en">{children}</IntlProvider>
</>
);
};
describe('SourcesPanelSidebar component', () => {
afterEach(jest.clearAllMocks);
it('shows the "AddIndicesField" component when there are indices and not loading', () => {
(useQueryIndices as jest.Mock).mockReturnValue({ indices: ['index1'], isLoading: false });
(useSourceIndicesFields as jest.Mock).mockReturnValue({
indices: [],
removeIndex: jest.fn(),
addIndex: jest.fn(),
loading: false,
});
render(<SourcesPanelSidebar />, { wrapper: Wrapper });
expect(screen.queryByTestId('indicesLoading')).not.toBeInTheDocument();
});
it('displays IndicesTable when there are selected indices', () => {
(useQueryIndices as jest.Mock).mockReturnValue({ indices: ['index1'], isLoading: false });
(useSourceIndicesFields as jest.Mock).mockReturnValue({
indices: ['index1', 'index2'],
removeIndex: jest.fn(),
addIndex: jest.fn(),
loading: false,
});
render(<SourcesPanelSidebar />, { wrapper: Wrapper });
expect(screen.getAllByTestId('removeIndexButton')).toHaveLength(2);
expect(screen.getAllByTestId('removeIndexButton')[0]).not.toBeDisabled();
expect(screen.getAllByTestId('removeIndexButton')[1]).not.toBeDisabled();
});
it('does not allow to remove all indices', () => {
(useQueryIndices as jest.Mock).mockReturnValue({ indices: ['index1'], isLoading: false });
(useSourceIndicesFields as jest.Mock).mockReturnValue({
indices: ['index1'],
removeIndex: jest.fn(),
addIndex: jest.fn(),
loading: false,
});
render(<SourcesPanelSidebar />, { wrapper: Wrapper });
expect(screen.getByTestId('removeIndexButton')).toBeDisabled();
});
});

View file

@ -1,108 +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 React, { FC, PropsWithChildren } from 'react';
import { render, screen } from '@testing-library/react';
import { SourcesPanelForStartChat } from './sources_panel_for_start_chat';
import { useSourceIndicesFields } from '../../hooks/use_source_indices_field';
import { useQueryIndices } from '../../hooks/use_query_indices';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
jest.mock('../../hooks/use_source_indices_field', () => ({
useSourceIndicesFields: jest.fn(),
}));
jest.mock('../../hooks/use_query_indices', () => ({
useQueryIndices: jest.fn(),
}));
jest.mock('../../hooks/use_kibana', () => ({
useKibana: jest.fn(() => ({
services: {
application: { navigateToUrl: jest.fn() },
share: { url: { locators: { get: jest.fn() } } },
},
})),
}));
const Wrapper: FC<PropsWithChildren<unknown>> = ({ children }) => {
return (
<>
<IntlProvider locale="en">{children}</IntlProvider>
</>
);
};
describe('SourcesPanelForStartChat component', () => {
afterEach(jest.clearAllMocks);
it('shows a loading spinner when query is loading', () => {
(useQueryIndices as jest.Mock).mockReturnValue({ indices: [], isLoading: true });
(useSourceIndicesFields as jest.Mock).mockReturnValue({
indices: [],
removeIndex: jest.fn(),
addIndex: jest.fn(),
loading: false,
});
render(<SourcesPanelForStartChat />, { wrapper: Wrapper });
expect(screen.getByTestId('indicesLoading')).toBeInTheDocument();
});
it('shows the "AddIndicesField" component when there are indices and not loading', () => {
(useQueryIndices as jest.Mock).mockReturnValue({ indices: ['index1'], isLoading: false });
(useSourceIndicesFields as jest.Mock).mockReturnValue({
indices: [],
removeIndex: jest.fn(),
addIndex: jest.fn(),
loading: false,
});
render(<SourcesPanelForStartChat />, { wrapper: Wrapper });
expect(screen.queryByTestId('indicesLoading')).not.toBeInTheDocument();
});
it('displays IndicesTable when there are selected indices', () => {
(useQueryIndices as jest.Mock).mockReturnValue({ indices: ['index1'], isLoading: false });
(useSourceIndicesFields as jest.Mock).mockReturnValue({
indices: ['index1'],
removeIndex: jest.fn(),
addIndex: jest.fn(),
loading: false,
});
render(<SourcesPanelForStartChat />, { wrapper: Wrapper });
expect(screen.getAllByText('index1')).toHaveLength(1);
expect(screen.getByTestId('removeIndexButton')).toBeInTheDocument();
});
it('displays "CreateIndexCallout" when no indices are found and not loading', () => {
(useQueryIndices as jest.Mock).mockReturnValue({ indices: [], isLoading: false });
(useSourceIndicesFields as jest.Mock).mockReturnValue({
indices: [],
removeIndex: jest.fn(),
addIndex: jest.fn(),
loading: false,
});
render(<SourcesPanelForStartChat />, { wrapper: Wrapper });
expect(screen.getByTestId('createIndexCallout')).toBeInTheDocument();
});
it('renders warning callout', () => {
(useSourceIndicesFields as jest.Mock).mockReturnValue({
indices: ['index1'],
removeIndex: jest.fn(),
addIndex: jest.fn(),
loading: false,
noFieldsIndicesWarning: 'index1',
});
render(<SourcesPanelForStartChat />, { wrapper: Wrapper });
expect(screen.getByTestId('NoIndicesFieldsMessage')).toBeInTheDocument();
expect(screen.getByTestId('NoIndicesFieldsMessage')).toHaveTextContent('index1');
});
});

View file

@ -1,79 +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 { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { AddIndicesField } from './add_indices_field';
import { IndicesTable } from './indices_table';
import { StartChatPanel } from '../start_chat_panel';
import { CreateIndexCallout } from './create_index_callout';
import { useQueryIndices } from '../../hooks/use_query_indices';
import { useSourceIndicesFields } from '../../hooks/use_source_indices_field';
export const SourcesPanelForStartChat: React.FC = () => {
const {
indices: selectedIndices,
removeIndex,
addIndex,
loading: fieldIndicesLoading,
noFieldsIndicesWarning,
} = useSourceIndicesFields();
const { indices, isLoading } = useQueryIndices();
return (
<StartChatPanel
title={i18n.translate('xpack.searchPlayground.emptyPrompts.sources.title', {
defaultMessage: 'Select indices',
})}
description={i18n.translate('xpack.searchPlayground.emptyPrompts.sources.description', {
defaultMessage:
"Select the Elasticsearch indices you'd like to query, providing additional context for the LLM.",
})}
isValid={!!selectedIndices?.length}
dataTestSubj="selectIndicesChatPanel"
>
{!!selectedIndices?.length && (
<EuiFlexItem>
<IndicesTable indices={selectedIndices} onRemoveClick={removeIndex} />
</EuiFlexItem>
)}
{noFieldsIndicesWarning && (
<EuiCallOut color="warning" iconType="warning" data-test-subj="NoIndicesFieldsMessage">
<p>
{i18n.translate('xpack.searchPlayground.emptyPrompts.sources.warningCallout', {
defaultMessage:
'No fields found for {errorMessage}. Try adding data to these indices.',
values: {
errorMessage: noFieldsIndicesWarning,
},
})}
</p>
</EuiCallOut>
)}
{isLoading && (
<EuiFlexGroup justifyContent="center" alignItems="center">
<EuiLoadingSpinner size="l" data-test-subj="indicesLoading" />
</EuiFlexGroup>
)}
{!isLoading && !!indices?.length && (
<EuiFlexItem>
<AddIndicesField
selectedIndices={selectedIndices}
onIndexSelect={addIndex}
loading={fieldIndicesLoading}
/>
</EuiFlexItem>
)}
{!isLoading && !indices?.length && <CreateIndexCallout />}
</StartChatPanel>
);
};

View file

@ -1,43 +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 React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiCallOut, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { useSourceIndicesFields } from '../../hooks/use_source_indices_field';
import { AddIndicesField } from './add_indices_field';
import { IndicesList } from './indices_list';
export const SourcesPanelSidebar: React.FC = () => {
const { indices: selectedIndices, removeIndex, addIndex, loading } = useSourceIndicesFields();
return (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiCallOut
title={i18n.translate('xpack.searchPlayground.sources.callout', {
defaultMessage: 'Changes update the query used to search your data',
})}
iconType="warning"
size="s"
/>
</EuiFlexItem>
<EuiFlexItem>
<IndicesList indices={selectedIndices} onRemoveClick={removeIndex} hasBorder />
</EuiFlexItem>
<EuiFlexItem>
<AddIndicesField
selectedIndices={selectedIndices}
onIndexSelect={addIndex}
loading={loading}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -1,69 +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 {
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiPanel,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import React, { FC, PropsWithChildren } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
interface StartChatPanelProps {
children: React.ReactNode;
title: string;
description: string | React.ReactNode;
isValid?: boolean;
dataTestSubj: string;
}
export const StartChatPanel: FC<PropsWithChildren<StartChatPanelProps>> = ({
title,
description,
children,
isValid,
dataTestSubj,
}) => (
<EuiPanel hasBorder paddingSize="l" data-test-subj={dataTestSubj}>
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiTitle size="xs">
<h5>{title}</h5>
</EuiTitle>
{isValid && (
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiIcon type="check" color="success" />
<EuiText size="xs" color="success">
<p>
<FormattedMessage
id="xpack.searchPlayground.startChatPanel.verified"
defaultMessage="Completed"
/>
</p>
</EuiText>
</EuiFlexGroup>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup direction="column" gutterSize="l">
<EuiText size="s">
<p>{description}</p>
</EuiText>
{children}
</EuiFlexGroup>
</EuiPanel>
);

View file

@ -1,123 +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 { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, useSearchParams } from 'react-router-dom-v5-compat';
import { StartNewChat } from './start_new_chat';
import { useLoadConnectors } from '../hooks/use_load_connectors';
import { useUsageTracker } from '../hooks/use_usage_tracker';
import { AnalyticsEvents } from '../analytics/constants';
import { useSourceIndicesFields } from '../hooks/use_source_indices_field';
import { useKibana } from '../hooks/use_kibana';
import { PlaygroundProvider } from '../providers/playground_provider';
jest.mock('../hooks/use_load_connectors');
jest.mock('../hooks/use_usage_tracker');
jest.mock('../hooks/use_source_indices_field', () => ({
useSourceIndicesFields: jest.fn(() => ({ addIndex: jest.fn() })),
}));
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useSearchParams: jest.fn(),
}));
jest.mock('../hooks/use_kibana');
const mockUseLoadConnectors = useLoadConnectors as jest.Mock;
const mockUseUsageTracker = useUsageTracker as jest.Mock;
const mockUseSearchParams = useSearchParams as jest.Mock;
const renderWithForm = (ui: React.ReactElement) => {
const Wrapper: React.FC = ({ children }) => {
return (
<IntlProvider locale="en">
<PlaygroundProvider>{children}</PlaygroundProvider>
</IntlProvider>
);
};
return render(ui, { wrapper: Wrapper });
};
const mockConnectors = {
'1': { title: 'Connector 1' },
'2': { title: 'Connector 2' },
};
describe('StartNewChat', () => {
beforeEach(() => {
mockUseLoadConnectors.mockReturnValue({ data: [] });
mockUseUsageTracker.mockReturnValue({ load: jest.fn() });
mockUseSearchParams.mockReturnValue([new URLSearchParams()]);
(useKibana as jest.Mock).mockReturnValue({
services: {
triggersActionsUi: {
getAddConnectorFlyout: () => (
<div data-test-subj="addConnectorFlyout">Add Connector Flyout</div>
),
},
},
});
(useLoadConnectors as jest.Mock).mockReturnValue({
data: mockConnectors,
refetch: jest.fn(),
isLoading: false,
isSuccess: true,
});
});
it('renders correctly', () => {
renderWithForm(
<MemoryRouter>
<StartNewChat onStartClick={jest.fn()} />
</MemoryRouter>
);
expect(screen.getByTestId('startNewChatTitle')).toBeInTheDocument();
});
it('disables the start button when form conditions are not met', () => {
renderWithForm(
<MemoryRouter>
<StartNewChat onStartClick={jest.fn()} />
</MemoryRouter>
);
const startButton = screen.getByTestId('startChatButton');
expect(startButton).toBeDisabled();
});
it('tracks the page load event', () => {
const usageTracker = { load: jest.fn() };
mockUseUsageTracker.mockReturnValue(usageTracker);
renderWithForm(
<MemoryRouter>
<StartNewChat onStartClick={jest.fn()} />
</MemoryRouter>
);
expect(usageTracker.load).toHaveBeenCalledWith(AnalyticsEvents.startNewChatPageLoaded);
});
it('calls addIndex when default-index is present in searchParams', () => {
const addIndex = jest.fn();
(useSourceIndicesFields as jest.Mock).mockReturnValue({ addIndex });
const searchParams = new URLSearchParams({ 'default-index': 'test-index' });
mockUseSearchParams.mockReturnValue([searchParams]);
renderWithForm(
<MemoryRouter>
<StartNewChat onStartClick={jest.fn()} />
</MemoryRouter>
);
expect(addIndex).toHaveBeenCalledWith('test-index');
});
});

View file

@ -1,123 +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 {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiText,
EuiTitle,
useEuiTheme,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useEffect, useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { useUsageTracker } from '../hooks/use_usage_tracker';
import { useLoadConnectors } from '../hooks/use_load_connectors';
import { SourcesPanelForStartChat } from './sources_panel/sources_panel_for_start_chat';
import { SetUpConnectorPanelForStartChat } from './set_up_connector_panel_for_start_chat';
import { ChatFormFields } from '../types';
import { AnalyticsEvents } from '../analytics/constants';
import { useSourceIndicesFields } from '../hooks/use_source_indices_field';
const maxWidthPage = 640;
interface StartNewChatProps {
onStartClick: () => void;
}
export const StartNewChat: React.FC<StartNewChatProps> = ({ onStartClick }) => {
const { euiTheme } = useEuiTheme();
const { data: connectors } = useLoadConnectors();
const { watch } = useFormContext();
const usageTracker = useUsageTracker();
const [searchParams] = useSearchParams();
const index = useMemo(() => searchParams.get('default-index'), [searchParams]);
const { addIndex } = useSourceIndicesFields();
useEffect(() => {
if (index) {
addIndex(index);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [index]);
useEffect(() => {
usageTracker?.load(AnalyticsEvents.startNewChatPageLoaded);
}, [usageTracker]);
return (
<EuiFlexGroup justifyContent="center" className="eui-yScroll" data-test-subj="startChatPage">
<EuiFlexGroup
css={{
height: 'fit-content',
padding: `${euiTheme.size.xxl} ${euiTheme.size.l}`,
maxWidth: maxWidthPage,
boxSizing: 'content-box',
}}
direction="column"
gutterSize="xl"
>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" justifyContent="center" gutterSize="m">
<EuiTitle>
<h2 data-test-subj="startNewChatTitle">
<FormattedMessage
id="xpack.searchPlayground.startNewChat.title"
defaultMessage="Start a new chat"
/>
</h2>
</EuiTitle>
<EuiIcon type="discuss" size="xl" />
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
<p>
<FormattedMessage
id="xpack.searchPlayground.startNewChat.description"
defaultMessage="Combine your Elasticsearch data with the power of large language models for retrieval augmented generation (RAG). Use the UI to view and edit the Elasticsearch queries used to search your data, then download the code to integrate into your own application."
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SetUpConnectorPanelForStartChat />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SourcesPanelForStartChat />
</EuiFlexItem>
<EuiFlexGroup justifyContent="flexEnd">
<EuiButton
fill
iconType="arrowRight"
iconSide="right"
data-test-subj="startChatButton"
disabled={
!watch(ChatFormFields.indices, [])?.length ||
!Object.keys(connectors || {}).length ||
!watch(ChatFormFields.elasticsearchQuery, '')
}
onClick={onStartClick}
>
<FormattedMessage
id="xpack.searchPlayground.startNewChat.startBtn"
defaultMessage="Start"
/>
</EuiButton>
</EuiFlexGroup>
</EuiFlexGroup>
</EuiFlexGroup>
);
};

View file

@ -27,7 +27,7 @@ export const IncludeCitationsField: React.FC<IncludeCitationsFieldProps> = ({
};
return (
<EuiFormRow>
<EuiFormRow fullWidth>
<EuiSwitch
label={i18n.translate('xpack.searchPlayground.sidebar.citationsField.label', {
defaultMessage: 'Include citations',

View file

@ -53,6 +53,7 @@ export const InstructionsField: React.FC<InstructionsFieldProps> = ({ value, onC
</>
</EuiToolTip>
}
fullWidth
>
<EuiTextArea
placeholder={i18n.translate(

View file

@ -60,9 +60,5 @@ describe('SummarizationModel', () => {
);
expect(getByTestId('summarizationModelSelect')).toBeInTheDocument();
expect(getByTestId('manageConnectorsLink')).toHaveAttribute(
'href',
'http://example.com/manage-connectors'
);
});
});

View file

@ -8,7 +8,6 @@
import React, { useEffect, useMemo } from 'react';
import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
@ -16,18 +15,15 @@ import {
EuiSuperSelect,
type EuiSuperSelectOption,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { AnalyticsEvents } from '../../analytics/constants';
import { useUsageTracker } from '../../hooks/use_usage_tracker';
import type { LLMModel } from '../../types';
import { useManagementLink } from '../../hooks/use_management_link';
interface SummarizationModelProps {
selectedModel: LLMModel;
selectedModel?: LLMModel;
onSelect: (model: LLMModel) => void;
models: LLMModel[];
}
@ -40,7 +36,6 @@ export const SummarizationModel: React.FC<SummarizationModelProps> = ({
onSelect,
}) => {
const usageTracker = useUsageTracker();
const managementLink = useManagementLink(selectedModel.connectorId);
const onChange = (modelValue: string) => {
const newSelectedModel = models.find((model) => getOptionValue(model) === modelValue);
@ -98,7 +93,7 @@ export const SummarizationModel: React.FC<SummarizationModelProps> = ({
useEffect(() => {
usageTracker?.click(
`${AnalyticsEvents.modelSelected}_${selectedModel.value || selectedModel.connectorType}`
`${AnalyticsEvents.modelSelected}_${selectedModel!.value || selectedModel!.connectorType}`
);
}, [usageTracker, selectedModel]);
@ -113,36 +108,14 @@ export const SummarizationModel: React.FC<SummarizationModelProps> = ({
/>{' '}
</>
}
labelAppend={
<EuiToolTip
delay="long"
content={i18n.translate(
'xpack.searchPlayground.sidebar.summarizationModel.manageConnectorTooltip',
{
defaultMessage: 'Manage',
}
)}
>
<EuiButtonIcon
target="_blank"
href={managementLink}
data-test-subj="manageConnectorsLink"
iconType="wrench"
aria-label={i18n.translate(
'xpack.searchPlayground.sidebar.summarizationModel.manageConnectorLink',
{
defaultMessage: 'Manage connector',
}
)}
/>
</EuiToolTip>
}
fullWidth
>
<EuiSuperSelect
data-test-subj="summarizationModelSelect"
options={modelsOption}
valueOfSelected={getOptionValue(selectedModel)}
valueOfSelected={selectedModel && getOptionValue(selectedModel)}
onChange={onChange}
fullWidth
/>
</EuiFormRow>
);

View file

@ -8,6 +8,7 @@
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { EuiPanel } from '@elastic/eui';
import { useLLMsModels } from '../../hooks/use_llms_models';
import { IncludeCitationsField } from './include_citations_field';
import { InstructionsField } from './instructions_field';
@ -17,13 +18,11 @@ import { SummarizationModel } from './summarization_model';
export const SummarizationPanel: React.FC = () => {
const { control } = useFormContext<ChatForm>();
const models = useLLMsModels();
const defaultModel = models.find((model) => !model.disabled);
return (
<>
<EuiPanel data-test-subj="summarizationPanel">
<Controller
name={ChatFormFields.summarizationModel}
defaultValue={defaultModel}
rules={{ required: true }}
control={control}
render={({ field }) => (
@ -51,6 +50,6 @@ export const SummarizationPanel: React.FC = () => {
<IncludeCitationsField checked={field.value} onChange={field.onChange} />
)}
/>
</>
</EuiPanel>
);
};

View file

@ -7,15 +7,13 @@
import { EuiFlexGroup } from '@elastic/eui';
import React from 'react';
import { DataActionButton } from './data_action_button';
import { ViewCodeAction } from './view_code/view_code_action';
import { ViewQueryAction } from './view_query/view_query_action';
import { EditContextAction } from './edit_context/edit_context_action';
export const Toolbar: React.FC = () => {
return (
<EuiFlexGroup gutterSize="s" data-test-subj="playground-header-actions">
<EditContextAction />
<ViewQueryAction />
<EuiFlexGroup gutterSize="s" alignItems="center" data-test-subj="playground-header-actions">
<DataActionButton />
<ViewCodeAction />
</EuiFlexGroup>
);

View file

@ -27,6 +27,7 @@ export const ViewCodeAction: React.FC = () => {
onClick={() => setShowFlyout(true)}
disabled={!selectedIndices || selectedIndices?.length === 0}
data-test-subj="viewCodeActionButton"
size="s"
>
<FormattedMessage
id="xpack.searchPlayground.viewCode.actionButtonLabel"

View file

@ -1,35 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { EuiButtonEmpty } from '@elastic/eui';
import { useFormContext } from 'react-hook-form';
import { FormattedMessage } from '@kbn/i18n-react';
import { ViewQueryFlyout } from './view_query_flyout';
import { ChatForm, ChatFormFields } from '../../types';
export const ViewQueryAction: React.FC = () => {
const [showFlyout, setShowFlyout] = useState(false);
const { watch } = useFormContext<ChatForm>();
const selectedIndices: string[] = watch(ChatFormFields.indices);
return (
<>
{showFlyout && <ViewQueryFlyout onClose={() => setShowFlyout(false)} />}
<EuiButtonEmpty
onClick={() => setShowFlyout(true)}
disabled={selectedIndices?.length === 0}
data-test-subj="viewQueryActionButton"
>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.actionButtonLabel"
defaultMessage="View query"
/>
</EuiButtonEmpty>
</>
);
};

View file

@ -1,302 +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 {
EuiAccordion,
EuiSelectable,
EuiButton,
EuiButtonEmpty,
EuiCodeBlock,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiPanel,
EuiSpacer,
EuiSelectableOption,
EuiText,
EuiTitle,
EuiCheckbox,
EuiLink,
EuiIcon,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useEffect, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useController } from 'react-hook-form';
import { AnalyticsEvents } from '../../analytics/constants';
import { docLinks } from '../../../common/doc_links';
import { useIndicesFields } from '../../hooks/use_indices_fields';
import { useUsageTracker } from '../../hooks/use_usage_tracker';
import { ChatForm, ChatFormFields, IndicesQuerySourceFields } from '../../types';
import { createQuery, getDefaultQueryFields, IndexFields } from '../../utils/create_query';
const groupTypeQueryFields = (
fields: IndicesQuerySourceFields,
queryFields: IndexFields
): string[] =>
Object.entries(queryFields).map(([index, selectedFields]) => {
const indexFields = fields[index];
let typeQueryFields = '';
if (selectedFields.some((field) => indexFields.bm25_query_fields.includes(field))) {
typeQueryFields = 'BM25';
}
if (
selectedFields.some((field) =>
indexFields.dense_vector_query_fields.find((vectorField) => vectorField.field === field)
)
) {
typeQueryFields += (typeQueryFields ? '_' : '') + 'DENSE';
}
if (
selectedFields.some((field) =>
indexFields.elser_query_fields.find((elserField) => elserField.field === field)
)
) {
typeQueryFields += (typeQueryFields ? '_' : '') + 'SPARSE';
}
if (
selectedFields.some((field) => indexFields.semantic_fields.find((f) => f.field === field))
) {
typeQueryFields += (typeQueryFields ? '_' : '') + 'SEMANTIC';
}
return typeQueryFields;
});
interface ViewQueryFlyoutProps {
onClose: () => void;
}
export const ViewQueryFlyout: React.FC<ViewQueryFlyoutProps> = ({ onClose }) => {
const usageTracker = useUsageTracker();
const { getValues } = useFormContext<ChatForm>();
const selectedIndices: string[] = getValues(ChatFormFields.indices);
const sourceFields = getValues(ChatFormFields.sourceFields);
const { fields } = useIndicesFields(selectedIndices);
const defaultFields = getDefaultQueryFields(fields);
const {
field: { onChange: queryFieldsOnChange, value: queryFields },
} = useController({
name: ChatFormFields.queryFields,
defaultValue: defaultFields,
});
const [tempQueryFields, setTempQueryFields] = useState<IndexFields>(queryFields);
const {
field: { onChange: elasticsearchQueryChange },
} = useController({
name: ChatFormFields.elasticsearchQuery,
});
const isQueryFieldSelected = (index: string, field: string) => {
return tempQueryFields[index].includes(field);
};
const updateFields = (index: string, options: EuiSelectableOption[]) => {
const newFields = options
.filter((option) => option.checked === 'on')
.map((option) => option.label);
setTempQueryFields({
...tempQueryFields,
[index]: newFields,
});
usageTracker?.count(AnalyticsEvents.viewQueryFieldsUpdated, newFields.length);
};
const saveQuery = () => {
queryFieldsOnChange(tempQueryFields);
elasticsearchQueryChange(createQuery(tempQueryFields, sourceFields, fields));
onClose();
const groupedQueryFields = groupTypeQueryFields(fields, tempQueryFields);
groupedQueryFields.forEach((typeQueryFields) =>
usageTracker?.click(`${AnalyticsEvents.viewQuerySaved}_${typeQueryFields}`)
);
};
useEffect(() => {
usageTracker?.load(AnalyticsEvents.viewQueryFlyoutOpened);
}, [usageTracker]);
return (
<EuiFlyout ownFocus onClose={onClose} size="l" data-test-subj="viewQueryFlyout">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.title"
defaultMessage="Customize your Elasticsearch query"
/>
</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText color="subdued">
<p>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.description"
defaultMessage="This query will be used to search your indices. Customize by choosing which
fields in your Elasticsearch documents to search."
/>
{` `}
<EuiLink
href={docLinks.retrievalOptimize}
target="_blank"
data-test-subj="query-optimize-documentation-link"
>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.learnMoreQueryOptimizeLink"
defaultMessage="Learn more."
/>
</EuiLink>
</p>
</EuiText>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiFlexGroup>
<EuiFlexItem grow={6}>
<EuiCodeBlock
language="json"
fontSize="m"
paddingSize="m"
lineNumbers
data-test-subj="ViewElasticsearchQueryResult"
>
{JSON.stringify(createQuery(tempQueryFields, sourceFields, fields), null, 2)}
</EuiCodeBlock>
</EuiFlexItem>
<EuiFlexItem grow={3}>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiText>
<h5>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.table.title"
defaultMessage="Fields to search (per index)"
/>
</h5>
</EuiText>
{Object.entries(fields).map(([index, group]) => (
<EuiFlexItem grow={false} key={index}>
<EuiPanel grow={false} hasShadow={false} hasBorder>
<EuiAccordion
id={index}
initialIsOpen
buttonContent={
<EuiText>
<h5>{index}</h5>
</EuiText>
}
>
<EuiSpacer size="s" />
<EuiSelectable
aria-label="Select query fields"
data-test-subj={`queryFieldsSelectable_${index}`}
options={[
...group.semantic_fields,
...group.elser_query_fields,
...group.dense_vector_query_fields,
...group.bm25_query_fields,
].map((field, idx) => {
const checked = isQueryFieldSelected(
index,
typeof field === 'string' ? field : field.field
);
return {
label: typeof field === 'string' ? field : field.field,
prepend: (
<EuiCheckbox
id={`checkbox_${idx}`}
checked={checked}
onChange={() => {}}
/>
),
checked: checked ? 'on' : undefined,
'data-test-subj': 'queryField',
};
})}
listProps={{
bordered: false,
showIcons: false,
}}
onChange={(newOptions) => updateFields(index, newOptions)}
>
{(list) => list}
</EuiSelectable>
{group.skipped_fields > 0 && (
<>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiText
size="s"
color="subdued"
data-test-subj={`skipped_fields_${index}`}
>
<EuiIcon type="eyeClosed" />
{` `}
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.hiddenFields"
defaultMessage="{skippedFields} fields are hidden."
values={{ skippedFields: group.skipped_fields }}
/>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink
href={docLinks.hiddenFields}
target="_blank"
data-test-subj="hidden-fields-documentation-link"
>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.learnMoreLink"
defaultMessage="Learn more."
/>
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
</EuiAccordion>
</EuiPanel>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={onClose} flush="left">
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.closeButton"
defaultMessage="Close"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton onClick={saveQuery} fill>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.saveButton"
defaultMessage="Save changes"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -9,16 +9,15 @@ import React from 'react';
import { dynamic } from '@kbn/shared-ux-utility';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { CoreStart } from '@kbn/core-lifecycle-browser';
import { QueryClientProvider } from '@tanstack/react-query';
import { AppPluginStartDependencies } from './types';
import { queryClient } from './utils/query_client';
import { AppProps } from './components/app';
export const Playground = dynamic(async () => ({
export const Playground = dynamic<React.FC<AppProps>>(async () => ({
default: (await import('./components/app')).App,
}));
export const PlaygroundToolbar = dynamic(async () => ({
default: (await import('./components/toolbar')).Toolbar,
}));
export const PlaygroundProvider = dynamic(async () => ({
default: (await import('./providers/playground_provider')).PlaygroundProvider,
}));
@ -32,6 +31,8 @@ export const getPlaygroundProvider =
(props: React.ComponentProps<typeof PlaygroundProvider>) =>
(
<KibanaContextProvider services={{ ...core, ...services }}>
<PlaygroundProvider {...props} />
<QueryClientProvider client={queryClient}>
<PlaygroundProvider {...props} />
</QueryClientProvider>
</KibanaContextProvider>
);

View file

@ -9,13 +9,15 @@ import { useQuery } from '@tanstack/react-query';
import { useKibana } from './use_kibana';
import { APIRoutes, IndicesQuerySourceFields } from '../types';
const initialData = {};
export const useIndicesFields = (indices: string[] = []) => {
const { services } = useKibana();
const { data, isLoading } = useQuery({
const { data, isLoading, isFetching } = useQuery<IndicesQuerySourceFields>({
enabled: indices.length > 0,
queryKey: ['fields', indices.toString()],
initialData: {},
initialData,
queryFn: async () => {
const response = await services.http.post<IndicesQuerySourceFields>(
APIRoutes.POST_QUERY_SOURCE_FIELDS,
@ -30,5 +32,5 @@ export const useIndicesFields = (indices: string[] = []) => {
},
});
return { fields: data!, isLoading };
return { fields: data, isLoading: isLoading || isFetching };
};

View file

@ -29,7 +29,8 @@ export const useQueryIndices = (
return response.indices;
},
initialData: [],
});
return { indices: data || [], isLoading };
return { indices: data, isLoading };
};

View file

@ -5,12 +5,11 @@
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import { useController, useFormContext } from 'react-hook-form';
import { useController } from 'react-hook-form';
import { IndexName } from '@elastic/elasticsearch/lib/api/types';
import { useEffect, useState } from 'react';
import { useKibana } from './use_kibana';
import { APIRoutes, IndicesQuerySourceFields } from '../types';
import { useCallback, useEffect, useState } from 'react';
import { merge } from 'lodash';
import { useIndicesFields } from './use_indices_fields';
import { ChatForm, ChatFormFields } from '../types';
import {
createQuery,
@ -36,10 +35,7 @@ export const getIndicesWithNoSourceFields = (
export const useSourceIndicesFields = () => {
const usageTracker = useUsageTracker();
const { services } = useKibana();
const [loading, setLoading] = useState<boolean>(false);
const [noFieldsIndicesWarning, setNoFieldsIndicesWarning] = useState<string | null>(null);
const { resetField } = useFormContext<ChatForm>();
const {
field: { value: selectedIndices, onChange: onIndicesChange },
@ -55,73 +51,74 @@ export const useSourceIndicesFields = () => {
});
const {
field: { onChange: onSourceFieldsChange },
field: { onChange: onQueryFieldsOnChange, value: queryFields },
} = useController<ChatForm, ChatFormFields.queryFields>({
name: ChatFormFields.queryFields,
});
const {
field: { onChange: onSourceFieldsChange, value: sourceFields },
} = useController({
name: ChatFormFields.sourceFields,
});
const { data: fields } = useQuery({
enabled: selectedIndices.length > 0,
queryKey: ['fields', selectedIndices.toString()],
queryFn: async () => {
const response = await services.http.post<IndicesQuerySourceFields>(
APIRoutes.POST_QUERY_SOURCE_FIELDS,
{
body: JSON.stringify({ indices: selectedIndices }),
}
);
return response;
},
});
const { fields, isLoading: isFieldsLoading } = useIndicesFields(selectedIndices);
useEffect(() => {
if (fields) {
resetField(ChatFormFields.queryFields);
const defaultFields = getDefaultQueryFields(fields);
const defaultSourceFields = getDefaultSourceFields(fields);
const mergedQueryFields = merge(defaultFields, queryFields);
const mergedSourceFields = merge(defaultSourceFields, sourceFields);
const indicesWithNoSourceFields = getIndicesWithNoSourceFields(defaultSourceFields);
onElasticsearchQueryChange(createQuery(mergedQueryFields, mergedSourceFields, fields));
onQueryFieldsOnChange(mergedQueryFields);
if (indicesWithNoSourceFields) {
setNoFieldsIndicesWarning(indicesWithNoSourceFields);
} else {
setNoFieldsIndicesWarning(null);
}
onElasticsearchQueryChange(createQuery(defaultFields, defaultSourceFields, fields));
onSourceFieldsChange(defaultSourceFields);
onSourceFieldsChange(mergedSourceFields);
usageTracker?.count(
AnalyticsEvents.sourceFieldsLoaded,
Object.values(fields)?.flat()?.length
);
} else {
setNoFieldsIndicesWarning(null);
}
setLoading(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fields]);
const addIndex = (newIndex: IndexName) => {
const newIndices = [...selectedIndices, newIndex];
setLoading(true);
onIndicesChange(newIndices);
usageTracker?.count(AnalyticsEvents.sourceIndexUpdated, newIndices.length);
};
const addIndex = useCallback(
(newIndex: IndexName) => {
const newIndices = [...selectedIndices, newIndex];
setLoading(true);
onIndicesChange(newIndices);
usageTracker?.count(AnalyticsEvents.sourceIndexUpdated, newIndices.length);
},
[onIndicesChange, selectedIndices, usageTracker]
);
const removeIndex = (index: IndexName) => {
const newIndices = selectedIndices.filter((indexName: string) => indexName !== index);
setLoading(true);
onIndicesChange(newIndices);
usageTracker?.count(AnalyticsEvents.sourceIndexUpdated, newIndices.length);
};
const removeIndex = useCallback(
(index: IndexName) => {
const newIndices = selectedIndices.filter((indexName: string) => indexName !== index);
setLoading(true);
onIndicesChange(newIndices);
usageTracker?.count(AnalyticsEvents.sourceIndexUpdated, newIndices.length);
},
[onIndicesChange, selectedIndices, usageTracker]
);
const setIndices = useCallback(
(indices: IndexName[]) => {
setLoading(true);
onIndicesChange(indices);
usageTracker?.count(AnalyticsEvents.sourceIndexUpdated, indices.length);
},
[onIndicesChange, usageTracker]
);
return {
indices: selectedIndices,
fields,
loading,
isFieldsLoading,
addIndex,
removeIndex,
noFieldsIndicesWarning,
setIndices,
};
};

View file

@ -165,7 +165,6 @@ describe.skip('useSourceIndicesFields Hook', () => {
expect(postMock).toHaveBeenCalled();
await act(async () => {
expect(result.current.noFieldsIndicesWarning).toEqual('missing_fields_index');
expect(result.current.loading).toBe(false);
expect(getValues()).toMatchInlineSnapshot(`
Object {
@ -219,7 +218,6 @@ describe.skip('useSourceIndicesFields Hook', () => {
expect(postMock).toHaveBeenCalled();
await act(async () => {
expect(result.current.noFieldsIndicesWarning).toBeNull();
expect(result.current.loading).toBe(false);
expect(getValues()).toMatchInlineSnapshot(`
Object {

View file

@ -15,7 +15,7 @@ import {
import { PLUGIN_ID, PLUGIN_NAME } from '../common';
import { docLinks } from '../common/doc_links';
import { PlaygroundHeaderDocs } from './components/playground_header_docs';
import { PlaygroundToolbar, Playground, getPlaygroundProvider } from './embeddable';
import { Playground, getPlaygroundProvider } from './embeddable';
import {
AppPluginStartDependencies,
SearchPlaygroundConfigType,
@ -60,7 +60,6 @@ export class SearchPlaygroundPlugin
docLinks.setDocLinks(core.docLinks.links);
return {
PlaygroundProvider: getPlaygroundProvider(core, deps),
PlaygroundToolbar,
Playground,
PlaygroundHeaderDocs,
};

View file

@ -5,32 +5,30 @@
* 2.0.
*/
import React, { FC, PropsWithChildren } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React, { FC, useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { ChatForm } from '../types';
import { useLLMsModels } from '../hooks/use_llms_models';
import { ChatForm, ChatFormFields } from '../types';
const queryClient = new QueryClient({});
export interface PlaygroundProviderProps {
children: React.ReactNode;
}
export const PlaygroundProvider: FC<PropsWithChildren<PlaygroundProviderProps>> = ({
children,
}) => {
export const PlaygroundProvider: FC = ({ children }) => {
const models = useLLMsModels();
const form = useForm<ChatForm>({
defaultValues: {
prompt: 'You are an assistant for question-answering tasks.',
doc_size: 3,
source_fields: {},
indices: [],
summarization_model: {},
},
});
return (
<QueryClientProvider client={queryClient}>
<FormProvider {...form}>{children}</FormProvider>
</QueryClientProvider>
);
useEffect(() => {
const defaultModel = models.find((model) => !model.disabled);
if (defaultModel) {
form.setValue(ChatFormFields.summarizationModel, defaultModel);
}
}, [form, models]);
return <FormProvider {...form}>{children}</FormProvider>;
};

View file

@ -25,7 +25,6 @@ import type { ConsolePluginStart } from '@kbn/console-plugin/public';
import { ChatRequestData } from '../common/types';
import type { App } from './components/app';
import type { PlaygroundProvider as PlaygroundProviderComponent } from './providers/playground_provider';
import type { Toolbar } from './components/toolbar';
import { PlaygroundHeaderDocs } from './components/playground_header_docs';
export * from '../common/types';
@ -34,7 +33,6 @@ export * from '../common/types';
export interface SearchPlaygroundPluginSetup {}
export interface SearchPlaygroundPluginStart {
PlaygroundProvider: React.FC<React.ComponentProps<typeof PlaygroundProviderComponent>>;
PlaygroundToolbar: React.FC<React.ComponentProps<typeof Toolbar>>;
Playground: React.FC<React.ComponentProps<typeof App>>;
PlaygroundHeaderDocs: React.FC<React.ComponentProps<typeof PlaygroundHeaderDocs>>;
}

View file

@ -55,6 +55,9 @@ export function createQuery(
const indices = Object.keys(fieldDescriptors);
const boolMatches = Object.keys(fields).reduce<Matches>(
(acc, index) => {
if (!fieldDescriptors[index]) {
return acc;
}
const indexFields: string[] = fields[index];
const indexFieldDescriptors: QuerySourceFields = fieldDescriptors[index];
@ -320,6 +323,21 @@ export function getDefaultSourceFields(fieldDescriptors: IndicesQuerySourceField
return indexFields;
}
export const getIndicesWithNoSourceFields = (
fields: IndicesQuerySourceFields
): string | undefined => {
const defaultSourceFields = getDefaultSourceFields(fields);
const indices = Object.keys(defaultSourceFields).reduce<string[]>((result, index: string) => {
if (defaultSourceFields[index].length === 0) {
result.push(index);
}
return result;
}, []);
return indices.length === 0 ? undefined : indices.join();
};
export function getDefaultQueryFields(fieldDescriptors: IndicesQuerySourceFields): IndexFields {
const indexFields = Object.keys(fieldDescriptors).reduce<IndexFields>(
(acc: IndexFields, index: string) => {

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({});

View file

@ -15,7 +15,6 @@ import {
LlmProxy,
} from '../../../observability_ai_assistant_api_integration/common/create_llm_proxy';
const indexName = 'basic_index';
const esArchiveIndex = 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index';
export default function (ftrContext: FtrProviderContext) {
@ -56,6 +55,7 @@ export default function (ftrContext: FtrProviderContext) {
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectPlaygroundHeaderComponentsToExist();
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectPlaygroundHeaderComponentsToDisabled();
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectPlaygroundStartChatPageComponentsToExist();
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectPlaygroundStartChatPageIndexButtonExists();
});
describe('with gen ai connectors', () => {
@ -68,8 +68,8 @@ export default function (ftrContext: FtrProviderContext) {
await removeOpenAIConnector?.();
await browser.refresh();
});
it('hide gen ai panel', async () => {
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectHideGenAIPanelConnector();
it('show success llm button', async () => {
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectShowSuccessLLMButton();
});
});
@ -80,7 +80,7 @@ export default function (ftrContext: FtrProviderContext) {
it('creates a connector successfully', async () => {
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectOpenConnectorPagePlayground();
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectHideGenAIPanelConnectorAfterCreatingConnector(
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectSuccessButtonAfterCreatingConnector(
createConnector
);
});
@ -92,18 +92,16 @@ export default function (ftrContext: FtrProviderContext) {
});
describe('without any indices', () => {
it('show no index callout', async () => {
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectNoIndexCalloutExists();
it('show create index button', async () => {
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectCreateIndexButtonToExists();
});
it('hide no index callout when index added', async () => {
it('show success button when index added', async () => {
await createIndex();
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectSelectIndex(indexName);
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectOpenFlyoutAndSelectIndex();
});
after(async () => {
await pageObjects.searchPlayground.PlaygroundStartChatPage.removeIndexFromComboBox();
await esArchiver.unload(esArchiveIndex);
await browser.refresh();
});
@ -116,14 +114,8 @@ export default function (ftrContext: FtrProviderContext) {
await browser.refresh();
});
it('dropdown shows up', async () => {
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectIndicesInDropdown();
});
it('can select index from dropdown and navigate to chat window', async () => {
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectToSelectIndicesAndStartButtonEnabled(
indexName
);
it('can select index from dropdown and load chat page', async () => {
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectToSelectIndicesAndLoadChat();
});
after(async () => {
@ -139,7 +131,7 @@ export default function (ftrContext: FtrProviderContext) {
await createConnector();
await createIndex();
await browser.refresh();
await pageObjects.searchPlayground.PlaygroundChatPage.navigateToChatPage(indexName);
await pageObjects.searchPlayground.PlaygroundChatPage.navigateToChatPage();
});
it('loads successfully', async () => {
await pageObjects.searchPlayground.PlaygroundChatPage.expectChatWindowLoaded();

View file

@ -10,16 +10,28 @@ import { FtrProviderContext } from '../ftr_provider_context';
export function SearchPlaygroundPageProvider({ getService }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const comboBox = getService('comboBox');
const browser = getService('browser');
const selectIndex = async () => {
await testSubjects.existOrFail('addDataSourcesButton');
await testSubjects.click('addDataSourcesButton');
await testSubjects.existOrFail('selectIndicesFlyout');
await testSubjects.click('sourceIndex-0');
await testSubjects.click('saveButton');
};
return {
PlaygroundStartChatPage: {
async expectPlaygroundStartChatPageComponentsToExist() {
await testSubjects.existOrFail('startChatPage');
await testSubjects.existOrFail('connectToLLMChatPanel');
await testSubjects.existOrFail('selectIndicesChatPanel');
await testSubjects.existOrFail('startChatButton');
await testSubjects.existOrFail('setupPage');
await testSubjects.existOrFail('connectLLMButton');
},
async expectPlaygroundStartChatPageIndexButtonExists() {
await testSubjects.existOrFail('createIndexButton');
},
async expectPlaygroundStartChatPageIndexCalloutExists() {
await testSubjects.existOrFail('createIndexCallout');
},
async expectPlaygroundHeaderComponentsToExist() {
@ -28,8 +40,8 @@ export function SearchPlaygroundPageProvider({ getService }: FtrProviderContext)
},
async expectPlaygroundHeaderComponentsToDisabled() {
expect(await testSubjects.isEnabled('editContextActionButton')).to.be(false);
expect(await testSubjects.isEnabled('viewQueryActionButton')).to.be(false);
expect(await testSubjects.getAttribute('viewModeSelector', 'disabled')).to.be('true');
expect(await testSubjects.isEnabled('dataSourceActionButton')).to.be(false);
expect(await testSubjects.isEnabled('viewCodeActionButton')).to.be(false);
},
@ -37,66 +49,45 @@ export function SearchPlaygroundPageProvider({ getService }: FtrProviderContext)
await testSubjects.existOrFail('createIndexButton');
},
async expectNoIndexCalloutExists() {
await testSubjects.existOrFail('createIndexCallout');
},
async expectSelectIndex(indexName: string) {
async expectOpenFlyoutAndSelectIndex() {
await browser.refresh();
await testSubjects.missingOrFail('createIndexCallout');
await testSubjects.existOrFail('selectIndicesComboBox');
await comboBox.setCustom('selectIndicesComboBox', indexName);
await selectIndex();
await testSubjects.existOrFail('dataSourcesSuccessButton');
},
async expectIndicesInDropdown() {
await testSubjects.existOrFail('selectIndicesComboBox');
},
async removeIndexFromComboBox() {
await testSubjects.click('removeIndexButton');
},
async expectToSelectIndicesAndStartButtonEnabled(indexName: string) {
await comboBox.setCustom('selectIndicesComboBox', indexName);
expect(await testSubjects.isEnabled('startChatButton')).to.be(true);
expect(await testSubjects.isEnabled('editContextActionButton')).to.be(true);
expect(await testSubjects.isEnabled('viewQueryActionButton')).to.be(true);
expect(await testSubjects.isEnabled('viewCodeActionButton')).to.be(true);
await testSubjects.click('startChatButton');
async expectToSelectIndicesAndLoadChat() {
await selectIndex();
await testSubjects.existOrFail('chatPage');
},
async expectAddConnectorButtonExists() {
await testSubjects.existOrFail('setupGenAIConnectorButton');
await testSubjects.existOrFail('connectLLMButton');
},
async expectOpenConnectorPagePlayground() {
await testSubjects.click('setupGenAIConnectorButton');
await testSubjects.click('connectLLMButton');
await testSubjects.existOrFail('create-connector-flyout');
},
async expectHideGenAIPanelConnectorAfterCreatingConnector(
createConnector: () => Promise<void>
) {
async expectSuccessButtonAfterCreatingConnector(createConnector: () => Promise<void>) {
await createConnector();
await browser.refresh();
await testSubjects.missingOrFail('connectToLLMChatPanel');
await testSubjects.existOrFail('successConnectLLMButton');
},
async expectHideGenAIPanelConnector() {
await testSubjects.missingOrFail('connectToLLMChatPanel');
async expectShowSuccessLLMButton() {
await testSubjects.existOrFail('successConnectLLMButton');
},
},
PlaygroundChatPage: {
async navigateToChatPage(indexName: string) {
await comboBox.setCustom('selectIndicesComboBox', indexName);
await testSubjects.click('startChatButton');
async navigateToChatPage() {
await selectIndex();
await testSubjects.existOrFail('chatPage');
},
async expectChatWindowLoaded() {
expect(await testSubjects.isEnabled('editContextActionButton')).to.be(true);
expect(await testSubjects.isEnabled('viewQueryActionButton')).to.be(true);
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);
@ -114,9 +105,8 @@ export function SearchPlaygroundPageProvider({ getService }: FtrProviderContext)
await (await testSubjects.find('manageConnectorsLink')).getAttribute('href')
).to.contain('/app/management/insightsAndAlerting/triggersActionsConnectors/connectors/');
await testSubjects.click('sourcesAccordion');
expect(await testSubjects.findAll('indicesInAccordian')).to.have.length(1);
await testSubjects.existOrFail('editContextPanel');
await testSubjects.existOrFail('summarizationPanel');
},
async sendQuestion() {
@ -145,9 +135,9 @@ export function SearchPlaygroundPageProvider({ getService }: FtrProviderContext)
},
async expectViewQueryHasFields() {
await testSubjects.click('viewQueryActionButton');
await testSubjects.existOrFail('viewQueryFlyout');
const fields = await testSubjects.findAll('queryField');
await testSubjects.existOrFail('queryMode');
await testSubjects.click('queryMode');
const fields = await testSubjects.findAll('fieldName');
expect(fields.length).to.be(1);
@ -156,18 +146,16 @@ export function SearchPlaygroundPageProvider({ getService }: FtrProviderContext)
expect(code.replace(/ /g, '')).to.be(
'{\n"retriever":{\n"standard":{\n"query":{\n"multi_match":{\n"query":"{query}",\n"fields":[\n"baz"\n]\n}\n}\n}\n}\n}'
);
await testSubjects.click('euiFlyoutCloseButton');
},
async expectEditContextOpens() {
await testSubjects.click('editContextActionButton');
await testSubjects.existOrFail('editContextFlyout');
await testSubjects.click('contextFieldsSelectable_basic_index');
await testSubjects.click('chatMode');
await testSubjects.existOrFail('contextFieldsSelectable-0');
await testSubjects.click('contextFieldsSelectable-0');
await testSubjects.existOrFail('contextField');
const fields = await testSubjects.findAll('contextField');
expect(fields.length).to.be(1);
await testSubjects.click('euiFlyoutCloseButton');
},
},
};

View file

@ -12,7 +12,6 @@ import { RoleCredentials } from '../../../../shared/services';
import { createOpenAIConnector } from './utils/create_openai_connector';
import { createLlmProxy, LlmProxy } from './utils/create_llm_proxy';
const indexName = 'basic_index';
const esArchiveIndex = 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index';
export default function ({ getPageObjects, getService }: FtrProviderContext) {
@ -66,6 +65,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectPlaygroundHeaderComponentsToExist();
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectPlaygroundHeaderComponentsToDisabled();
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectPlaygroundStartChatPageComponentsToExist();
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectPlaygroundStartChatPageIndexCalloutExists();
});
describe('with gen ai connectors', () => {
@ -78,8 +78,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await removeOpenAIConnector?.();
await browser.refresh();
});
it('hide gen ai panel', async () => {
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectHideGenAIPanelConnector();
it('show success llm button', async () => {
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectShowSuccessLLMButton();
});
});
@ -90,7 +90,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
it('creates a connector successfully', async () => {
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectOpenConnectorPagePlayground();
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectHideGenAIPanelConnectorAfterCreatingConnector(
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectSuccessButtonAfterCreatingConnector(
createConnector
);
});
@ -102,17 +102,12 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
});
describe('without any indices', () => {
it('show no index callout', async () => {
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectNoIndexCalloutExists();
});
it('hide no index callout when index added', async () => {
await createIndex();
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectSelectIndex(indexName);
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectOpenFlyoutAndSelectIndex();
});
after(async () => {
await pageObjects.searchPlayground.PlaygroundStartChatPage.removeIndexFromComboBox();
await esArchiver.unload(esArchiveIndex);
await browser.refresh();
});
@ -125,14 +120,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await browser.refresh();
});
it('dropdown shows up', async () => {
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectIndicesInDropdown();
});
it('can select index from dropdown and navigate to chat window', async () => {
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectToSelectIndicesAndStartButtonEnabled(
indexName
);
it('can select index from dropdown and load chat page', async () => {
await pageObjects.searchPlayground.PlaygroundStartChatPage.expectToSelectIndicesAndLoadChat();
});
after(async () => {
@ -148,7 +137,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await createConnector();
await createIndex();
await browser.refresh();
await pageObjects.searchPlayground.PlaygroundChatPage.navigateToChatPage(indexName);
await pageObjects.searchPlayground.PlaygroundChatPage.navigateToChatPage();
});
it('loads successfully', async () => {
await pageObjects.searchPlayground.PlaygroundChatPage.expectChatWindowLoaded();