[Obs ai assistant][ESQL] Visualizes a query (#174677)

## Summary


This PR

1. Adds a new CTA (Visualize query) on the generated ES|QL queries
<img width="965" alt="image"
src="3ec3176a-23e1-4329-9d27-a01c6ff8aa92">

2. Clicking the CTA, requests from Lens to suggest a chart based on the
given query
<img width="955" alt="image"
src="466da7d8-f6c4-4c46-9b51-a7fad5e31e55">


3. The embeddable has 2 actions:
- Edit the embeddable
- Save the embeddable on a dashboard

4. Editing the embeddable opens a push flyout where the user can

- Change the query
- Change the chart configuration (colors, dimensions etc)
- Click one of the chart suggestions


![ai_assistant](11fb6a55-60a6-491c-9540-060bebdfaa4a)

5. With clicking the apply button, the new chart configuration is saved
to the conversation

6. User can save the ES|QL chart on a dashboard. From there they can
continue editing the chart (we also display the same inline editing
flyout giving a seamless experience)

### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] 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))
- [x] 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))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Dario Gieselaar <dario.gieselaar@elastic.co>
Co-authored-by: Milton Hultgren <miltonhultgren@gmail.com>
Co-authored-by: Coen Warmer <coen.warmer@gmail.com>
This commit is contained in:
Stratoula Kalafateli 2024-02-05 17:55:13 +02:00 committed by GitHub
parent 6d5a485415
commit 25898e68bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
147 changed files with 1841 additions and 468 deletions

View file

@ -136,6 +136,7 @@
"visTypeXy": "src/plugins/vis_types/xy",
"visualizations": "src/plugins/visualizations",
"visualizationUiComponents": "packages/kbn-visualization-ui-components",
"visualizationUtils": "packages/kbn-visualization-utils",
"unifiedDocViewer": ["src/plugins/unified_doc_viewer", "packages/kbn-unified-doc-viewer"],
"unifiedSearch": "src/plugins/unified_search",
"unifiedFieldList": "packages/kbn-unified-field-list",

View file

@ -13,6 +13,7 @@ export {
getKbnFieldType,
getKbnTypeNames,
getFilterableKbnTypeNames,
esFieldTypeToKibanaFieldType,
} from './src/kbn_field_types';
export type { KbnFieldTypeOptions } from './src/types';

View file

@ -5,7 +5,6 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { createKbnFieldTypes, kbnFieldTypeUnknown } from './kbn_field_types_factory';
import { KbnFieldType } from './kbn_field_type';
import { ES_FIELD_TYPES, KBN_FIELD_TYPES } from './types';
@ -49,3 +48,18 @@ export const castEsToKbnFieldTypeName = (esType: ES_FIELD_TYPES | string): KBN_F
*/
export const getFilterableKbnTypeNames = (): string[] =>
registeredKbnTypes.filter((type) => type.filterable).map((type) => type.name);
export function esFieldTypeToKibanaFieldType(type: string) {
switch (type) {
case ES_FIELD_TYPES._INDEX:
case ES_FIELD_TYPES.GEO_POINT:
case ES_FIELD_TYPES.IP:
return KBN_FIELD_TYPES.STRING;
case '_version':
return KBN_FIELD_TYPES.NUMBER;
case 'datetime':
return KBN_FIELD_TYPES.DATE;
default:
return castEsToKbnFieldTypeName(type);
}
}

View file

