mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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  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:
parent
6d5a485415
commit
25898e68bd
147 changed files with 1841 additions and 468 deletions
|
@ -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",
|
||||
|
|
|
@ -13,6 +13,7 @@ export {
|
|||
getKbnFieldType,
|
||||
getKbnTypeNames,
|
||||
getFilterableKbnTypeNames,
|
||||
esFieldTypeToKibanaFieldType,
|
||||
} from './src/kbn_field_types';
|
||||
|
||||
export type { KbnFieldTypeOptions } from './src/types';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
134
packages/kbn-visualization-utils/__mocks__/suggestions_mock.ts
Normal file
134
packages/kbn-visualization-utils/__mocks__/suggestions_mock.ts
Normal 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,
|
||||
};
|
|
@ -7,4 +7,5 @@
|
|||
*/
|
||||
|
||||
export { getTimeZone } from './src/get_timezone';
|
||||
export { getLensAttributesFromSuggestion } from './src/get_lens_attributes';
|
||||
export { TooltipWrapper } from './src/tooltip_wrapper';
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
64
packages/kbn-visualization-utils/src/get_lens_attributes.ts
Normal file
64
packages/kbn-visualization-utils/src/get_lens_attributes.ts
Normal 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;
|
||||
};
|
43
packages/kbn-visualization-utils/src/types.ts
Normal file
43
packages/kbn-visualization-utils/src/types.ts
Normal 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[];
|
||||
}
|
|
@ -9,5 +9,9 @@
|
|||
],
|
||||
"kbn_references": [
|
||||
"@kbn/core",
|
||||
"@kbn/i18n",
|
||||
"@kbn/interpreter",
|
||||
"@kbn/data-views-plugin",
|
||||
"@kbn/es-query",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
|
@ -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']>;
|
|
@ -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',
|
||||
}
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
"share",
|
||||
"taskManager",
|
||||
"triggersActionsUi",
|
||||
"uiActions",
|
||||
"dataViews",
|
||||
"ml"
|
||||
],
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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`;
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -142,6 +142,8 @@ export function PromptEditor({
|
|||
useEffect(() => {
|
||||
if (hidden) {
|
||||
onChangeHeight(0);
|
||||
} else if (containerRef.current) {
|
||||
onChangeHeight(containerRef.current.clientHeight);
|
||||
}
|
||||
}, [hidden, onChangeHeight]);
|
||||
|
||||
|
|
|
@ -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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue