[Playground] Handle Error Message (#180857)

## Summary

This PR includes
- Returns error message as `bad request`
- Display the error message as chat messages
- Disable question box and `send` button when `regenerating` responses.
- Invalid form if prompt is empty
- Fix an issue with `no source fields found`

![Screenshot 2024-04-15 at 6 03
23 PM](05c0473b-a8fb-4a0c-bc5c-a5e08cd29143)


### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)



### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Samiul Monir 2024-04-16 17:22:35 -04:00 committed by GitHub
parent bbe704dfa9
commit 89321dc0ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 249 additions and 91 deletions

View file

@ -56,9 +56,10 @@ export const Chat = () => {
handleSubmit,
getValues,
} = useFormContext<ChatForm>();
const { messages, append, stop: stopRequest, setMessages, reload } = useChat();
const { messages, append, stop: stopRequest, setMessages, reload, error } = useChat();
const selectedIndicesCount = watch(ChatFormFields.indices, []).length;
const messagesRef = useAutoBottomScroll([showStartPage]);
const [isRegenerating, setIsRegenerating] = useState<boolean>(false);
const onSubmit = async (data: ChatForm) => {
await append(
@ -82,11 +83,18 @@ export const Chat = () => {
[messages]
);
const regenerateMessages = () => {
const isToolBarActionsDisabled = useMemo(
() => chatMessages.length <= 1 || !!error || isRegenerating || isSubmitting,
[chatMessages, error, isSubmitting, isRegenerating]
);
const regenerateMessages = async () => {
setIsRegenerating(true);
const formData = getValues();
reload({
await reload({
data: buildFormData(formData),
});
setIsRegenerating(false);
};
if (showStartPage) {
@ -135,7 +143,7 @@ export const Chat = () => {
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="sparkles"
disabled={chatMessages.length <= 1}
disabled={isToolBarActionsDisabled}
onClick={regenerateMessages}
>
<FormattedMessage
@ -147,7 +155,7 @@ export const Chat = () => {
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="refresh"
disabled={chatMessages.length <= 1}
disabled={isToolBarActionsDisabled}
onClick={() => {
setMessages([]);
}}
@ -174,9 +182,9 @@ export const Chat = () => {
<QuestionInput
value={field.value}
onChange={field.onChange}
isDisabled={isSubmitting}
isDisabled={isSubmitting || isRegenerating}
button={
isSubmitting ? (
isSubmitting || isRegenerating ? (
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.searchPlayground.chat.stopButtonAriaLabel',

View file

@ -0,0 +1,104 @@
/*
* 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 { render as testingLibraryRender, screen } from '@testing-library/react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { SourcesPanelForStartChat } from './sources_panel_for_start_chat';
import { useSourceIndicesField } from '../../hooks/use_source_indices_field';
import { getDefaultSourceFields } from '../../utils/create_query';
const render = (children: React.ReactNode) =>
testingLibraryRender(<IntlProvider locale="en">{children}</IntlProvider>);
jest.mock('./create_index_callout', () => ({ CreateIndexCallout: () => 'mocked component' }));
jest.mock('../../hooks/use_source_indices_field');
jest.mock('../../utils/create_query');
jest.mock('../../hooks/use_query_indices', () => {
return {
useQueryIndices: () => {
return {
indices: [],
isLoading: false,
};
},
};
});
jest.mock('../../hooks/use_indices_fields', () => {
return {
useIndicesFields: () => {
return {
fields: {},
isLoading: false,
};
},
};
});
jest.mock('react-hook-form', () => {
return {
useController: () => {
return {
field: { onChange: jest.fn() },
};
},
};
});
const mockUseSourceIndicesField = useSourceIndicesField as jest.Mock;
const mockGetDefaultSourceFields = getDefaultSourceFields as jest.Mock;
describe('SourcesPanelForStartChat', () => {
describe('renders sources', () => {
beforeEach(() => {
mockUseSourceIndicesField.mockReturnValue({
selectedIndices: [],
addIndex: jest.fn(),
removeIndex: jest.fn(),
});
mockGetDefaultSourceFields.mockReturnValue({});
});
test('renders Sources', () => {
render(<SourcesPanelForStartChat />);
expect(screen.getByText(/Select Sources/i)).toBeInTheDocument();
});
});
describe('with default index', () => {
beforeEach(() => {
mockUseSourceIndicesField.mockReturnValue({
selectedIndices: ['index-1'],
addIndex: jest.fn(),
removeIndex: jest.fn(),
});
mockGetDefaultSourceFields.mockReturnValue({
'index-1': ['text'],
});
});
test('renders Sources', () => {
render(<SourcesPanelForStartChat />);
expect(screen.getByText(/Select Sources/i)).toBeInTheDocument();
});
test('renders indices table', () => {
render(<SourcesPanelForStartChat />);
expect(screen.getByText(/index-1/i)).toBeInTheDocument();
});
});
describe('no source fields', () => {
beforeEach(() => {
mockGetDefaultSourceFields.mockReturnValue({
'index-1': [undefined],
});
});
test('renders warning callout', () => {
render(<SourcesPanelForStartChat />);
expect(screen.getByText(/No source fields found for index-1/i)).toBeInTheDocument();
});
});
});

View file

@ -5,8 +5,8 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import React, { useEffect } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiCallOut } from '@elastic/eui';
import React, { useState, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { useController } from 'react-hook-form';
import { AddIndicesField } from './add_indices_field';
@ -21,12 +21,25 @@ import {
createQuery,
getDefaultQueryFields,
getDefaultSourceFields,
IndexFields,
} from '../../utils/create_query';
const transformToErrorMessage = (defaultSourceFields: IndexFields): string | undefined => {
const indices: string[] = [];
Object.keys(defaultSourceFields).forEach((index: string) => {
if (defaultSourceFields[index][0] === undefined) {
indices.push(index);
}
});
return indices.length === 0 ? undefined : indices.join();
};
export const SourcesPanelForStartChat: React.FC = () => {
const { selectedIndices, removeIndex, addIndex } = useSourceIndicesField();
const { indices, isLoading } = useQueryIndices();
const { fields } = useIndicesFields(selectedIndices || []);
const [sourceFieldErrorMessage, setSourceFieldErrorMessage] = useState<string>();
const {
field: { onChange: elasticsearchQueryOnChange },
@ -45,9 +58,10 @@ export const SourcesPanelForStartChat: React.FC = () => {
useEffect(() => {
if (fields) {
const defaultFields = getDefaultQueryFields(fields);
const defaultSourceFields = getDefaultSourceFields(fields);
elasticsearchQueryOnChange(createQuery(defaultFields, fields));
const defaultSourceFields = getDefaultSourceFields(fields);
sourceFieldsOnChange(defaultSourceFields);
setSourceFieldErrorMessage(transformToErrorMessage(defaultSourceFields));
}
}, [fields, elasticsearchQueryOnChange, sourceFieldsOnChange]);
@ -67,6 +81,19 @@ export const SourcesPanelForStartChat: React.FC = () => {
</EuiFlexItem>
)}
{sourceFieldErrorMessage && (
<EuiCallOut color="warning" iconType="warning">
<p>
{i18n.translate('xpack.searchPlayground.emptyPrompts.sources.warningCallout', {
defaultMessage: 'No source fields found for {errorMessage}',
values: {
errorMessage: sourceFieldErrorMessage,
},
})}
</p>
</EuiCallOut>
)}
{isLoading && (
<EuiFlexGroup justifyContent="center" alignItems="center">
<EuiLoadingSpinner size="l" />

View file

@ -9,6 +9,7 @@ import React from 'react';
import { EuiFormRow, EuiIcon, EuiTextArea, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isEmpty } from 'lodash';
interface InstructionsFieldProps {
value?: string;
@ -49,6 +50,7 @@ export const InstructionsField: React.FC<InstructionsFieldProps> = ({ value, onC
value={value}
onChange={handlePromptChange}
fullWidth
isInvalid={isEmpty(value)}
/>
</EuiFormRow>
);

View file

@ -52,8 +52,15 @@ const getStreamedResponse = async (
appendMessage(message) {
mutate([...chatRequest.messages, message], false);
},
handleFailure() {
mutate(previousMessages, false);
handleFailure(errorMessage) {
const systemErrorMessage = {
id: uuidv4(),
content: errorMessage,
role: MessageRole.system,
createdAt: new Date(),
};
// concating the last question and error message with existing chat history
mutate([...previousMessages, chatRequest.messages.slice(-1)[0], systemErrorMessage], false);
},
onUpdate(merged) {
mutate([...chatRequest.messages, ...merged], false);

View file

@ -24,7 +24,7 @@ export async function fetchApi({
headers?: HeadersInit;
appendMessage: (message: Message) => void;
abortController?: () => AbortController | null;
handleFailure: () => void;
handleFailure: (error: string) => void;
onUpdate: (mergedMessages: Message[]) => void;
}) {
const requestInit = {
@ -42,16 +42,16 @@ export async function fetchApi({
const apiRequest = typeof api === 'string' ? fetch(api, requestInit) : api(requestInit);
const apiResponse = await apiRequest.catch((error) => {
handleFailure();
throw error;
const apiResponse = await apiRequest.catch(async (error) => {
let errorMessage = 'Failed to fetch the chat messages';
if (error.response) {
errorMessage =
(await error.response?.json())?.message ?? 'Failed to fetch the chat response.';
}
handleFailure(errorMessage);
throw new Error(errorMessage);
});
if (!apiResponse.ok) {
handleFailure();
throw new Error((await apiResponse.text()) || 'Failed to fetch the chat response.');
}
if (!apiResponse.body) {
throw new Error('The response body is empty.');
}

View file

@ -667,7 +667,7 @@ describe('create_query', () => {
});
});
it('should return an error when no source fields', () => {
it('should return undefined with index name when no source fields found', () => {
const fieldDescriptors: IndicesQuerySourceFields = {
'search-search-labs': {
elser_query_fields: [],
@ -677,7 +677,11 @@ describe('create_query', () => {
},
};
expect(() => getDefaultSourceFields(fieldDescriptors)).toThrowError('No source fields found');
const defaultSourceFields = getDefaultSourceFields(fieldDescriptors);
expect(defaultSourceFields).toEqual({
'search-search-labs': [undefined],
});
});
it('should return the first single field when no source fields', () => {

View file

@ -7,7 +7,7 @@
import { IndicesQuerySourceFields, QuerySourceFields } from '../types';
type IndexFields = Record<string, string[]>;
export type IndexFields = Record<string, string[]>;
// These fields are used to suggest the fields to use for the query
// If the field is not found in the suggested fields,
@ -216,10 +216,6 @@ export function getDefaultSourceFields(fieldDescriptors: IndicesQuerySourceField
(acc: IndexFields, index: string) => {
const indexFieldDescriptors = fieldDescriptors[index];
if (indexFieldDescriptors.source_fields.length === 0) {
throw new Error('No source fields found');
}
const suggested = indexFieldDescriptors.source_fields.filter((x) =>
SUGGESTED_SOURCE_FIELDS.includes(x)
);

View file

@ -14,7 +14,7 @@ export const transformFromChatMessages = (messages: UseChatHelpers['messages']):
id,
content,
createdAt,
role: role === MessageRole.assistant ? MessageRole.assistant : MessageRole.user,
role,
};
if (role === MessageRole.assistant) {

View file

@ -90,76 +90,86 @@ export function defineRoutes({
errorHandler(async (context, request, response) => {
const [, { actions }] = await getStartServices();
const { client } = (await context.core).elasticsearch;
const aiClient = Assist({
es_client: client.asCurrentUser,
} as AssistClientOptionsWithClient);
const { messages, data } = await request.body;
const abortController = new AbortController();
const abortSignal = abortController.signal;
const model = new ActionsClientChatOpenAI({
actions,
logger: log,
request,
connectorId: data.connector_id,
model: data.summarization_model,
traceId: uuidv4(),
signal: abortSignal,
// prevents the agent from retrying on failure
// failure could be due to bad connector, we should deliver that result to the client asap
maxRetries: 0,
});
let sourceFields = {};
try {
const aiClient = Assist({
es_client: client.asCurrentUser,
} as AssistClientOptionsWithClient);
const { messages, data } = await request.body;
const abortController = new AbortController();
const abortSignal = abortController.signal;
const model = new ActionsClientChatOpenAI({
actions,
logger: log,
request,
connectorId: data.connector_id,
model: data.summarization_model,
traceId: uuidv4(),
signal: abortSignal,
// prevents the agent from retrying on failure
// failure could be due to bad connector, we should deliver that result to the client asap
maxRetries: 0,
});
sourceFields = JSON.parse(data.source_fields);
} catch (e) {
log.error('Failed to parse the source fields', e);
throw Error(e);
}
let sourceFields = {};
const chain = ConversationalChain({
model,
rag: {
index: data.indices,
retriever: createRetriever(data.elasticsearch_query),
content_field: sourceFields,
size: Number(data.doc_size),
},
prompt: Prompt(data.prompt, {
citations: data.citations,
context: true,
type: 'openai',
}),
});
try {
sourceFields = JSON.parse(data.source_fields);
} catch (e) {
log.error('Failed to parse the source fields', e);
throw Error(e);
}
let stream: ReadableStream<Uint8Array>;
const chain = ConversationalChain({
model,
rag: {
index: data.indices,
retriever: createRetriever(data.elasticsearch_query),
content_field: sourceFields,
size: Number(data.doc_size),
},
prompt: Prompt(data.prompt, {
citations: data.citations,
context: true,
type: 'openai',
}),
});
const stream = await chain.stream(aiClient, messages);
const { end, push, responseWithHeaders } = streamFactory(request.headers, log);
const reader = (stream as ReadableStream).getReader();
const textDecoder = new TextDecoder();
async function pushStreamUpdate() {
reader.read().then(({ done, value }: { done: boolean; value?: Uint8Array }) => {
if (done) {
end();
return;
}
push(textDecoder.decode(value));
pushStreamUpdate();
});
}
pushStreamUpdate();
return response.ok(responseWithHeaders);
try {
stream = await chain.stream(aiClient, messages);
} catch (e) {
log.error('Failed to create the chat stream', e);
throw Error(e);
if (typeof e === 'string') {
return response.badRequest({
body: {
message: e,
},
});
}
throw e;
}
const { end, push, responseWithHeaders } = streamFactory(request.headers, log);
const reader = (stream as ReadableStream).getReader();
const textDecoder = new TextDecoder();
async function pushStreamUpdate() {
reader.read().then(({ done, value }: { done: boolean; value?: Uint8Array }) => {
if (done) {
end();
return;
}
push(textDecoder.decode(value));
pushStreamUpdate();
});
}
pushStreamUpdate();
return response.ok(responseWithHeaders);
})
);