@ -0,0 +1,134 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { TableChangeType } from '../src/types';
export const currentSuggestionMock = {
title: 'Heat map',
hide: false,
score: 0.6,
previewIcon: 'heatmap',
visualizationId: 'lnsHeatmap',
visualizationState: {
shape: 'heatmap',
layerId: '46aa21fa-b747-4543-bf90-0b40007c546d',
layerType: 'data',
legend: {
isVisible: true,
position: 'right',
type: 'heatmap_legend',
},
gridConfig: {
type: 'heatmap_grid',
isCellLabelVisible: false,
isYAxisLabelVisible: true,
isXAxisLabelVisible: true,
isYAxisTitleVisible: false,
isXAxisTitleVisible: false,
},
valueAccessor: '5b9b8b76-0836-4a12-b9c0-980c9900502f',
xAccessor: '81e332d6-ee37-42a8-a646-cea4fc75d2d3',
},
keptLayerIds: ['46aa21fa-b747-4543-bf90-0b40007c546d'],
datasourceState: {
layers: {
'46aa21fa-b747-4543-bf90-0b40007c546d': {
index: 'd3d7af60-4c81-11e8-b3d7-01146121b73d',
query: {
esql: 'FROM kibana_sample_data_flights | keep Dest, AvgTicketPrice',
},
columns: [
{
columnId: '81e332d6-ee37-42a8-a646-cea4fc75d2d3',
fieldName: 'Dest',
meta: {
type: 'string',
},
},
{
columnId: '5b9b8b76-0836-4a12-b9c0-980c9900502f',
fieldName: 'AvgTicketPrice',
meta: {
type: 'number',
},
},
],
timeField: 'timestamp',
},
},
indexPatternRefs: [],
initialContext: {
dataViewSpec: {
id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d',
version: 'WzM1ODA3LDFd',
title: 'kibana_sample_data_flights',
timeFieldName: 'timestamp',
sourceFilters: [],
fields: {
AvgTicketPrice: {
count: 0,
name: 'AvgTicketPrice',
type: 'number',
esTypes: ['float'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: {
id: 'number',
params: {
pattern: '$0,0.[00]',
},
},
shortDotsEnable: false,
isMapped: true,
},
Dest: {
count: 0,
name: 'Dest',
type: 'string',
esTypes: ['keyword'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: {
id: 'string',
},
shortDotsEnable: false,
isMapped: true,
},
timestamp: {
count: 0,
name: 'timestamp',
type: 'date',
esTypes: ['date'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: {
id: 'date',
},
shortDotsEnable: false,
isMapped: true,
},
},
allowNoIndex: false,
name: 'Kibana Sample Data Flights',
},
fieldName: '',
contextualFields: ['Dest', 'AvgTicketPrice'],
query: {
esql: 'FROM "kibana_sample_data_flights"',
},
},
},
datasourceId: 'textBased',
columns: 2,
changeType: 'initial' as TableChangeType,
};

View file

@ -7,4 +7,5 @@
*/
export { getTimeZone } from './src/get_timezone';
export { getLensAttributesFromSuggestion } from './src/get_lens_attributes';
export { TooltipWrapper } from './src/tooltip_wrapper';

View file

@ -0,0 +1,50 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { getLensAttributesFromSuggestion } from './get_lens_attributes';
import { AggregateQuery } from '@kbn/es-query';
import type { DataView } from '@kbn/data-views-plugin/public';
import { currentSuggestionMock } from '../__mocks__/suggestions_mock';
describe('getLensAttributesFromSuggestion', () => {
const dataView = {
id: `index-pattern-with-timefield-id`,
title: `index-pattern-with-timefield-title`,
fields: [],
getFieldByName: jest.fn(),
timeFieldName: '@timestamp',
isPersisted: () => false,
toSpec: () => ({}),
} as unknown as DataView;
const query: AggregateQuery = { esql: 'from foo | limit 10' };
it('should return correct attributes for given suggestion', () => {
const lensAttrs = getLensAttributesFromSuggestion({
filters: [],
query,
dataView,
suggestion: currentSuggestionMock,
});
expect(lensAttrs).toEqual({
state: expect.objectContaining({
adHocDataViews: {
'index-pattern-with-timefield-id': {},
},
}),
references: [
{
id: 'index-pattern-with-timefield-id',
name: 'textBasedLanguages-datasource-layer-suggestion',
type: 'index-pattern',
},
],
title: currentSuggestionMock.title,
visualizationType: 'lnsHeatmap',
});
});
});

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { AggregateQuery, Query, Filter } from '@kbn/es-query';
import type { Suggestion } from './types';
export const getLensAttributesFromSuggestion = ({
filters,
query,
suggestion,
dataView,
}: {
filters: Filter[];
query: Query | AggregateQuery;
suggestion: Suggestion | undefined;
dataView?: DataView;
}) => {
const suggestionDatasourceState = Object.assign({}, suggestion?.datasourceState);
const suggestionVisualizationState = Object.assign({}, suggestion?.visualizationState);
const datasourceStates =
suggestion && suggestion.datasourceState
? {
[suggestion.datasourceId!]: {
...suggestionDatasourceState,
},
}
: {
formBased: {},
};
const visualization = suggestionVisualizationState;
const attributes = {
title: suggestion
? suggestion.title
: i18n.translate('visualizationUtils.config.suggestion.title', {
defaultMessage: 'New suggestion',
}),
references: [
{
id: dataView?.id ?? '',
name: `textBasedLanguages-datasource-layer-suggestion`,
type: 'index-pattern',
},
],
state: {
datasourceStates,
filters,
query,
visualization,
...(dataView &&
dataView.id &&
!dataView.isPersisted() && {
adHocDataViews: { [dataView.id]: dataView.toSpec(false) },
}),
},
visualizationType: suggestion ? suggestion.visualizationId : 'lnsXY',
};
return attributes;
};

View file

@ -0,0 +1,43 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { Ast } from '@kbn/interpreter';
import type { IconType } from '@elastic/eui/src/components/icon/icon';
/**
* Indicates what was changed in this table compared to the currently active table of this layer.
* * `initial` means the layer associated with this table does not exist in the current configuration
* * `unchanged` means the table is the same in the currently active configuration
* * `reduced` means the table is a reduced version of the currently active table (some columns dropped, but not all of them)
* * `extended` means the table is an extended version of the currently active table (added one or multiple additional columns)
* * `reorder` means the table columns have changed order, which change the data as well
* * `layers` means the change is a change to the layer structure, not to the table
*/
export type TableChangeType =
| 'initial'
| 'unchanged'
| 'reduced'
| 'extended'
| 'reorder'
| 'layers';
export interface Suggestion<T = unknown, V = unknown> {
visualizationId: string;
datasourceState?: V;
datasourceId?: string;
columns: number;
score: number;
title: string;
visualizationState: T;
previewExpression?: Ast | string;
previewIcon: IconType;
hide?: boolean;
// flag to indicate if the visualization is incomplete
incomplete?: boolean;
changeType: TableChangeType;
keptLayerIds: string[];
}

View file

@ -9,5 +9,9 @@
],
"kbn_references": [
"@kbn/core",
"@kbn/i18n",
"@kbn/interpreter",
"@kbn/data-views-plugin",
"@kbn/es-query",
]
}

View file

@ -8,13 +8,9 @@
import type { KibanaRequest } from '@kbn/core/server';
import { buildEsQuery } from '@kbn/es-query';
import { castEsToKbnFieldTypeName, ES_FIELD_TYPES, KBN_FIELD_TYPES } from '@kbn/field-types';
import { esFieldTypeToKibanaFieldType } from '@kbn/field-types';
import { i18n } from '@kbn/i18n';
import type {
Datatable,
DatatableColumnType,
ExpressionFunctionDefinition,
} from '@kbn/expressions-plugin/common';
import type { Datatable, ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import { RequestAdapter } from '@kbn/inspector-plugin/common';
import { zipObject } from 'lodash';
@ -61,21 +57,6 @@ interface EssqlStartDependencies {
uiSettings: UiSettingsCommon;
}
function normalizeType(type: string): DatatableColumnType {
switch (type) {
case ES_FIELD_TYPES._INDEX:
case ES_FIELD_TYPES.GEO_POINT:
case ES_FIELD_TYPES.IP:
return KBN_FIELD_TYPES.STRING;
case '_version':
return KBN_FIELD_TYPES.NUMBER;
case 'datetime':
return KBN_FIELD_TYPES.DATE;
default:
return castEsToKbnFieldTypeName(type) as DatatableColumnType;
}
}
function sanitize(value: string) {
return value.replace(/[\(\)]/g, '_');
}
@ -260,7 +241,7 @@ export const getEssqlFn = ({ getStartDependencies }: EssqlFnArguments) => {
body.columns?.map(({ name, type }) => ({
id: sanitize(name),
name: sanitize(name),
meta: { type: normalizeType(type) },
meta: { type: esFieldTypeToKibanaFieldType(type) },
})) ?? [];
const columnNames = columns.map(({ name }) => name);
const rows = body.rows.map((row) => zipObject(columnNames, row));

View file

@ -8,6 +8,7 @@
import React, { useCallback, useState } from 'react';
import { EuiFlyout, EuiLoadingSpinner, EuiOverlayMask } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { I18nProvider } from '@kbn/i18n-react';
import { Provider } from 'react-redux';
import type { MiddlewareAPI, Dispatch, Action } from '@reduxjs/toolkit';
import { css } from '@emotion/react';
@ -220,11 +221,13 @@ export async function getEditLensConfiguration(
return getWrapper(
<Provider store={lensStore}>
<KibanaContextProvider services={lensServices}>
<RootDragDropProvider>
<LensEditConfigurationFlyout {...configPanelProps} />
</RootDragDropProvider>
</KibanaContextProvider>
<I18nProvider>
<KibanaContextProvider services={lensServices}>
<RootDragDropProvider>
<LensEditConfigurationFlyout {...configPanelProps} />
</RootDragDropProvider>
</KibanaContextProvider>
</I18nProvider>
</Provider>
);
};

View file

@ -200,34 +200,36 @@ export function LensEditConfigurationFlyout({
]);
const onApply = useCallback(() => {
const dsStates = Object.fromEntries(
Object.entries(datasourceStates).map(([id, ds]) => {
const dsState = ds.state;
return [id, dsState];
})
);
const references = extractReferencesFromState({
activeDatasources: Object.keys(datasourceStates).reduce(
(acc, id) => ({
...acc,
[id]: datasourceMap[id],
}),
{}
),
datasourceStates,
visualizationState: visualization.state,
activeVisualization,
});
const attrs = {
...attributes,
state: {
...attributes.state,
visualization: visualization.state,
datasourceStates: dsStates,
},
references,
visualizationType: visualization.activeId,
title: visualization.activeId ?? '',
};
if (savedObjectId) {
const dsStates = Object.fromEntries(
Object.entries(datasourceStates).map(([id, ds]) => {
const dsState = ds.state;
return [id, dsState];
})
);
const references = extractReferencesFromState({
activeDatasources: Object.keys(datasourceStates).reduce(
(acc, id) => ({
...acc,
[id]: datasourceMap[id],
}),
{}
),
datasourceStates,
visualizationState: visualization.state,
activeVisualization,
});
const attrs = {
...attributes,
state: {
...attributes.state,
visualization: visualization.state,
datasourceStates: dsStates,
},
references,
};
saveByRef?.(attrs);
updateByRefInput?.(savedObjectId);
}
@ -245,7 +247,7 @@ export function LensEditConfigurationFlyout({
trackUiCounterEvents(telemetryEvents);
}
onApplyCb?.();
onApplyCb?.(attrs as TypedLensByValueInput['attributes']);
closeFlyout?.();
}, [
visualization.activeId,
@ -256,9 +258,9 @@ export function LensEditConfigurationFlyout({
visualization.state,
activeVisualization,
attributes,
datasourceMap,
saveByRef,
updateByRefInput,
datasourceMap,
]);
const { getUserMessages } = useApplicationUserMessages({

View file

@ -83,7 +83,7 @@ export interface EditConfigPanelProps {
/** If set to true the layout changes to accordion and the text based query (i.e. ES|QL) can be edited */
hidesSuggestions?: boolean;
/** Optional callback for apply flyout button */
onApplyCb?: () => void;
onApplyCb?: (input: TypedLensByValueInput['attributes']) => void;
/** Optional callback for cancel flyout button */
onCancelCb?: () => void;
}

View file

@ -9,6 +9,7 @@ import React from 'react';
import { createFormulaPublicApi } from '../async_services';
import { LensPublicStart } from '..';
import { visualizationTypes } from '../visualizations/xy/types';
import { mockAllSuggestions } from './suggestions_mock';
type Start = jest.Mocked<LensPublicStart>;
@ -32,6 +33,7 @@ export const lensPluginMock = {
stateHelperApi: jest.fn().mockResolvedValue({
formula: createFormulaPublicApi(),
suggestions: jest.fn().mockReturnValue(mockAllSuggestions),
}),
};
return startContract;

View file

@ -348,7 +348,9 @@ export function loadInitial(
!(initialInput as LensByReferenceInput)?.savedObjectId &&
currentSessionId
? currentSessionId
: data.search.session.start(),
: !inlineEditing
? data.search.session.start()
: undefined,
persistedDoc: doc,
activeDatasourceId: getInitialDatasourceId(datasourceMap, doc),
visualization: {

View file

@ -40,7 +40,7 @@ export async function executeEditEmbeddableAction({
lensEvent: LensChartLoadEvent;
container?: HTMLElement | null;
onUpdate: (newAttributes: TypedLensByValueInput['attributes']) => void;
onApply?: () => void;
onApply?: (newAttributes: TypedLensByValueInput['attributes']) => void;
onCancel?: () => void;
}) {
const isCompatibleAction = isEmbeddableEditActionCompatible(core, attributes);

View file

@ -28,7 +28,7 @@ export interface InlineEditLensEmbeddableContext {
// callback which runs every time something changes in the dimension panel
onUpdate: (newAttributes: TypedLensByValueInput['attributes']) => void;
// optional onApply callback
onApply?: () => void;
onApply?: (newAttributes: TypedLensByValueInput['attributes']) => void;
// optional onCancel callback
onCancel?: () => void;
// custom container element, use in case you need to render outside a flyout

View file

@ -7,6 +7,7 @@
import { FromSchema } from 'json-schema-to-ts';
import { FIELD_FORMAT_IDS } from '@kbn/field-formats-plugin/common';
import { FunctionVisibility } from '../types';
export enum SeriesType {
Bar = 'bar',
@ -23,6 +24,8 @@ export enum SeriesType {
export const lensFunctionDefinition = {
name: 'lens',
contexts: ['core'],
// function is deprecated
visibility: FunctionVisibility.Internal,
description:
"Use this function to create custom visualizations, using Lens, that can be saved to dashboards. This function does not return data to the assistant, it only shows it to the user. When using this function, make sure to use the recall function to get more information about how to use it, with how you want to use it. Make sure the query also contains information about the user's request. The visualisation is displayed to the user above your reply, DO NOT try to generate or display an image yourself.",
descriptionForUser:

View file

@ -0,0 +1,50 @@
/*
* 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 { FromSchema } from 'json-schema-to-ts';
import { FunctionVisibility } from '../types';
export enum VisualizeESQLUserIntention {
generateQueryOnly = 'generateQueryOnly',
executeAndReturnResults = 'executeAndReturnResults',
visualizeAuto = 'visualizeAuto',
visualizeXy = 'visualizeXy',
visualizeBar = 'visualizeBar',
visualizeLine = 'visualizeLine',
visualizeDonut = 'visualizeDonut',
visualizeTreemap = 'visualizeTreemap',
visualizeHeatmap = 'visualizeHeatmap',
visualizeTagcloud = 'visualizeTagcloud',
visualizeWaffle = 'visualizeWaffle',
}
export const VISUALIZE_ESQL_USER_INTENTIONS: VisualizeESQLUserIntention[] = Object.values(
VisualizeESQLUserIntention
);
export const visualizeESQLFunction = {
name: 'visualize_query',
visibility: FunctionVisibility.UserOnly,
description: 'Use this function to visualize charts for ES|QL queries.',
descriptionForUser: 'Use this function to visualize charts for ES|QL queries.',
parameters: {
type: 'object',
additionalProperties: true,
properties: {
query: {
type: 'string',
},
intention: {
type: 'string',
enum: VISUALIZE_ESQL_USER_INTENTIONS,
},
},
required: ['query', 'intention'],
} as const,
contexts: ['core'],
};
export type VisualizeESQLFunctionArguments = FromSchema<typeof visualizeESQLFunction['parameters']>;

View file

@ -105,8 +105,9 @@ export type FunctionResponse =
| Observable<ChatCompletionChunkEvent | MessageAddEvent>;
export enum FunctionVisibility {
System = 'system',
User = 'user',
AssistantOnly = 'assistantOnly',
UserOnly = 'userOnly',
Internal = 'internal',
All = 'all',
}

View file

@ -20,6 +20,7 @@
"share",
"taskManager",
"triggersActionsUi",
"uiActions",
"dataViews",
"ml"
],

View file

@ -4,9 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiHeaderLink, EuiLoadingSpinner } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useMemo, useState } from 'react';
import { ObservabilityAIAssistantChatServiceProvider } from '../../context/observability_ai_assistant_chat_service_provider';
import { useAbortableAsync } from '../../hooks/use_abortable_async';
import { useObservabilityAIAssistant } from '../../hooks/use_observability_ai_assistant';

View file

@ -9,20 +9,23 @@ import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonIcon, EuiContextMenu, EuiPanel, EuiPopover } from '@elastic/eui';
import { useKibana } from '../../hooks/use_kibana';
import { useObservabilityAIAssistantRouter } from '../../hooks/use_observability_ai_assistant_router';
import { getSettingsHref } from '../../utils/get_settings_href';
import { getSettingsKnowledgeBaseHref } from '../../utils/get_settings_kb_href';
import type { UseGenAIConnectorsResult } from '../../hooks/use_genai_connectors';
import { ConnectorSelectorBase } from '../connector_selector/connector_selector_base';
import type { UseGenAIConnectorsResult } from '../../hooks/use_genai_connectors';
export function ChatActionsMenu({
connectors,
conversationId,
disabled,
showLinkToConversationsApp,
onCopyConversationClick,
}: {
connectors: UseGenAIConnectorsResult;
conversationId?: string;
disabled: boolean;
showLinkToConversationsApp: boolean;
onCopyConversationClick: () => void;
}) {
const {
@ -31,6 +34,8 @@ export function ChatActionsMenu({
} = useKibana().services;
const [isOpen, setIsOpen] = useState(false);
const router = useObservabilityAIAssistantRouter();
const toggleActionsMenu = () => {
setIsOpen(!isOpen);
};
@ -70,6 +75,42 @@ export function ChatActionsMenu({
defaultMessage: 'Actions',
}),
items: [
...(showLinkToConversationsApp
? [
{
name: conversationId
? i18n.translate(
'xpack.observabilityAiAssistant.chatHeader.actions.openInConversationsApp',
{
defaultMessage: 'Open in Conversations app',
}
)
: i18n.translate(
'xpack.observabilityAiAssistant.chatHeader.actions.goToConversationsApp',
{
defaultMessage: 'Go to Conversations app',
}
),
href: conversationId
? router.link('/conversations/{conversationId}', {
path: { conversationId },
})
: router.link('/conversations/new'),
},
]
: []),
{
name: i18n.translate(
'xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase',
{
defaultMessage: 'Manage knowledge base',
}
),
onClick: () => {
toggleActionsMenu();
handleNavigateToSettingsKnowledgeBase();
},
},
{
name: i18n.translate('xpack.observabilityAiAssistant.chatHeader.actions.settings', {
defaultMessage: 'AI Assistant Settings',
@ -95,18 +136,6 @@ export function ChatActionsMenu({
),
panel: 1,
},
{
name: i18n.translate(
'xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase',
{
defaultMessage: 'Manage knowledge base',
}
),
onClick: () => {
toggleActionsMenu();
handleNavigateToSettingsKnowledgeBase();
},
},
{
name: i18n.translate(
'xpack.observabilityAiAssistant.chatHeader.actions.copyConversation',

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { css, keyframes } from '@emotion/css';
import {
EuiCallOut,
@ -21,6 +21,7 @@ import type { AuthenticatedUser } from '@kbn/security-plugin/common';
import { euiThemeVars } from '@kbn/ui-theme';
import { i18n } from '@kbn/i18n';
import { findLastIndex } from 'lodash';
import { VisualizeESQLUserIntention } from '../../../common/functions/visualize_esql';
import { ChatState } from '../../hooks/use_chat';
import { useConversation } from '../../hooks/use_conversation';
import { useLicense } from '../../hooks/use_license';
@ -34,8 +35,12 @@ import { ChatTimeline } from './chat_timeline';
import { Feedback } from '../feedback_buttons';
import { IncorrectLicensePanel } from './incorrect_license_panel';
import { WelcomeMessage } from './welcome_message';
import {
ChatActionClickHandler,
ChatActionClickType,
type ChatFlyoutSecondSlotHandler,
} from './types';
import { ASSISTANT_SETUP_TITLE, EMPTY_CONVERSATION_TITLE, UPGRADE_LICENSE_TITLE } from '../../i18n';
import { ChatActionClickType } from './types';
import type { StartedFrom } from '../../utils/get_timeline_items_from_conversation';
import { TELEMETRY, sendEvent } from '../../analytics';
@ -94,7 +99,9 @@ export function ChatBody({
connectors,
knowledgeBase,
currentUser,
showLinkToConversationsApp,
startedFrom,
chatFlyoutSecondSlotHandler,
onConversationUpdate,
}: {
initialTitle?: string;
@ -103,7 +110,9 @@ export function ChatBody({
connectors: UseGenAIConnectorsResult;
knowledgeBase: UseKnowledgeBaseResult;
currentUser?: Pick<AuthenticatedUser, 'full_name' | 'username'>;
showLinkToConversationsApp: boolean;
startedFrom?: StartedFrom;
chatFlyoutSecondSlotHandler?: ChatFlyoutSecondSlotHandler;
onConversationUpdate: (conversation: { conversation: Conversation['conversation'] }) => void;
}) {
const license = useLicense();
@ -171,13 +180,13 @@ export function ChatBody({
}
};
const handleChangeHeight = (editorHeight: number) => {
const handleChangeHeight = useCallback((editorHeight: number) => {
if (editorHeight === 0) {
setPromptEditorHeight(0);
} else {
setPromptEditorHeight(editorHeight + PADDING_AND_BORDER);
}
};
}, []);
useEffect(() => {
const parent = timelineContainerRef.current?.parentElement;
@ -214,6 +223,72 @@ export function ChatBody({
navigator.clipboard?.writeText(content || '');
};
const handleActionClick: ChatActionClickHandler = (payload) => {
setStickToBottom(true);
switch (payload.type) {
case ChatActionClickType.executeEsqlQuery:
next(
messages.concat({
'@timestamp': new Date().toISOString(),
message: {
role: MessageRole.Assistant,
content: '',
function_call: {
name: 'execute_query',
arguments: JSON.stringify({
query: payload.query,
}),
trigger: MessageRole.User,
},
},
})
);
break;
case ChatActionClickType.updateVisualization:
const visualizeQueryMessagesIndex = messages.findIndex(
({ message }) => message.name === 'visualize_query'
);
next(
messages.slice(0, visualizeQueryMessagesIndex).concat({
'@timestamp': new Date().toISOString(),
message: {
role: MessageRole.Assistant,
content: '',
function_call: {
name: 'visualize_query',
arguments: JSON.stringify({
query: payload.query,
userOverrides: payload.userOverrides,
intention: VisualizeESQLUserIntention.visualizeAuto,
}),
trigger: MessageRole.User,
},
},
})
);
break;
case ChatActionClickType.visualizeEsqlQuery:
next(
messages.concat({
'@timestamp': new Date().toISOString(),
message: {
role: MessageRole.Assistant,
content: '',
function_call: {
name: 'visualize_query',
arguments: JSON.stringify({
query: payload.query,
intention: VisualizeESQLUserIntention.visualizeAuto,
}),
trigger: MessageRole.User,
},
},
})
);
break;
}
};
if (!hasCorrectLicense && !initialConversationId) {
footer = (
<>
@ -282,29 +357,8 @@ export function ChatBody({
onStopGenerating={() => {
stop();
}}
onActionClick={(payload) => {
setStickToBottom(true);
switch (payload.type) {
case ChatActionClickType.executeEsqlQuery:
next(
messages.concat({
'@timestamp': new Date().toISOString(),
message: {
role: MessageRole.Assistant,
content: '',
function_call: {
name: 'execute_query',
arguments: JSON.stringify({
query: payload.query,
}),
trigger: MessageRole.User,
},
},
})
);
break;
}
}}
chatFlyoutSecondSlotHandler={chatFlyoutSecondSlotHandler}
onActionClick={handleActionClick}
/>
)}
</EuiPanel>
@ -393,7 +447,7 @@ export function ChatBody({
</EuiCallOut>
) : null}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexItem grow={false} css={{ paddingRight: showLinkToConversationsApp ? '24px' : '0' }}>
<ChatHeader
connectors={connectors}
conversationId={
@ -403,6 +457,7 @@ export function ChatBody({
}
licenseInvalid={!hasCorrectLicense && !initialConversationId}
loading={isLoading}
showLinkToConversationsApp={showLinkToConversationsApp}
title={title}
onCopyConversation={handleCopyConversation}
onSaveTitle={(newTitle) => {

View file

@ -4,25 +4,26 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiFlyout, EuiLink, EuiPanel, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/css';
import React, { useCallback, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
import type { Message } from '../../../common/types';
import { v4 } from 'uuid';
import { css } from '@emotion/css';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiFlyout, useEuiTheme } from '@elastic/eui';
import { useForceUpdate } from '../../hooks/use_force_update';
import { useCurrentUser } from '../../hooks/use_current_user';
import { useGenAIConnectors } from '../../hooks/use_genai_connectors';
import { useKnowledgeBase } from '../../hooks/use_knowledge_base';
import { useObservabilityAIAssistantRouter } from '../../hooks/use_observability_ai_assistant_router';
import { StartedFrom } from '../../utils/get_timeline_items_from_conversation';
import { ChatBody } from './chat_body';
import { ConversationList } from './conversation_list';
import type { Message } from '../../../common/types';
import { ChatInlineEditingContent } from './chat_inline_edit';
const containerClassName = css`
max-height: 100%;
`;
const CONVERSATIONS_SIDEBAR_WIDTH = 260;
const CONVERSATIONS_SIDEBAR_WIDTH_COLLAPSED = 34;
const bodyClassName = css`
overflow-y: auto;
`;
const SIDEBAR_WIDTH = 400;
export function ChatFlyout({
initialTitle,
@ -43,70 +44,181 @@ export function ChatFlyout({
const connectors = useGenAIConnectors();
const router = useObservabilityAIAssistantRouter();
const knowledgeBase = useKnowledgeBase();
const [conversationId, setConversationId] = useState<string | undefined>(undefined);
const conversationsHeaderClassName = css`
padding-top: 12px;
padding-bottom: 12px;
border-bottom: solid 1px ${euiTheme.border.color};
const [expanded, setExpanded] = useState(false);
const [secondSlotContainer, setSecondSlotContainer] = useState<HTMLDivElement | null>(null);
const [isSecondSlotVisible, setIsSecondSlotVisible] = useState(false);
const sidebarClass = css`
max-width: ${expanded ? CONVERSATIONS_SIDEBAR_WIDTH : CONVERSATIONS_SIDEBAR_WIDTH_COLLAPSED}px;
min-width: ${expanded ? CONVERSATIONS_SIDEBAR_WIDTH : CONVERSATIONS_SIDEBAR_WIDTH_COLLAPSED}px;
border-right: solid 1px ${euiTheme.border.color};
`;
const expandButtonClassName = css`
position: absolute;
margin-top: 16px;
margin-left: ${expanded
? CONVERSATIONS_SIDEBAR_WIDTH - CONVERSATIONS_SIDEBAR_WIDTH_COLLAPSED
: 5}px;
padding: ${euiTheme.size.s};
z-index: 1;
`;
const containerClassName = css`
height: 100%;
`;
const chatBodyContainerClassName = css`
min-width: 0;
`;
const newChatButtonClassName = css`
position: absolute;
bottom: 31px;
margin-left: ${expanded
? CONVERSATIONS_SIDEBAR_WIDTH - CONVERSATIONS_SIDEBAR_WIDTH_COLLAPSED
: 5}px;
padding: ${euiTheme.size.s};
z-index: 1;
`;
const chatBodyKeyRef = useRef(v4());
const forceUpdate = useForceUpdate();
const reloadConversation = useCallback(() => {
chatBodyKeyRef.current = v4();
forceUpdate();
}, [forceUpdate]);
const handleClickChat = (id: string) => {
setConversationId(id);
reloadConversation();
};
const handleClickDeleteConversation = () => {
setConversationId(undefined);
reloadConversation();
};
const handleClickNewChat = () => {
if (conversationId) {
setConversationId(undefined);
reloadConversation();
}
};
return isOpen ? (
<EuiFlyout onClose={onClose}>
<EuiFlexGroup
responsive={false}
gutterSize="none"
direction="column"
className={containerClassName}
>
<EuiFlexItem grow={false}>
<EuiPanel
hasShadow={false}
hasBorder={false}
borderRadius="none"
className={conversationsHeaderClassName}
>
{conversationId ? (
<EuiLink
data-test-subj="observabilityAiAssistantChatFlyoutOpenConversationLink"
href={router.link('/conversations/{conversationId}', {
path: { conversationId },
})}
>
{i18n.translate('xpack.observabilityAiAssistant.conversationDeepLinkLabel', {
defaultMessage: 'Open conversation',
})}
</EuiLink>
) : (
<EuiLink
data-test-subj="observabilityAiAssistantChatFlyoutGoToConversationsLink"
href={router.link('/conversations/new')}
>
{i18n.translate('xpack.observabilityAiAssistant.conversationListDeepLinkLabel', {
defaultMessage: 'Go to conversations',
})}
</EuiLink>
<EuiFlyout
closeButtonProps={{
css: { marginRight: `${euiTheme.size.s}`, marginTop: `${euiTheme.size.s}` },
}}
size={getFlyoutWidth({ expanded, isSecondSlotVisible })}
paddingSize="m"
onClose={() => {
onClose();
setIsSecondSlotVisible(false);
if (secondSlotContainer) {
ReactDOM.unmountComponentAtNode(secondSlotContainer);
}
}}
>
<EuiFlexGroup gutterSize="none" className={containerClassName}>
<EuiFlexItem className={sidebarClass}>
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.observabilityAiAssistant.chatFlyout.euiButtonIcon.expandConversationListLabel',
{ defaultMessage: 'Expand conversation list' }
)}
</EuiPanel>
className={expandButtonClassName}
color="text"
data-test-subj="observabilityAiAssistantChatFlyoutButton"
iconType={expanded ? 'transitionLeftIn' : 'transitionLeftOut'}
onClick={() => setExpanded(!expanded)}
/>
{expanded ? (
<ConversationList
selected={conversationId ?? ''}
onClickDeleteConversation={handleClickDeleteConversation}
onClickChat={handleClickChat}
onClickNewChat={handleClickNewChat}
/>
) : (
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.observabilityAiAssistant.chatFlyout.euiButtonIcon.newChatLabel',
{ defaultMessage: 'New chat' }
)}
className={newChatButtonClassName}
data-test-subj="observabilityAiAssistantNewChatFlyoutButton"
iconType="plusInCircle"
onClick={handleClickNewChat}
/>
)}
</EuiFlexItem>
<EuiFlexItem grow className={bodyClassName}>
<EuiFlexItem className={chatBodyContainerClassName}>
<ChatBody
key={chatBodyKeyRef.current}
connectors={connectors}
initialTitle={initialTitle}
initialMessages={initialMessages}
initialConversationId={conversationId}
currentUser={currentUser}
knowledgeBase={knowledgeBase}
startedFrom={startedFrom}
onConversationUpdate={(conversation) => {
setConversationId(conversation.conversation.id);
}}
chatFlyoutSecondSlotHandler={{
container: secondSlotContainer,
setVisibility: setIsSecondSlotVisible,
}}
showLinkToConversationsApp
/>
</EuiFlexItem>
<EuiFlexItem
style={{
maxWidth: isSecondSlotVisible ? SIDEBAR_WIDTH : 0,
paddingTop: '56px',
}}
>
<ChatInlineEditingContent
setContainer={setSecondSlotContainer}
visible={isSecondSlotVisible}
style={{
borderTop: `solid 1px ${euiTheme.border.color}`,
borderLeft: `solid 1px ${euiTheme.border.color}`,
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyout>
) : null;
}
const getFlyoutWidth = ({
expanded,
isSecondSlotVisible,
}: {
expanded: boolean;
isSecondSlotVisible: boolean;
}) => {
if (!expanded && !isSecondSlotVisible) {
return '40vw';
}
if (expanded && !isSecondSlotVisible) {
return `calc(40vw + ${CONVERSATIONS_SIDEBAR_WIDTH - CONVERSATIONS_SIDEBAR_WIDTH_COLLAPSED}px`;
}
if (!expanded && isSecondSlotVisible) {
return `calc(40vw + ${SIDEBAR_WIDTH}px`;
}
return `calc(40vw + ${
CONVERSATIONS_SIDEBAR_WIDTH - CONVERSATIONS_SIDEBAR_WIDTH_COLLAPSED
}px + ${SIDEBAR_WIDTH}px`;
};

View file

@ -35,6 +35,7 @@ export function ChatHeader({
licenseInvalid,
connectors,
conversationId,
showLinkToConversationsApp,
onCopyConversation,
onSaveTitle,
}: {
@ -43,6 +44,7 @@ export function ChatHeader({
licenseInvalid: boolean;
connectors: UseGenAIConnectorsResult;
conversationId?: string;
showLinkToConversationsApp: boolean;
onCopyConversation: () => void;
onSaveTitle: (title: string) => void;
}) {
@ -54,6 +56,11 @@ export function ChatHeader({
setNewTitle(title);
}, [title]);
const chatActionsMenuWrapper = css`
position: absolute;
right: 46px;
`;
return (
<EuiPanel
borderRadius="none"
@ -98,11 +105,15 @@ export function ChatHeader({
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexItem
grow={false}
className={showLinkToConversationsApp ? chatActionsMenuWrapper : ''}
>
<ChatActionsMenu
connectors={connectors}
disabled={licenseInvalid}
conversationId={conversationId}
disabled={licenseInvalid}
showLinkToConversationsApp={showLinkToConversationsApp}
onCopyConversationClick={onCopyConversation}
/>
</EuiFlexItem>

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 { EuiFlexGroup } from '@elastic/eui';
import React, { useRef, useEffect } from 'react';
export function ChatInlineEditingContent({
visible,
setContainer,
style,
}: {
visible?: boolean;
setContainer?: (element: HTMLDivElement | null) => void;
style: React.CSSProperties;
}) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (containerRef?.current && setContainer) {
setContainer(containerRef.current);
}
}, [setContainer]);
return (
<EuiFlexGroup
className="lnsConfigPanel__overlay"
style={{ ...style, padding: 0, position: 'relative', blockSize: visible ? '100%' : 0 }}
direction="column"
ref={containerRef}
gutterSize="none"
/>
);
}

View file

@ -13,7 +13,7 @@ import { omit } from 'lodash';
import type { Feedback } from '../feedback_buttons';
import type { Message } from '../../../common';
import type { UseKnowledgeBaseResult } from '../../hooks/use_knowledge_base';
import type { ChatActionClickHandler } from './types';
import type { ChatActionClickHandler, ChatFlyoutSecondSlotHandler } from './types';
import type { ObservabilityAIAssistantChatService } from '../../types';
import type { TelemetryEventTypeWithPayload } from '../../analytics';
import { ChatItem } from './chat_item';
@ -54,6 +54,7 @@ export interface ChatTimelineProps {
chatState: ChatState;
currentUser?: Pick<AuthenticatedUser, 'full_name' | 'username'>;
startedFrom?: StartedFrom;
chatFlyoutSecondSlotHandler?: ChatFlyoutSecondSlotHandler;
onEdit: (message: Message, messageAfterEdit: Message) => void;
onFeedback: (message: Message, feedback: Feedback) => void;
onRegenerate: (message: Message) => void;
@ -68,6 +69,7 @@ export function ChatTimeline({
hasConnector,
currentUser,
startedFrom,
chatFlyoutSecondSlotHandler,
onEdit,
onFeedback,
onRegenerate,
@ -84,6 +86,8 @@ export function ChatTimeline({
currentUser,
startedFrom,
chatState,
chatFlyoutSecondSlotHandler,
onActionClick,
});
const consolidatedChatItems: Array<ChatTimelineItem | ChatTimelineItem[]> = [];
@ -106,7 +110,16 @@ export function ChatTimeline({
}
return consolidatedChatItems;
}, [chatService, hasConnector, messages, currentUser, startedFrom, chatState]);
}, [
chatService,
hasConnector,
messages,
currentUser,
startedFrom,
chatState,
chatFlyoutSecondSlotHandler,
onActionClick,
]);
return (
<EuiCommentList

View file

@ -29,48 +29,25 @@ const Wrapper = (props: ConversationListProps) => {
};
export const ChatHeaderLoading: ComponentStoryObj<typeof Component> = {
args: {
loading: true,
},
args: {},
render: Wrapper,
};
export const ChatHeaderError: ComponentStoryObj<typeof Component> = {
args: {
error: new Error(),
},
args: {},
render: Wrapper,
};
export const ChatHeaderLoaded: ComponentStoryObj<typeof Component> = {
args: {
loading: false,
selected: '',
conversations: [
{
id: '',
label: 'New conversation',
},
{
id: 'first',
label: 'My first conversation',
href: '/my-first-conversation',
},
{
id: 'second',
label: 'My second conversation',
href: '/my-second-conversation',
},
],
},
render: Wrapper,
};
export const ChatHeaderEmpty: ComponentStoryObj<typeof Component> = {
args: {
loading: false,
selected: '',
conversations: [],
},
render: Wrapper,
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
@ -21,11 +21,13 @@ import {
} from '@elastic/eui';
import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
import { noop } from 'lodash';
import { useConfirmModal } from '../../hooks/use_confirm_modal';
import { useKibana } from '../../hooks/use_kibana';
import { NewChatButton } from '../buttons/new_chat_button';
const containerClassName = css`
height: 100%;
`;
import { useObservabilityAIAssistant } from '../../hooks/use_observability_ai_assistant';
import { useAbortableAsync } from '../../hooks/use_abortable_async';
import { EMPTY_CONVERSATION_TITLE } from '../../i18n';
const titleClassName = css`
text-transform: uppercase;
@ -33,6 +35,7 @@ const titleClassName = css`
const panelClassName = css`
max-height: 100%;
padding-top: 56px;
`;
const overflowScrollClassName = (scrollBarStyles: string) => css`
@ -46,127 +49,254 @@ const newChatButtonWrapperClassName = css`
export function ConversationList({
selected,
loading,
error,
conversations,
onClickChat,
onClickNewChat,
onClickDeleteConversation,
}: {
selected: string;
loading: boolean;
error?: any;
conversations?: Array<{ id: string; label: string; href?: string }>;
onClickChat?: (id: string) => void;
onClickNewChat: () => void;
onClickDeleteConversation: (id: string) => void;
}) {
const {
services: { notifications },
} = useKibana();
const euiTheme = useEuiTheme();
const scrollBarStyles = euiScrollBarStyles(euiTheme);
const service = useObservabilityAIAssistant();
const containerClassName = css`
height: 100%;
border-top: solid 1px ${euiTheme.euiTheme.border.color};
padding: ${euiTheme.euiTheme.size.s};
`;
const { element: confirmDeleteElement, confirm: confirmDeleteFunction } = useConfirmModal({
title: i18n.translate('xpack.observabilityAiAssistant.flyout.confirmDeleteConversationTitle', {
defaultMessage: 'Delete this conversation?',
}),
children: i18n.translate(
'xpack.observabilityAiAssistant.flyout.confirmDeleteConversationContent',
{
defaultMessage: 'This action cannot be undone.',
}
),
confirmButtonText: i18n.translate(
'xpack.observabilityAiAssistant.flyout.confirmDeleteButtonText',
{
defaultMessage: 'Delete conversation',
}
),
});
const [conversationId, setConversationId] = useState<string | undefined>(undefined);
const [isUpdatingList, setIsUpdatingList] = useState(false);
const conversations = useAbortableAsync(
({ signal }) => {
setIsUpdatingList(true);
return service.callApi('POST /internal/observability_ai_assistant/conversations', {
signal,
});
},
[service]
);
useEffect(() => {
setIsUpdatingList(conversations.loading);
}, [conversations.loading]);
const displayedConversations = useMemo(() => {
return [
...(!conversationId
? [{ id: '', label: EMPTY_CONVERSATION_TITLE, lastUpdated: '', href: '' }]
: []),
...(conversations.value?.conversations ?? []).map(({ conversation }) => ({
id: conversation.id,
label: conversation.title,
lastUpdated: conversation.last_updated,
onClick: () => {
onClickChat?.(conversation.id);
},
})),
];
}, [conversationId, conversations.value?.conversations, onClickChat]);
const handleDeleteConversation = (id: string) => {
confirmDeleteFunction()
.then(async (confirmed) => {
if (!confirmed) {
return;
}
setIsUpdatingList(true);
await service.callApi(
'DELETE /internal/observability_ai_assistant/conversation/{conversationId}',
{
params: {
path: {
conversationId: id,
},
},
signal: null,
}
);
const isCurrentConversation = id === conversationId;
const hasOtherConversations = conversations.value?.conversations.find(
(conv) => 'id' in conv.conversation && conv.conversation.id !== id
);
if (isCurrentConversation) {
setConversationId(
hasOtherConversations ? hasOtherConversations.conversation.id : undefined
);
}
conversations.refresh();
})
.catch((err) => {
notifications.toasts.addError(err, {
title: i18n.translate(
'xpack.observabilityAiAssistant.flyout.failedToDeleteConversation',
{
defaultMessage: 'Could not delete conversation',
}
),
});
})
.finally(() => {
setIsUpdatingList(false);
onClickDeleteConversation(id);
});
};
const handleClickNewChat = () => {
onClickNewChat();
};
return (
<EuiPanel paddingSize="s" hasShadow={false} className={panelClassName}>
<EuiFlexGroup direction="column" gutterSize="none" className={containerClassName}>
<EuiFlexItem grow className={overflowScrollClassName(scrollBarStyles)}>
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiSpacer size="s" />
<EuiText className={titleClassName} size="s">
<strong>
{i18n.translate('xpack.observabilityAiAssistant.conversationList.title', {
defaultMessage: 'Previously',
})}
</strong>
</EuiText>
</EuiFlexItem>
{loading ? (
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="s" />
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
{error ? (
<>
<EuiPanel paddingSize="none" hasShadow={false} className={panelClassName}>
<EuiFlexGroup direction="column" gutterSize="none" className={containerClassName}>
<EuiFlexItem grow className={overflowScrollClassName(scrollBarStyles)}>
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
<EuiFlexGroup direction="row" alignItems="center" gutterSize="s">
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type="warning" color="danger" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color="danger">
{i18n.translate(
'xpack.observabilityAiAssistant.conversationList.errorMessage',
{
defaultMessage: 'Failed to load',
}
)}
<EuiSpacer size="s" />
<EuiText className={titleClassName} size="s">
<strong>
{i18n.translate('xpack.observabilityAiAssistant.conversationList.title', {
defaultMessage: 'Previously',
})}
</strong>
</EuiText>
</EuiFlexItem>
{isUpdatingList ? (
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="s" />
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
) : null}
{conversations?.length ? (
<EuiFlexItem grow>
<EuiListGroup flush={false} gutterSize="none">
{conversations?.map((conversation) => (
<EuiListGroupItem
key={conversation.id}
label={conversation.label}
size="s"
isActive={conversation.id === selected}
isDisabled={loading}
href={conversation.href}
wrapText
extraAction={
conversation.id
? {
iconType: 'trash',
'aria-label': i18n.translate(
'xpack.observabilityAiAssistant.conversationList.deleteConversationIconLabel',
{
defaultMessage: 'Delete',
}
),
onClick: () => {
onClickDeleteConversation(conversation.id);
},
{conversations.error ? (
<EuiFlexItem grow={false}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
<EuiFlexGroup direction="row" alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiIcon type="warning" color="danger" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color="danger">
{i18n.translate(
'xpack.observabilityAiAssistant.conversationList.errorMessage',
{
defaultMessage: 'Failed to load',
}
: undefined
)}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
) : null}
{displayedConversations?.length ? (
<EuiFlexItem grow>
<EuiListGroup flush={false} gutterSize="none">
{displayedConversations?.map((conversation) => (
<EuiListGroupItem
key={conversation.id}
label={conversation.label}
size="s"
isActive={conversation.id === selected}
isDisabled={isUpdatingList}
wrapText
showToolTip
onClick={
onClickChat
? (e) => {
onClickChat(conversation.id);
}
: noop
}
extraAction={
conversation.id
? {
iconType: 'trash',
'aria-label': i18n.translate(
'xpack.observabilityAiAssistant.conversationList.deleteConversationIconLabel',
{
defaultMessage: 'Delete',
}
),
onClick: () => {
handleDeleteConversation(conversation.id);
},
}
: undefined
}
/>
))}
</EuiListGroup>
</EuiFlexItem>
) : null}
{!isUpdatingList && !conversations.error && !displayedConversations?.length ? (
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
<EuiText color="subdued" size="s">
{i18n.translate(
'xpack.observabilityAiAssistant.conversationList.noConversations',
{
defaultMessage: 'No conversations',
}
/>
))}
</EuiListGroup>
</EuiFlexItem>
) : null}
{!loading && !error && !conversations?.length ? (
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
<EuiText color="subdued" size="s">
{i18n.translate(
'xpack.observabilityAiAssistant.conversationList.noConversations',
{
defaultMessage: 'No conversations',
}
)}
</EuiText>
</EuiPanel>
) : null}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPanel paddingSize="s" hasBorder={false} hasShadow={false}>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow className={newChatButtonWrapperClassName}>
<NewChatButton onClick={onClickNewChat} />
</EuiFlexItem>
)}
</EuiText>
</EuiPanel>
) : null}
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPanel paddingSize="s" hasBorder={false} hasShadow={false}>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow className={newChatButtonWrapperClassName}>
<NewChatButton onClick={handleClickNewChat} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
{confirmDeleteElement}
</>
);
}

View file

@ -158,7 +158,11 @@ function mapFunctions({
selectedFunctionName: string | undefined;
}) {
return functions
.filter((func) => func.visibility !== FunctionVisibility.System)
.filter(
(func) =>
func.visibility !== FunctionVisibility.AssistantOnly &&
func.visibility !== FunctionVisibility.Internal
)
.map((func) => ({
label: func.name,
searchableLabel: func.descriptionForUser || func.description,

View file

@ -4,20 +4,30 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
type ChatActionClickPayloadBase<TType extends ChatActionClickType, TExtraProps extends {}> = {
type: TType;
} & TExtraProps;
type ChatActionClickPayloadExecuteEsql = ChatActionClickPayloadBase<
ChatActionClickType.executeEsqlQuery,
{ query: string }
| ChatActionClickType.executeEsqlQuery
| ChatActionClickType.visualizeEsqlQuery
| ChatActionClickType.updateVisualization,
{ query: string; userOverrides?: TypedLensByValueInput }
>;
type ChatActionClickPayload = ChatActionClickPayloadExecuteEsql;
export enum ChatActionClickType {
executeEsqlQuery = 'executeEsqlQuery',
visualizeEsqlQuery = 'visualizeEsqlQuery',
updateVisualization = 'updateVisualization',
}
export type ChatActionClickHandler = (payload: ChatActionClickPayload) => void;
export interface ChatFlyoutSecondSlotHandler {
container?: HTMLDivElement | null;
setVisibility?: (status: boolean) => void;
}

View file

@ -65,7 +65,22 @@ export function EsqlCodeBlock({
disabled={actionsDisabled}
>
{i18n.translate('xpack.observabilityAiAssistant.runThisQuery', {
defaultMessage: 'Run this query',
defaultMessage: 'Display results',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="observabilityAiAssistantEsqlCodeBlockVisualizeThisQueryButton"
size="xs"
iconType="lensApp"
onClick={() =>
onActionClick({ type: ChatActionClickType.visualizeEsqlQuery, query: value })
}
disabled={actionsDisabled}
>
{i18n.translate('xpack.observabilityAiAssistant.visualizeThisQuery', {
defaultMessage: 'Visualize this query',
})}
</EuiButtonEmpty>
</EuiFlexItem>

View file

@ -142,6 +142,8 @@ export function PromptEditor({
useEffect(() => {
if (hidden) {
onChangeHeight(0);
} else if (containerRef.current) {
onChangeHeight(containerRef.current.clientHeight);
}
}, [hidden, onChangeHeight]);

View file

@ -7,15 +7,27 @@
import React from 'react';
import { Message } from '../../common';
import { useObservabilityAIAssistantChatService } from '../hooks/use_observability_ai_assistant_chat_service';
import type { ChatActionClickHandler, ChatFlyoutSecondSlotHandler } from './chat/types';
interface Props {
name: string;
arguments: string | undefined;
response: Message['message'];
onActionClick: ChatActionClickHandler;
chatFlyoutSecondSlotHandler?: ChatFlyoutSecondSlotHandler;
}
export function RenderFunction(props: Props) {
const chatService = useObservabilityAIAssistantChatService();
return <>{chatService.renderFunction(props.name, props.arguments, props.response)}</>;
return (
<>
{chatService.renderFunction(
props.name,
props.arguments,
props.response,
props.onActionClick,
props.chatFlyoutSecondSlotHandler
)}
</>
);
}

View file

@ -11,6 +11,7 @@ import type {
RegisterRenderFunctionDefinition,
} from '../types';
import { registerLensRenderFunction } from './lens';
import { registerVisualizeQueryRenderFunction } from './visualize_esql';
export async function registerFunctions({
registerRenderFunction,
@ -22,4 +23,5 @@ export async function registerFunctions({
pluginsStart: ObservabilityAIAssistantPluginStartDependencies;
}) {
registerLensRenderFunction({ service, pluginsStart, registerRenderFunction });
registerVisualizeQueryRenderFunction({ service, pluginsStart, registerRenderFunction });
}

View file

@ -0,0 +1,119 @@
/*
* 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, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { DatatableColumn } from '@kbn/expressions-plugin/common';
import type { LensPublicStart } from '@kbn/lens-plugin/public';
import { lensPluginMock } from '@kbn/lens-plugin/public/mocks/lens_plugin_mock';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { VisualizeESQL } from './visualize_esql';
describe('VisualizeESQL', () => {
function renderComponent(
userOverrides?: unknown,
newLensService?: LensPublicStart,
setVisibilitySpy?: () => void
) {
const lensService = newLensService ?? lensPluginMock.createStartContract();
const dataViewsService = {
...dataViewPluginMocks.createStartContract(),
create: jest.fn().mockReturnValue(
Promise.resolve({
title: 'foo',
id: 'foo',
toSpec: jest.fn(),
isPersisted: jest.fn().mockReturnValue(false),
})
),
};
const uiActionsService = uiActionsPluginMock.createStartContract();
const columns = [
{
name: 'bytes',
id: 'bytes',
meta: {
type: 'number',
},
},
{
name: 'destination',
id: 'destination',
meta: {
type: 'keyword',
},
},
] as DatatableColumn[];
render(
<VisualizeESQL
lens={lensService}
dataViews={dataViewsService}
uiActions={uiActionsService}
columns={columns}
query={'from foo | keep bytes, destination'}
onActionClick={jest.fn()}
userOverrides={userOverrides}
chatFlyoutSecondSlotHandler={{
container: document.createElement('div'),
setVisibility: setVisibilitySpy ?? jest.fn(),
}}
/>
);
}
it('should render the embeddable if no initial input is given', async () => {
renderComponent();
await waitFor(() =>
expect(screen.getByTestId('observabilityAiAssistantESQLLensChart')).toMatchInlineSnapshot(`
<div
class="euiFlexItem emotion-euiFlexItem-grow-1"
data-test-subj="observabilityAiAssistantESQLLensChart"
>
<span>
Lens Embeddable Component
</span>
</div>
`)
);
});
it('should run the suggestions api if no initial input is given', async () => {
const suggestionsApiSpy = jest.fn();
const lensService = {
...lensPluginMock.createStartContract(),
stateHelperApi: jest.fn().mockResolvedValue({
formula: jest.fn(),
suggestions: suggestionsApiSpy,
}),
};
renderComponent(undefined, lensService);
await waitFor(() => expect(suggestionsApiSpy).toHaveBeenCalled());
});
it('should not run the suggestions api if no initial input is given', async () => {
const suggestionsApiSpy = jest.fn();
const lensService = {
...lensPluginMock.createStartContract(),
stateHelperApi: jest.fn().mockResolvedValue({
formula: jest.fn(),
suggestions: suggestionsApiSpy,
}),
};
renderComponent({}, lensService);
await waitFor(() => expect(suggestionsApiSpy).not.toHaveBeenCalled());
});
it('should run the setVisibility callback if edit button is clicked', async () => {
const setVisibilitySpy = jest.fn();
renderComponent({}, undefined, setVisibilitySpy);
await waitFor(() => {
userEvent.click(screen.getByTestId('observabilityAiAssistantLensESQLEditButton'));
expect(setVisibilitySpy).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,379 @@
/*
* 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 { v4 as uuidv4 } from 'uuid';
import { i18n } from '@kbn/i18n';
import {
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiToolTip,
EuiButtonIcon,
} from '@elastic/eui';
import type { DataViewsServicePublic } from '@kbn/data-views-plugin/public/types';
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import type { DatatableColumn } from '@kbn/expressions-plugin/common';
import { getLensAttributesFromSuggestion } from '@kbn/visualization-utils';
import type {
LensPublicStart,
TypedLensByValueInput,
InlineEditLensEmbeddableContext,
} from '@kbn/lens-plugin/public';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import ReactDOM from 'react-dom';
import useAsync from 'react-use/lib/useAsync';
import { getIndexPatternFromESQLQuery } from '@kbn/esql-utils';
import {
VisualizeESQLFunctionArguments,
VisualizeESQLUserIntention,
} from '../../common/functions/visualize_esql';
import type {
ObservabilityAIAssistantPluginStartDependencies,
ObservabilityAIAssistantService,
RegisterRenderFunctionDefinition,
RenderFunction,
} from '../types';
import {
type ChatActionClickHandler,
ChatActionClickType,
ChatFlyoutSecondSlotHandler,
} from '../components/chat/types';
interface VisualizeLensResponse {
content: DatatableColumn[];
}
interface VisualizeESQLProps {
/** Lens start contract, get the ES|QL charts suggestions api */
lens: LensPublicStart;
/** Dataviews start contract, creates an adhoc dataview */
dataViews: DataViewsServicePublic;
/** UiActions start contract, triggers the inline editing flyout */
uiActions: UiActionsStart;
/** Datatable columns as returned from the ES|QL _query api, slightly processed to be kibana compliant */
columns: DatatableColumn[];
/** The ES|QL query */
query: string;
/** Actions handler */
onActionClick: ChatActionClickHandler;
/** Optional, overwritten ES|QL Lens chart attributes
* If not given, the embeddable gets them from the suggestions api
*/
userOverrides?: unknown;
/** Optional, should be passed if the embeddable is rendered in a flyout
* If not given, the inline editing push flyout won't open
* The code will be significantly improved,
* if this is addressed https://github.com/elastic/eui/issues/7443
*/
chatFlyoutSecondSlotHandler?: ChatFlyoutSecondSlotHandler;
/** User's preferation chart type as it comes from the model */
preferredChartType?: string;
}
function generateId() {
return uuidv4();
}
export function VisualizeESQL({
lens,
dataViews,
uiActions,
columns,
query,
onActionClick,
userOverrides,
chatFlyoutSecondSlotHandler,
preferredChartType,
}: VisualizeESQLProps) {
// fetch the pattern from the query
const indexPattern = getIndexPatternFromESQLQuery(query);
const lensHelpersAsync = useAsync(() => {
return lens.stateHelperApi();
}, [lens]);
const dataViewAsync = useAsync(() => {
return dataViews.create({
title: indexPattern,
});
}, [indexPattern]);
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
const [lensInput, setLensInput] = useState<TypedLensByValueInput | undefined>(
userOverrides as TypedLensByValueInput
);
const [lensLoadEvent, setLensLoadEvent] = useState<
InlineEditLensEmbeddableContext['lensEvent'] | null
>(null);
const onLoad = useCallback(
(
isLoading: boolean,
adapters: InlineEditLensEmbeddableContext['lensEvent']['adapters'] | undefined,
lensEmbeddableOutput$?: InlineEditLensEmbeddableContext['lensEvent']['embeddableOutput$']
) => {
const adapterTables = adapters?.tables?.tables;
if (adapterTables && !isLoading) {
setLensLoadEvent({
adapters,
embeddableOutput$: lensEmbeddableOutput$,
});
}
},
[]
);
// initialization
useEffect(() => {
if (lensHelpersAsync.value && dataViewAsync.value && !lensInput) {
const context = {
dataViewSpec: dataViewAsync.value?.toSpec(),
fieldName: '',
textBasedColumns: columns,
query: {
esql: query,
},
};
const chartSuggestions = lensHelpersAsync.value.suggestions(context, dataViewAsync.value);
if (chartSuggestions?.length) {
let [suggestion] = chartSuggestions;
if (chartSuggestions.length > 1 && preferredChartType) {
const suggestionFromModel = chartSuggestions.find(
(s) =>
s.title.includes(preferredChartType) || s.visualizationId.includes(preferredChartType)
);
if (suggestionFromModel) {
suggestion = suggestionFromModel;
}
}
const attrs = getLensAttributesFromSuggestion({
filters: [],
query: {
esql: query,
},
suggestion,
dataView: dataViewAsync.value,
}) as TypedLensByValueInput['attributes'];
const lensEmbeddableInput = {
attributes: attrs,
id: generateId(),
};
setLensInput(lensEmbeddableInput);
}
}
}, [columns, dataViewAsync.value, lensHelpersAsync.value, lensInput, query, preferredChartType]);
// trigger options to open the inline editing flyout correctly
const triggerOptions: InlineEditLensEmbeddableContext | undefined = useMemo(() => {
if (lensLoadEvent && lensInput?.attributes) {
return {
attributes: lensInput?.attributes,
lensEvent: lensLoadEvent,
onUpdate: (newAttributes: TypedLensByValueInput['attributes']) => {
if (lensInput) {
const newInput = {
...lensInput,
attributes: newAttributes,
};
setLensInput(newInput);
}
},
onApply: (newAttributes: TypedLensByValueInput['attributes']) => {
const newInput = {
...lensInput,
attributes: newAttributes,
};
onActionClick({
type: ChatActionClickType.updateVisualization,
userOverrides: newInput,
query,
});
chatFlyoutSecondSlotHandler?.setVisibility?.(false);
if (chatFlyoutSecondSlotHandler?.container) {
ReactDOM.unmountComponentAtNode(chatFlyoutSecondSlotHandler.container);
}
},
onCancel: () => {
onActionClick({
type: ChatActionClickType.updateVisualization,
userOverrides: lensInput,
query,
});
chatFlyoutSecondSlotHandler?.setVisibility?.(false);
if (chatFlyoutSecondSlotHandler?.container) {
ReactDOM.unmountComponentAtNode(chatFlyoutSecondSlotHandler.container);
}
},
container: chatFlyoutSecondSlotHandler?.container,
};
}
}, [chatFlyoutSecondSlotHandler, lensInput, lensLoadEvent, onActionClick, query]);
if (!lensHelpersAsync.value || !dataViewAsync.value || !lensInput) {
return <EuiLoadingSpinner />;
}
return (
<>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="row" gutterSize="s" justifyContent="flexEnd">
<EuiToolTip
content={i18n.translate('xpack.observabilityAiAssistant.lensESQLFunction.edit', {
defaultMessage: 'Edit visualization',
})}
>
<EuiButtonIcon
size="xs"
iconType="pencil"
onClick={() => {
chatFlyoutSecondSlotHandler?.setVisibility?.(true);
if (triggerOptions) {
uiActions.getTrigger('IN_APP_EMBEDDABLE_EDIT_TRIGGER').exec(triggerOptions);
}
}}
data-test-subj="observabilityAiAssistantLensESQLEditButton"
aria-label={i18n.translate('xpack.observabilityAiAssistant.lensESQLFunction.edit', {
defaultMessage: 'Edit visualization',
})}
/>
</EuiToolTip>
<EuiFlexItem grow={false}>
<EuiToolTip
content={i18n.translate('xpack.observabilityAiAssistant.lensESQLFunction.save', {
defaultMessage: 'Save visualization',
})}
>
<EuiButtonIcon
size="xs"
iconType="save"
onClick={() => setIsSaveModalOpen(true)}
data-test-subj="observabilityAiAssistantLensESQLSaveButton"
aria-label={i18n.translate(
'xpack.observabilityAiAssistant.lensESQLFunction.save',
{
defaultMessage: 'Save visualization',
}
)}
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem data-test-subj="observabilityAiAssistantESQLLensChart">
<lens.EmbeddableComponent
{...lensInput}
style={{
height: 240,
}}
onLoad={onLoad}
/>
</EuiFlexItem>
</EuiFlexGroup>
{isSaveModalOpen ? (
<lens.SaveModalComponent
initialInput={lensInput}
onClose={() => {
setIsSaveModalOpen(() => false);
}}
// For now, we don't want to allow saving ESQL charts to the library
isSaveable={false}
/>
) : null}
</>
);
}
enum ChartType {
XY = 'XY',
Bar = 'Bar',
Line = 'Line',
Donut = 'Donut',
Heatmap = 'Heat map',
Treemap = 'Treemap',
Tagcloud = 'Tag cloud',
Waffle = 'Waffle',
}
export function registerVisualizeQueryRenderFunction({
service,
registerRenderFunction,
pluginsStart,
}: {
service: ObservabilityAIAssistantService;
registerRenderFunction: RegisterRenderFunctionDefinition;
pluginsStart: ObservabilityAIAssistantPluginStartDependencies;
}) {
registerRenderFunction(
'visualize_query',
({
arguments: { query, userOverrides, intention },
response,
onActionClick,
chatFlyoutSecondSlotHandler,
}: Parameters<RenderFunction<VisualizeESQLFunctionArguments, {}>>[0]) => {
const { content } = response as VisualizeLensResponse;
let preferredChartType: string | undefined;
switch (intention) {
case VisualizeESQLUserIntention.executeAndReturnResults:
case VisualizeESQLUserIntention.generateQueryOnly:
case VisualizeESQLUserIntention.visualizeAuto:
break;
case VisualizeESQLUserIntention.visualizeBar:
preferredChartType = ChartType.Bar;
break;
case VisualizeESQLUserIntention.visualizeDonut:
preferredChartType = ChartType.Donut;
break;
case VisualizeESQLUserIntention.visualizeHeatmap:
preferredChartType = ChartType.Heatmap;
break;
case VisualizeESQLUserIntention.visualizeLine:
preferredChartType = ChartType.Line;
break;
case VisualizeESQLUserIntention.visualizeTagcloud:
preferredChartType = ChartType.Tagcloud;
break;
case VisualizeESQLUserIntention.visualizeTreemap:
preferredChartType = ChartType.Treemap;
break;
case VisualizeESQLUserIntention.visualizeWaffle:
preferredChartType = ChartType.Waffle;
break;
case VisualizeESQLUserIntention.visualizeXy:
preferredChartType = ChartType.XY;
break;
}
return (
<VisualizeESQL
lens={pluginsStart.lens}
dataViews={pluginsStart.dataViews}
uiActions={pluginsStart.uiActions}
columns={content}
query={query}
onActionClick={onActionClick}
userOverrides={userOverrides}
chatFlyoutSecondSlotHandler={chatFlyoutSecondSlotHandler}
preferredChartType={preferredChartType}
/>
);
}
);
}

View file

@ -4,39 +4,35 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer } from '@elastic/eui';
import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
import { euiThemeVars } from '@kbn/ui-theme';
import React, { useMemo, useRef, useState } from 'react';
import usePrevious from 'react-use/lib/usePrevious';
import React, { useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { v4 } from 'uuid';
import { css } from '@emotion/css';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer, useEuiTheme } from '@elastic/eui';
import { euiThemeVars } from '@kbn/ui-theme';
import usePrevious from 'react-use/lib/usePrevious';
import { ChatBody } from '../../components/chat/chat_body';
import { ConversationList } from '../../components/chat/conversation_list';
import { ObservabilityAIAssistantChatServiceProvider } from '../../context/observability_ai_assistant_chat_service_provider';
import { useAbortableAsync } from '../../hooks/use_abortable_async';
import { useConfirmModal } from '../../hooks/use_confirm_modal';
import { useCurrentUser } from '../../hooks/use_current_user';
import { useForceUpdate } from '../../hooks/use_force_update';
import { useGenAIConnectors } from '../../hooks/use_genai_connectors';
import { useKibana } from '../../hooks/use_kibana';
import { useKnowledgeBase } from '../../hooks/use_knowledge_base';
import { useObservabilityAIAssistant } from '../../hooks/use_observability_ai_assistant';
import { useObservabilityAIAssistantParams } from '../../hooks/use_observability_ai_assistant_params';
import { useObservabilityAIAssistantRouter } from '../../hooks/use_observability_ai_assistant_router';
import { EMPTY_CONVERSATION_TITLE } from '../../i18n';
import { ChatInlineEditingContent } from '../../components/chat/chat_inline_edit';
const containerClassName = css`
max-width: 100%;
`;
const conversationListContainerName = css`
min-width: 250px;
width: 250px;
border-right: solid 1px ${euiThemeVars.euiColorLightShade};
`;
const SECOND_SLOT_CONTAINER_WIDTH = 400;
export function ConversationView() {
const { euiTheme } = useEuiTheme();
const currentUser = useCurrentUser();
const service = useObservabilityAIAssistant();
@ -49,24 +45,6 @@ export function ConversationView() {
const { path } = useObservabilityAIAssistantParams('/conversations/*');
const {
services: { notifications },
} = useKibana();
const { element: confirmDeleteElement, confirm: confirmDeleteFunction } = useConfirmModal({
title: i18n.translate('xpack.observabilityAiAssistant.confirmDeleteConversationTitle', {
defaultMessage: 'Delete this conversation?',
}),
children: i18n.translate('xpack.observabilityAiAssistant.confirmDeleteConversationContent', {
defaultMessage: 'This action cannot be undone.',
}),
confirmButtonText: i18n.translate('xpack.observabilityAiAssistant.confirmDeleteButtonText', {
defaultMessage: 'Delete conversation',
}),
});
const [isUpdatingList, setIsUpdatingList] = useState(false);
const chatService = useAbortableAsync(
({ signal }) => {
return service.start({ signal });
@ -87,6 +65,9 @@ export function ConversationView() {
const keepPreviousKeyRef = useRef(false);
const prevConversationId = usePrevious(conversationId);
const [secondSlotContainer, setSecondSlotContainer] = useState<HTMLDivElement | null>(null);
const [isSecondSlotVisible, setIsSecondSlotVisible] = useState(false);
if (conversationId !== prevConversationId && keepPreviousKeyRef.current === false) {
chatBodyKeyRef.current = v4();
}
@ -104,21 +85,6 @@ export function ConversationView() {
[service]
);
const displayedConversations = useMemo(() => {
return [
...(!conversationId ? [{ id: '', label: EMPTY_CONVERSATION_TITLE }] : []),
...(conversations.value?.conversations ?? []).map((conv) => ({
id: conv.conversation.id,
label: conv.conversation.title,
href: observabilityAIAssistantRouter.link('/conversations/{conversationId}', {
path: {
conversationId: conv.conversation.id,
},
}),
})),
];
}, [conversations.value?.conversations, conversationId, observabilityAIAssistantRouter]);
function navigateToConversation(nextConversationId?: string, usePrevConversationKey?: boolean) {
if (nextConversationId) {
observabilityAIAssistantRouter.push('/conversations/{conversationId}', {
@ -136,16 +102,59 @@ export function ConversationView() {
conversations.refresh();
}
const handleConversationUpdate = (conversation: { conversation: { id: string } }) => {
if (!conversationId) {
keepPreviousKeyRef.current = true;
navigateToConversation(conversation.conversation.id);
}
handleRefreshConversations();
};
useEffect(() => {
return () => {
setIsSecondSlotVisible(false);
if (secondSlotContainer) {
ReactDOM.unmountComponentAtNode(secondSlotContainer);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const conversationListContainerName = css`
min-width: 250px;
width: 250px;
border-right: solid 1px ${euiThemeVars.euiColorLightShade};
`;
const sidebarContainerClass = css`
display: flex;
position: absolute;
z-index: 1;
top: 56px;
right: 0;
height: calc(100% - 56px);
background-color: ${euiTheme.colors.lightestShade};
width: ${isSecondSlotVisible ? SECOND_SLOT_CONTAINER_WIDTH : 0}px;
border-top: solid 1px ${euiThemeVars.euiColorLightShade};
border-left: solid 1px ${euiThemeVars.euiColorLightShade};
.euiFlyoutHeader {
padding: ${euiTheme.size.m};
}
.euiFlyoutFooter {
padding: ${euiTheme.size.m};
padding-top: ${euiTheme.size.l};
padding-bottom: ${euiTheme.size.l};
}
`;
return (
<>
{confirmDeleteElement}
<EuiFlexGroup direction="row" className={containerClassName} gutterSize="none">
<EuiFlexItem grow={false} className={conversationListContainerName}>
<ConversationList
selected={conversationId ?? ''}
loading={conversations.loading || isUpdatingList}
error={conversations.error}
conversations={displayedConversations}
onClickNewChat={() => {
if (conversationId) {
observabilityAIAssistantRouter.push('/conversations/new', {
@ -158,55 +167,13 @@ export function ConversationView() {
forceUpdate();
}
}}
onClickChat={(id) => {
navigateToConversation(id, false);
}}
onClickDeleteConversation={(id) => {
confirmDeleteFunction()
.then(async (confirmed) => {
if (!confirmed) {
return;
}
setIsUpdatingList(true);
await service.callApi(
'DELETE /internal/observability_ai_assistant/conversation/{conversationId}',
{
params: {
path: {
conversationId: id,
},
},
signal: null,
}
);
const isCurrentConversation = id === conversationId;
const hasOtherConversations = conversations.value?.conversations.find(
(conv) => 'id' in conv.conversation && conv.conversation.id !== id
);
if (isCurrentConversation) {
navigateToConversation(
hasOtherConversations
? conversations.value!.conversations[0].conversation.id
: undefined
);
}
conversations.refresh();
})
.catch((error) => {
notifications.toasts.addError(error, {
title: i18n.translate(
'xpack.observabilityAiAssistant.failedToDeleteConversation',
{
defaultMessage: 'Could not delete conversation',
}
),
});
})
.finally(() => {
setIsUpdatingList(false);
});
if (conversationId === id) {
navigateToConversation(undefined, false);
}
}}
/>
<EuiSpacer size="s" />
@ -220,6 +187,7 @@ export function ConversationView() {
</EuiFlexItem>
</EuiFlexGroup>
) : null}
{chatService.value && (
<ObservabilityAIAssistantChatServiceProvider value={chatService.value}>
<ChatBody
@ -228,15 +196,22 @@ export function ConversationView() {
connectors={connectors}
initialConversationId={conversationId}
knowledgeBase={knowledgeBase}
showLinkToConversationsApp={false}
startedFrom="conversationView"
onConversationUpdate={(conversation) => {
if (!conversationId) {
keepPreviousKeyRef.current = true;
navigateToConversation(conversation.conversation.id);
}
handleRefreshConversations();
onConversationUpdate={handleConversationUpdate}
chatFlyoutSecondSlotHandler={{
container: secondSlotContainer,
setVisibility: setIsSecondSlotVisible,
}}
/>
<div className={sidebarContainerClass}>
<ChatInlineEditingContent
setContainer={setSecondSlotContainer}
visible={isSecondSlotVisible}
style={{ width: '100%' }}
/>
</div>
</ObservabilityAIAssistantChatServiceProvider>
)}
</EuiFlexGroup>

View file

@ -116,7 +116,7 @@ export async function createChatService({
return {
analytics,
renderFunction: (name, args, response) => {
renderFunction: (name, args, response, onActionClick, chatFlyoutSecondSlotHandler) => {
const fn = renderFunctionRegistry.get(name);
if (!fn) {
@ -130,7 +130,12 @@ export async function createChatService({
data: JSON.parse(response.data ?? '{}'),
};
return fn?.({ response: parsedResponse, arguments: parsedArguments });
return fn?.({
response: parsedResponse,
arguments: parsedArguments,
onActionClick,
chatFlyoutSecondSlotHandler,
});
},
getContexts: () => contextDefinitions,
getFunctions,
@ -191,7 +196,13 @@ export async function createChatService({
return new Observable<StreamingChatResponseEventWithoutError>((subscriber) => {
const contexts = ['core', 'apm'];
const functions = getFunctions({ contexts });
const functions = getFunctions({ contexts }).filter((fn) => {
const visibility = fn.visibility ?? FunctionVisibility.All;
return (
visibility === FunctionVisibility.All || visibility === FunctionVisibility.AssistantOnly
);
});
client('POST /internal/observability_ai_assistant/chat', {
params: {
@ -202,9 +213,7 @@ export async function createChatService({
functions:
callFunctions === 'none'
? []
: functions
.filter((fn) => fn.visibility !== FunctionVisibility.User)
.map((fn) => pick(fn, 'name', 'description', 'parameters')),
: functions.map((fn) => pick(fn, 'name', 'description', 'parameters')),
},
},
signal,

View file

@ -6,11 +6,12 @@
*/
import type { AnalyticsServiceStart } from '@kbn/core/public';
import type { FeaturesPluginStart, FeaturesPluginSetup } from '@kbn/features-plugin/public';
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import type {
DataViewsPublicPluginSetup,
DataViewsPublicPluginStart,
} from '@kbn/data-views-plugin/public';
import type { FeaturesPluginSetup, FeaturesPluginStart } from '@kbn/features-plugin/public';
import type { LensPublicSetup, LensPublicStart } from '@kbn/lens-plugin/public';
import type { ILicense, LicensingPluginStart } from '@kbn/licensing-plugin/public';
import { MlPluginSetup, MlPluginStart } from '@kbn/ml-plugin/public';
@ -39,6 +40,7 @@ import type {
Message,
PendingMessage,
} from '../common/types';
import type { ChatActionClickHandler, ChatFlyoutSecondSlotHandler } from './components/chat/types';
import type { ObservabilityAIAssistantAPIClient } from './api';
import type { InsightProps } from './components/insight/insight';
import type { UseGenAIConnectorsResult } from './hooks/use_genai_connectors';
@ -73,7 +75,9 @@ export interface ObservabilityAIAssistantChatService {
renderFunction: (
name: string,
args: string | undefined,
response: { data?: string; content?: string }
response: { data?: string; content?: string },
onActionClick: ChatActionClickHandler,
chatFlyoutSecondSlotHandler?: ChatFlyoutSecondSlotHandler
) => React.ReactNode;
}
@ -90,6 +94,8 @@ export interface ObservabilityAIAssistantService {
export type RenderFunction<TArguments, TResponse extends FunctionResponse> = (options: {
arguments: TArguments;
response: TResponse;
onActionClick: ChatActionClickHandler;
chatFlyoutSecondSlotHandler?: ChatFlyoutSecondSlotHandler;
}) => React.ReactNode;
export type RegisterRenderFunctionDefinition<
@ -122,6 +128,7 @@ export interface ObservabilityAIAssistantPluginStartDependencies {
security: SecurityPluginStart;
share: SharePluginStart;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
uiActions: UiActionsStart;
ml: MlPluginStart;
}

View file

@ -25,6 +25,7 @@ describe('getTimelineItemsFromConversation', () => {
hasConnector: true,
messages: [],
chatState: ChatState.Ready,
onActionClick: jest.fn(),
});
expect(items.length).toBe(1);
@ -57,6 +58,7 @@ describe('getTimelineItemsFromConversation', () => {
},
},
],
onActionClick: jest.fn(),
});
});
it('excludes the system message', () => {
@ -129,6 +131,7 @@ describe('getTimelineItemsFromConversation', () => {
},
},
],
onActionClick: jest.fn(),
});
});
@ -227,6 +230,11 @@ describe('getTimelineItemsFromConversation', () => {
},
},
],
onActionClick: jest.fn(),
chatFlyoutSecondSlotHandler: {
container: null,
setVisibility: jest.fn(),
},
});
});
@ -261,7 +269,9 @@ describe('getTimelineItemsFromConversation', () => {
expect(mockChatService.renderFunction).toHaveBeenCalledWith(
'my_render_function',
JSON.stringify({ foo: 'bar' }),
{ content: '[]', name: 'my_render_function', role: 'user' }
{ content: '[]', name: 'my_render_function', role: 'user' },
expect.any(Function),
{ container: null, setVisibility: expect.any(Function) }
);
expect(container.textContent).toEqual('Rendered');
@ -313,6 +323,7 @@ describe('getTimelineItemsFromConversation', () => {
},
},
],
onActionClick: jest.fn(),
});
});
@ -384,6 +395,7 @@ describe('getTimelineItemsFromConversation', () => {
},
},
],
onActionClick: jest.fn(),
});
});
@ -425,6 +437,7 @@ describe('getTimelineItemsFromConversation', () => {
},
},
],
onActionClick: jest.fn(),
});
});
@ -475,6 +488,7 @@ describe('getTimelineItemsFromConversation', () => {
},
},
],
onActionClick: jest.fn(),
});
});
@ -532,6 +546,7 @@ describe('getTimelineItemsFromConversation', () => {
},
...extraMessages,
],
onActionClick: jest.fn(),
});
};

View file

@ -17,6 +17,7 @@ import { RenderFunction } from '../components/render_function';
import type { ObservabilityAIAssistantChatService } from '../types';
import { ChatState } from '../hooks/use_chat';
import { safeJsonParse } from './safe_json_parse';
import type { ChatActionClickHandler, ChatFlyoutSecondSlotHandler } from '../components/chat/types';
function convertMessageToMarkdownCodeBlock(message: Message['message']) {
let value: object;
@ -64,6 +65,8 @@ export function getTimelineItemsfromConversation({
messages,
startedFrom,
chatState,
chatFlyoutSecondSlotHandler,
onActionClick,
}: {
chatService: ObservabilityAIAssistantChatService;
currentUser?: Pick<AuthenticatedUser, 'username' | 'full_name'>;
@ -71,6 +74,8 @@ export function getTimelineItemsfromConversation({
messages: Message[];
startedFrom?: StartedFrom;
chatState: ChatState;
chatFlyoutSecondSlotHandler?: ChatFlyoutSecondSlotHandler;
onActionClick: ChatActionClickHandler;
}): ChatTimelineItem[] {
const messagesWithoutSystem = messages.filter(
(message) => message.message.role !== MessageRole.System
@ -163,6 +168,8 @@ export function getTimelineItemsfromConversation({
name={message.message.name}
arguments={prevFunctionCall?.arguments}
response={message.message}
onActionClick={onActionClick}
chatFlyoutSecondSlotHandler={chatFlyoutSecondSlotHandler}
/>
) : undefined;

View file

@ -20,7 +20,7 @@ export function registerGetDatasetInfoFunction({
{
name: 'get_dataset_info',
contexts: ['core'],
visibility: FunctionVisibility.System,
visibility: FunctionVisibility.AssistantOnly,
description: `Use this function to get information about indices/datasets available and the fields available on them.
providing empty string as index name will retrieve all indices

View file

@ -11,10 +11,11 @@ import { registerSummarizationFunction } from './summarize';
import { ChatRegistrationFunction } from '../service/types';
import { registerAlertsFunction } from './alerts';
import { registerElasticsearchFunction } from './elasticsearch';
import { registerEsqlFunction } from './esql';
import { registerQueryFunction } from './query';
import { registerGetDatasetInfoFunction } from './get_dataset_info';
import { registerLensFunction } from './lens';
import { registerKibanaFunction } from './kibana';
import { registerVisualizeESQLFunction } from './visualize_esql';
export type FunctionRegistrationParameters = Omit<
Parameters<ChatRegistrationFunction>[0],
@ -50,19 +51,22 @@ export const registerFunctions: ChatRegistrationFunction = async ({
You can use Github-flavored Markdown in your responses. If a function returns an array, consider using a Markdown table to format the response.
If multiple functions are suitable, use the most specific and easy one. E.g., when the user asks to visualise APM data, use the APM functions (if available) rather than Lens.
If multiple functions are suitable, use the most specific and easy one. E.g., when the user asks to visualise APM data, use the APM functions (if available) rather than "query".
If a function call fails, DO NOT UNDER ANY CIRCUMSTANCES execute it again. Ask the user for guidance and offer them options.
Use the "get_dataset_info" function if it is not clear what fields or indices the user means, or if you want to get more information about the mappings.
Note that ES|QL (the Elasticsearch query language, which is NOT Elasticsearch SQL, but a new piped language) is the preferred query language.
Use the "get_dataset_info" function if it is not clear what fields or indices the user means, or if you want to get more information about the mappings.
If the user asks about a query, or ES|QL, always call the "esql" function. DO NOT UNDER ANY CIRCUMSTANCES generate ES|QL queries or explain anything about the ES|QL query language yourself.
Even if the "recall" function was used before that, follow it up with the "esql" function. If a query fails, do not attempt to correct it yourself. Again you should call the "esql" function,
If the user wants to visualize data, or run any arbitrary query, always use the "query" function. DO NOT UNDER ANY CIRCUMSTANCES generate ES|QL queries
or explain anything about the ES|QL query language yourself.
Even if the "recall" function was used before that, follow it up with the "query" function. If a query fails, do not attempt to correct it yourself. Again you should call the "query" function,
even if it has been called before.
If the "get_dataset_info" function returns no data, and the user asks for a query, generate a query anyway with the "esql" function, but be explicit about it potentially being incorrect.
When the "visualize_query" function has been called, a visualization has been displayed to the user. DO NOT UNDER ANY CIRCUMSTANCES follow up a "visualize_query" function call with your own visualization attempt.
If the "execute_query" function has been called, summarize these results for the user. The user does not see a visualization in this case.
If the "get_dataset_info" function returns no data, and the user asks for a query, generate a query anyway with the "query" function, but be explicit about it potentially being incorrect.
`
);
@ -76,13 +80,14 @@ export const registerFunctions: ChatRegistrationFunction = async ({
registerSummarizationFunction(registrationParameters);
registerRecallFunction(registrationParameters);
registerLensFunction(registrationParameters);
registerVisualizeESQLFunction(registrationParameters);
} else {
description += `You do not have a working memory. Don't try to recall information via the "recall" function. If the user expects you to remember the previous conversations, tell them they can set up the knowledge base. A banner is available at the top of the conversation to set this up.`;
}
registerElasticsearchFunction(registrationParameters);
registerKibanaFunction(registrationParameters);
registerEsqlFunction(registrationParameters);
registerQueryFunction(registrationParameters);
registerAlertsFunction(registrationParameters);
registerGetDatasetInfoFunction(registrationParameters);

Some files were not shown because too many files have changed in this diff Show more