mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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`

### 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:
parent
bbe704dfa9
commit
89321dc0ab
10 changed files with 249 additions and 91 deletions
|
@ -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',
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
})
|
||||
);
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue