diff --git a/common/constants/llm.ts b/common/constants/llm.ts index ff0673f0..f2839d75 100644 --- a/common/constants/llm.ts +++ b/common/constants/llm.ts @@ -34,3 +34,5 @@ export const NOTEBOOK_API = { }; export const DEFAULT_USER_NAME = 'User'; + +export const TEXT2VEGA_INPUT_SIZE_LIMIT = 400; diff --git a/common/constants/vis_type_nlq.ts b/common/constants/vis_type_nlq.ts new file mode 100644 index 00000000..dfb15a1a --- /dev/null +++ b/common/constants/vis_type_nlq.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const VIS_NLQ_SAVED_OBJECT = 'visualization-nlq'; +export const VIS_NLQ_APP_ID = 'text2viz'; diff --git a/common/types/config.ts b/common/types/config.ts index bf84a8da..ad8e340b 100644 --- a/common/types/config.ts +++ b/common/types/config.ts @@ -6,8 +6,7 @@ import { schema, TypeOf } from '@osd/config-schema'; export const configSchema = schema.object({ - // TODO: add here to prevent this plugin from being loaded - // enabled: schema.boolean({ defaultValue: true }), + enabled: schema.boolean({ defaultValue: true }), chat: schema.object({ enabled: schema.boolean({ defaultValue: false }), }), @@ -17,6 +16,15 @@ export const configSchema = schema.object({ next: schema.object({ enabled: schema.boolean({ defaultValue: false }), }), + text2viz: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), + alertInsight: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), + smartAnomalyDetector: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), }); export type ConfigSchema = TypeOf; diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index aef99132..736c1a09 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -8,6 +8,7 @@ "data", "dashboard", "embeddable", + "expressions", "opensearchDashboardsReact", "opensearchDashboardsUtils", "visualizations", diff --git a/public/components/visualization/editor_panel.tsx b/public/components/visualization/editor_panel.tsx new file mode 100644 index 00000000..cc247954 --- /dev/null +++ b/public/components/visualization/editor_panel.tsx @@ -0,0 +1,120 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useRef, useState } from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiButton, EuiButtonEmpty, EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { BehaviorSubject } from 'rxjs'; +import { useObservable } from 'react-use'; + +import { debounceTime } from 'rxjs/operators'; +import { CodeEditor } from '../../../../../src/plugins/opensearch_dashboards_react/public'; + +interface Props { + originalValue: string; + onApply: (value: string) => void; +} + +export const EditorPanel = (props: Props) => { + const [autoUpdate, setAutoUpdate] = useState(false); + const editorInputRef = useRef(new BehaviorSubject('')); + const editorInput = useObservable(editorInputRef.current) ?? ''; + + const editInputChanged = props.originalValue !== editorInput; + + useEffect(() => { + if (props.originalValue !== editorInputRef.current.value) { + editorInputRef.current.next(props.originalValue); + } + }, [props.originalValue]); + + useEffect(() => { + if (!autoUpdate) { + return; + } + const subscription = editorInputRef.current.pipe(debounceTime(1000)).subscribe((value) => { + props.onApply(value); + }); + return () => { + subscription.unsubscribe(); + }; + }, [autoUpdate, props.onApply]); + + return ( + <> +
+ editorInputRef.current.next(v)} + options={{ + readOnly: false, + lineNumbers: 'on', + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + folding: true, + automaticLayout: true, + }} + /> +
+ + {!autoUpdate && ( + <> + + editorInputRef.current.next(props.originalValue)} + > + {i18n.translate('dashboardAssistant.feature.text2viz.discardVegaSpecChange', { + defaultMessage: 'Discard', + })} + + + + props.onApply(editorInput)} + > + {i18n.translate('dashboardAssistant.feature.text2viz.updateVegaSpec', { + defaultMessage: 'Update', + })} + + + + )} + + setAutoUpdate((v) => !v)} + /> + + + + ); +}; diff --git a/public/components/visualization/embeddable/nlq_vis_embeddable.ts b/public/components/visualization/embeddable/nlq_vis_embeddable.ts new file mode 100644 index 00000000..f96669dc --- /dev/null +++ b/public/components/visualization/embeddable/nlq_vis_embeddable.ts @@ -0,0 +1,189 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import cloneDeep from 'lodash/cloneDeep'; +import { Subscription } from 'rxjs'; + +import { Embeddable, IContainer } from '../../../../../../src/plugins/embeddable/public'; +import { + ExpressionRenderError, + ExpressionsStart, + IExpressionLoaderParams, +} from '../../../../../../src/plugins/expressions/public'; +import { TimeRange } from '../../../../../../src/plugins/data/public'; +import { NLQVisualizationInput, NLQVisualizationOutput } from './types'; +import { getExpressions } from '../../../services'; +import { VIS_NLQ_APP_ID, VIS_NLQ_SAVED_OBJECT } from '../../../../common/constants/vis_type_nlq'; +import { PersistedState } from '../../../../../../src/plugins/visualizations/public'; + +type ExpressionLoader = InstanceType; + +interface NLQVisualizationEmbeddableConfig { + editUrl: string; + editPath: string; + editable: boolean; +} + +export const NLQ_VISUALIZATION_EMBEDDABLE_TYPE = VIS_NLQ_SAVED_OBJECT; + +const escapeString = (data: string): string => { + return data.replace(/\\/g, `\\\\`).replace(/'/g, `\\'`); +}; + +export class NLQVisualizationEmbeddable extends Embeddable< + NLQVisualizationInput, + NLQVisualizationOutput +> { + public readonly type = NLQ_VISUALIZATION_EMBEDDABLE_TYPE; + private handler?: ExpressionLoader; + private domNode?: HTMLDivElement; + private abortController?: AbortController; + private timeRange?: TimeRange; + private subscriptions: Subscription[] = []; + private uiState: PersistedState; + private visInput?: NLQVisualizationInput['visInput']; + + constructor( + initialInput: NLQVisualizationInput, + config?: NLQVisualizationEmbeddableConfig, + parent?: IContainer + ) { + super( + initialInput, + { + defaultTitle: initialInput.title, + editPath: config?.editPath ?? '', + editApp: VIS_NLQ_APP_ID, + editUrl: config?.editUrl ?? '', + editable: config?.editable, + visTypeName: 'Natural Language Query', + }, + parent + ); + // TODO: right now, there is nothing in ui state will trigger visualization to reload, so we set it to empty + // In the future, we may need to add something to ui state to trigger visualization to reload + this.uiState = new PersistedState(); + this.visInput = initialInput.visInput; + } + + /** + * Build expression for the visualization, it only supports vega type visualization now + */ + private buildPipeline = async () => { + if (!this.visInput?.visualizationState) { + return ''; + } + + let pipeline = `opensearchDashboards | opensearch_dashboards_context `; + pipeline += '| '; + + const visState = JSON.parse(this.visInput?.visualizationState ?? '{}'); + const params = visState.params ?? {}; + + if (visState.type === 'vega-lite' || visState.type === 'vega') { + if (params.spec) { + pipeline += `vega spec='${escapeString(JSON.stringify(params.spec))}'`; + } else { + return ''; + } + } + + return pipeline; + }; + + private updateHandler = async () => { + const expressionParams: IExpressionLoaderParams = { + searchContext: { + timeRange: this.timeRange, + query: this.input.query, + filters: this.input.filters, + }, + uiState: this.uiState, + }; + if (this.abortController) { + this.abortController.abort(); + } + this.abortController = new AbortController(); + const abortController = this.abortController; + + const expression = await this.buildPipeline(); + + if (this.handler && !abortController.signal.aborted) { + this.handler.update(expression, expressionParams); + } + }; + + onContainerError = (error: ExpressionRenderError) => { + if (this.abortController) { + this.abortController.abort(); + } + this.renderComplete.dispatchError(); + this.updateOutput({ loading: false, error }); + }; + + onContainerLoading = () => { + this.renderComplete.dispatchInProgress(); + this.updateOutput({ loading: true, error: undefined }); + }; + + onContainerRender = () => { + this.renderComplete.dispatchComplete(); + this.updateOutput({ loading: false, error: undefined }); + }; + + // TODO: fix inspector + public getInspectorAdapters = () => { + if (!this.handler) { + return undefined; + } + return this.handler.inspect(); + }; + + public async render(domNode: HTMLElement) { + this.timeRange = cloneDeep(this.input.timeRange); + + const div = document.createElement('div'); + div.className = `visualize panel-content panel-content--fullWidth`; + domNode.appendChild(div); + domNode.classList.add('text2viz-canvas'); + + this.domNode = div; + super.render(this.domNode); + + const expressions = getExpressions(); + this.handler = new expressions.ExpressionLoader(this.domNode, undefined, { + onRenderError: (element: HTMLElement, error: ExpressionRenderError) => { + this.onContainerError(error); + }, + }); + + if (this.handler) { + this.subscriptions.push(this.handler.loading$.subscribe(this.onContainerLoading)); + this.subscriptions.push(this.handler.render$.subscribe(this.onContainerRender)); + } + + this.updateHandler(); + } + + public updateInput(changes: Partial): void { + super.updateInput(changes); + this.visInput = changes.visInput; + this.reload(); + } + + public reload = () => { + this.updateHandler(); + }; + + public destroy() { + super.destroy(); + this.subscriptions.forEach((s) => s.unsubscribe()); + + if (this.handler) { + this.handler.destroy(); + this.handler.getElement().remove(); + } + } +} diff --git a/public/components/visualization/embeddable/nlq_vis_embeddable_factory.ts b/public/components/visualization/embeddable/nlq_vis_embeddable_factory.ts new file mode 100644 index 00000000..e8b24195 --- /dev/null +++ b/public/components/visualization/embeddable/nlq_vis_embeddable_factory.ts @@ -0,0 +1,86 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; + +import { + EmbeddableFactoryDefinition, + ErrorEmbeddable, + IContainer, +} from '../../../../../../src/plugins/embeddable/public'; +import { VisNLQSavedObject } from '../../../vis_nlq/types'; +import { getVisNLQSavedObjectLoader } from '../../../vis_nlq/saved_object_loader'; +import { NLQVisualizationInput } from './types'; +import { + NLQ_VISUALIZATION_EMBEDDABLE_TYPE, + NLQVisualizationEmbeddable, +} from './nlq_vis_embeddable'; +import { VIS_NLQ_APP_ID, VIS_NLQ_SAVED_OBJECT } from '../../../../common/constants/vis_type_nlq'; +import { SavedObjectMetaData } from '../../../../../../src/plugins/saved_objects/public'; +import { getHttp } from '../../../services'; + +export class NLQVisualizationEmbeddableFactory implements EmbeddableFactoryDefinition { + public readonly type = NLQ_VISUALIZATION_EMBEDDABLE_TYPE; + + // TODO: it may need control on whether it's editable or not + public async isEditable() { + return true; + } + + public getDisplayName() { + return i18n.translate('nlq.vis.displayName', { + defaultMessage: 'Visualization with natural language', + }); + } + + public readonly savedObjectMetaData: SavedObjectMetaData = { + name: 'Natural language visualization', + includeFields: ['visualizationState'], + type: VIS_NLQ_SAVED_OBJECT, + getIconForSavedObject: () => 'chatRight', + }; + + public async createFromSavedObject( + savedObjectId: string, + input: Partial & { id: string }, + parent?: IContainer + ): Promise { + const loader = getVisNLQSavedObjectLoader(); + const editPath = `/edit/${savedObjectId}`; + const editUrl = getHttp().basePath.prepend(`/app/${VIS_NLQ_APP_ID}${editPath}`); + + try { + const savedObject: VisNLQSavedObject = await loader.get(savedObjectId); + return new NLQVisualizationEmbeddable( + { + ...input, + visInput: { + title: savedObject.title, + description: savedObject.description, + visualizationState: savedObject.visualizationState, + uiState: savedObject.uiState, + ...input.visInput, + }, + savedObjectId, + title: savedObject.title, + }, + { editUrl, editPath, editable: true }, + parent + ); + } catch (e) { + return new ErrorEmbeddable(e, input, parent); + } + } + + public async create(input: NLQVisualizationInput, parent?: IContainer) { + if (input.visInput) { + const editPath = `/edit/${input.savedObjectId}`; + const editUrl = getHttp().basePath.prepend(`/app/${VIS_NLQ_APP_ID}${editPath}`); + return new NLQVisualizationEmbeddable(input, { editUrl, editPath, editable: true }, parent); + } else { + return undefined; + } + } +} diff --git a/public/components/visualization/embeddable/types.ts b/public/components/visualization/embeddable/types.ts new file mode 100644 index 00000000..ff5ce2be --- /dev/null +++ b/public/components/visualization/embeddable/types.ts @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EmbeddableFactoryDefinition, + EmbeddableOutput, + ErrorEmbeddable, + IContainer, + EmbeddableInput, + SavedObjectEmbeddableInput, +} from '../../../../../../src/plugins/embeddable/public'; + +interface VisInput { + title?: string; + description?: string; + visualizationState?: string; + uiState?: string; +} + +export interface NLQVisualizationInput extends SavedObjectEmbeddableInput { + visInput?: VisInput; +} + +export interface NLQVisualizationOutput extends EmbeddableOutput { + editPath: string; + editApp: string; + editUrl: string; + visTypeName: string; +} diff --git a/public/components/visualization/text2vega.ts b/public/components/visualization/text2vega.ts index 9621bd2b..873c1ef1 100644 --- a/public/components/visualization/text2vega.ts +++ b/public/components/visualization/text2vega.ts @@ -6,23 +6,12 @@ import { BehaviorSubject, Observable, of } from 'rxjs'; import { debounceTime, switchMap, tap, filter, catchError } from 'rxjs/operators'; import { TEXT2VIZ_API } from '.../../../common/constants/llm'; -import { HttpSetup } from '../../../../../src/core/public'; +import { HttpSetup, SavedObjectsStart } from '../../../../../src/core/public'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; - -const DATA_SOURCE_DELIMITER = '::'; +import { DataSourceAttributes } from '../../../../../src/plugins/data_source/common/data_sources'; const topN = (ppl: string, n: number) => `${ppl} | head ${n}`; -const getDataSourceAndIndexFromLabel = (label: string) => { - if (label.includes(DATA_SOURCE_DELIMITER)) { - return [ - label.slice(0, label.indexOf(DATA_SOURCE_DELIMITER)), - label.slice(label.indexOf(DATA_SOURCE_DELIMITER) + DATA_SOURCE_DELIMITER.length), - ] as const; - } - return [, label] as const; -}; - interface Input { prompt: string; index: string; @@ -36,10 +25,16 @@ export class Text2Vega { status$ = new BehaviorSubject<'RUNNING' | 'STOPPED'>('STOPPED'); http: HttpSetup; searchClient: DataPublicPluginStart['search']; + savedObjects: SavedObjectsStart; - constructor(http: HttpSetup, searchClient: DataPublicPluginStart['search']) { + constructor( + http: HttpSetup, + searchClient: DataPublicPluginStart['search'], + savedObjects: SavedObjectsStart + ) { this.http = http; this.searchClient = searchClient; + this.savedObjects = savedObjects; this.result$ = this.input$ .pipe( filter((v) => v.prompt.length > 0), @@ -51,9 +46,8 @@ export class Text2Vega { of(v).pipe( // text to ppl switchMap(async (value) => { - const [, indexName] = getDataSourceAndIndexFromLabel(value.index); const pplQuestion = value.prompt.split('//')[0]; - const ppl = await this.text2ppl(pplQuestion, indexName, value.dataSourceId); + const ppl = await this.text2ppl(pplQuestion, value.index, value.dataSourceId); return { ...value, ppl, @@ -80,7 +74,8 @@ export class Text2Vega { dataSchema: JSON.stringify(value.sample.schema), dataSourceId: value.dataSourceId, }); - const [dataSourceName] = getDataSourceAndIndexFromLabel(value.index); + const dataSource = await this.getDataSourceById(value.dataSourceId); + const dataSourceName = dataSource?.attributes.title; result.data = { url: { '%type%': 'ppl', @@ -123,9 +118,11 @@ export class Text2Vega { } } }; + const [inputQuestion, inputInstruction = ''] = input.split('//'); const res = await this.http.post(TEXT2VIZ_API.TEXT2VEGA, { body: JSON.stringify({ - input, + input_question: inputQuestion.trim(), + input_instruction: inputInstruction.trim(), ppl, sampleData: JSON.stringify(sampleData), dataSchema: JSON.stringify(dataSchema), @@ -149,6 +146,18 @@ export class Text2Vega { return pplResponse.ppl; } + async getDataSourceById(id?: string) { + if (!id) { + return null; + } + + const res = await this.savedObjects.client.get('data-source', id); + if (res.error) { + return null; + } + return res; + } + invoke(value: Input) { this.input$.next(value); } diff --git a/public/components/visualization/text2viz.scss b/public/components/visualization/text2viz.scss index ead70f30..f2b0f84b 100644 --- a/public/components/visualization/text2viz.scss +++ b/public/components/visualization/text2viz.scss @@ -1,6 +1,15 @@ .text2viz__page { + .text2viz-canvas { + height: 100%; + } + .visualize { - height: 400px; + height: 100%; + background-color: $euiColorEmptyShade; + + .visChart__container { + overflow: unset; + } } .text2viz__right { diff --git a/public/components/visualization/text2viz.tsx b/public/components/visualization/text2viz.tsx index 8a4ead6c..5ce9abb8 100644 --- a/public/components/visualization/text2viz.tsx +++ b/public/components/visualization/text2viz.tsx @@ -4,27 +4,26 @@ */ import { - EuiPageBody, EuiPage, - EuiPageContent, - EuiPageContentBody, EuiFlexGroup, EuiFlexItem, EuiFieldText, EuiIcon, EuiButtonIcon, - EuiButton, EuiBreadcrumb, EuiHeaderLinks, + EuiResizableContainer, + EuiSpacer, + EuiText, } from '@elastic/eui'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { i18n } from '@osd/i18n'; import { useCallback } from 'react'; import { useObservable } from 'react-use'; -import { useMemo } from 'react'; +import { useParams } from 'react-router-dom'; import { SourceSelector } from './source_selector'; -import type { DataSourceOption } from '../../../../../src/plugins/data/public'; +import type { IndexPattern } from '../../../../../src/plugins/data/public'; import chatIcon from '../../assets/chat.svg'; import { EmbeddableRenderer } from '../../../../../src/plugins/embeddable/public'; import { @@ -33,11 +32,6 @@ import { toMountPoint, } from '../../../../../src/plugins/opensearch_dashboards_react/public'; import { StartServices } from '../../types'; -import { - VISUALIZE_EMBEDDABLE_TYPE, - VisSavedObject, - VisualizeInput, -} from '../../../../../src/plugins/visualizations/public'; import './text2viz.scss'; import { Text2VizEmpty } from './text2viz_empty'; import { Text2VizLoading } from './text2viz_loading'; @@ -46,28 +40,65 @@ import { OnSaveProps, SavedObjectSaveModalOrigin, } from '../../../../../src/plugins/saved_objects/public'; +import { getVisNLQSavedObjectLoader } from '../../vis_nlq/saved_object_loader'; +import { VisNLQSavedObject } from '../../vis_nlq/types'; +import { getIndexPatterns } from '../../services'; +import { NLQ_VISUALIZATION_EMBEDDABLE_TYPE } from './embeddable/nlq_vis_embeddable'; +import { NLQVisualizationInput } from './embeddable/types'; +import { EditorPanel } from './editor_panel'; +import { VIS_NLQ_SAVED_OBJECT } from '../../../common/constants/vis_type_nlq'; +import { HeaderVariant } from '../../../../../src/core/public'; +import { TEXT2VEGA_INPUT_SIZE_LIMIT } from '../../../common/constants/llm'; export const Text2Viz = () => { - const [selectedSource, setSelectedSource] = useState(); + const { savedObjectId } = useParams<{ savedObjectId?: string }>(); + const [selectedSource, setSelectedSource] = useState(''); + const [savedObjectLoading, setSavedObjectLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); const { services: { application, chrome, embeddable, - visualizations, http, notifications, setHeaderActionMenu, overlays, data, + uiSettings, + savedObjects, }, } = useOpenSearchDashboards(); + + const useUpdatedUX = uiSettings.get('home:useNewHomePage'); + const [input, setInput] = useState(''); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const [vegaSpec, setVegaSpec] = useState>(); - const text2vegaRef = useRef(new Text2Vega(http, data.search)); + const [editorInput, setEditorInput] = useState(''); + const text2vegaRef = useRef(new Text2Vega(http, data.search, savedObjects)); + const status = useObservable(text2vegaRef.current.status$); + const vegaSpec = useMemo(() => { + if (!editorInput) { + return undefined; + } + + try { + return JSON.parse(editorInput); + } catch (e) { + // TODO: handle error state + return undefined; + } + }, [editorInput]); + + /** + * The index pattern of current generated visualization used + */ + const currentUsedIndexPatternRef = useRef(); + + /** + * Subscribe to text to visualization result changes + */ useEffect(() => { const text2vega = text2vegaRef.current; const subscription = text2vega.getResult$().subscribe((result) => { @@ -79,7 +110,7 @@ export const Text2Viz = () => { }), }); } else { - setVegaSpec(result); + setEditorInput(JSON.stringify(result, undefined, 4)); } } }); @@ -89,57 +120,113 @@ export const Text2Viz = () => { }; }, [http, notifications]); - const onInputChange = useCallback((e: React.ChangeEvent) => { - setInput(e.target.value); - }, []); + /** + * Loads the saved object from id when editing an existing visualization + */ + useEffect(() => { + if (savedObjectId) { + const loader = getVisNLQSavedObjectLoader(); + setSavedObjectLoading(true); + loader + .get(savedObjectId) + .then((savedVis) => { + if (savedVis?.visualizationState) { + const spec = JSON.parse(savedVis.visualizationState ?? '{}').params?.spec; + const indexId = savedVis.searchSourceFields?.index; + if (spec) { + setEditorInput(JSON.stringify(spec, undefined, 4)); + } + if (indexId) { + setSelectedSource(indexId); + } + } + if (savedVis?.uiState) { + setInput(JSON.parse(savedVis.uiState ?? '{}').input); + } + }) + .catch(() => { + notifications.toasts.addDanger({ + title: i18n.translate('dashboardAssistant.feature.text2viz.loadFailed', { + defaultMessage: `Failed to load saved object: '{title}'`, + values: { + title: savedObjectId, + }, + }), + }); + }) + .finally(() => { + setSavedObjectLoading(false); + }); + } + }, [savedObjectId, notifications]); + /** + * Submit user's natural language input to generate visualization + */ const onSubmit = useCallback(async () => { - setVegaSpec(undefined); - const text2vega = text2vegaRef.current; - if (selectedSource?.label) { - const dataSource = (await selectedSource.ds.getDataSet()).dataSets.find( - (ds) => ds.title === selectedSource.label - ); - text2vega.invoke({ - index: selectedSource.label, - prompt: input, - dataSourceId: dataSource?.dataSourceId, + if (status === 'RUNNING' || !selectedSource) return; + + const [inputQuestion = '', inputInstruction = ''] = input.split('//'); + if ( + inputQuestion.trim().length > TEXT2VEGA_INPUT_SIZE_LIMIT || + inputInstruction.trim().length > TEXT2VEGA_INPUT_SIZE_LIMIT + ) { + notifications.toasts.addDanger({ + title: i18n.translate('dashboardAssistant.feature.text2viz.invalidInput', { + defaultMessage: `Input size exceed limit: {limit}. Actual size: question({inputQuestionLength}), instruction({inputInstructionLength})`, + values: { + limit: TEXT2VEGA_INPUT_SIZE_LIMIT, + inputQuestionLength: inputQuestion.trim().length, + inputInstructionLength: inputInstruction.trim().length, + }, + }), }); + return; } - }, [selectedSource, input]); - const factory = embeddable.getEmbeddableFactory(VISUALIZE_EMBEDDABLE_TYPE); - const vis = useMemo(() => { - return vegaSpec - ? visualizations.convertToSerializedVis({ - title: vegaSpec?.title ?? 'vega', - description: vegaSpec?.description ?? '', - visState: { - title: vegaSpec?.title ?? 'vega', - type: 'vega', - aggs: [], - params: { - spec: JSON.stringify(vegaSpec, null, 4), - }, - }, - }) - : null; - }, [vegaSpec]); + setSubmitting(true); + + const indexPatterns = getIndexPatterns(); + const indexPattern = await indexPatterns.get(selectedSource); + currentUsedIndexPatternRef.current = indexPattern; + + const text2vega = text2vegaRef.current; + text2vega.invoke({ + index: indexPattern.title, + prompt: input, + dataSourceId: indexPattern.dataSourceRef?.id, + }); + setSubmitting(false); + }, [selectedSource, input, status]); + + /** + * Display the save visualization dialog to persist the current generated visualization + */ const onSaveClick = useCallback(async () => { - if (!vis) return; + if (!vegaSpec || !selectedSource) return; const doSave = async (onSaveProps: OnSaveProps) => { - const savedVis: VisSavedObject = await visualizations.savedVisualizationsLoader.get(); // .createVis('vega', vis) - savedVis.visState = { + const indexPattern = currentUsedIndexPatternRef.current; + const loader = getVisNLQSavedObjectLoader(); + const savedVis: VisNLQSavedObject = await loader.get(); + + savedVis.visualizationState = JSON.stringify({ title: onSaveProps.newTitle, - type: vis.type, - params: vis.params, - aggs: [], - }; + type: 'vega-lite', + params: { + spec: vegaSpec, + }, + }); + savedVis.uiState = JSON.stringify({ + input, + }); + savedVis.searchSourceFields = { index: indexPattern }; savedVis.title = onSaveProps.newTitle; savedVis.description = onSaveProps.newDescription; savedVis.copyOnSave = onSaveProps.newCopyOnSave; + savedVis.id = savedObjectId ?? ''; + try { const id = await savedVis.save({ isTitleDuplicateConfirmed: onSaveProps.isTitleDuplicateConfirmed, @@ -171,112 +258,176 @@ export const Text2Viz = () => { const dialog = overlays.openModal( toMountPoint( dialog.close()} onSave={doSave} /> ) ); - }, [vis, visualizations, notifications]); + }, [notifications, vegaSpec, input, overlays, selectedSource, savedObjectId]); + + const pageTitle = savedObjectId + ? i18n.translate('dashboardAssistant.feature.text2viz.breadcrumbs.editVisualization', { + defaultMessage: 'Edit visualization', + }) + : i18n.translate('dashboardAssistant.feature.text2viz.breadcrumbs.newVisualization', { + defaultMessage: 'New visualization', + }); useEffect(() => { const breadcrumbs: EuiBreadcrumb[] = [ { - text: 'Visualize', + text: i18n.translate('dashboardAssistant.feature.text2viz.breadcrumbs.visualize', { + defaultMessage: 'Visualize', + }), onClick: () => { application.navigateToApp('visualize'); }, }, - { - text: 'Create', - }, ]; + if (!useUpdatedUX) { + breadcrumbs.push({ + text: pageTitle, + }); + } chrome.setBreadcrumbs(breadcrumbs); - }, [chrome, application]); + }, [chrome, application, pageTitle, useUpdatedUX]); + + const visInput: NLQVisualizationInput = useMemo(() => { + return { + id: 'text2viz', + title: vegaSpec?.title ?? '', + visInput: { + title: vegaSpec?.title ?? '', + visualizationState: JSON.stringify({ + title: vegaSpec?.title ?? '', + type: 'vega-lite', + params: { + spec: vegaSpec, + }, + }), + }, + savedObjectId: savedObjectId ?? '', + }; + }, [vegaSpec, savedObjectId]); + + useEffect(() => { + chrome.setHeaderVariant(HeaderVariant.APPLICATION); + return () => { + chrome.setHeaderVariant(); + }; + }, [chrome]); + + const factory = embeddable.getEmbeddableFactory( + NLQ_VISUALIZATION_EMBEDDABLE_TYPE + ); + + const getInputSection = () => { + return ( + <> + + setSelectedSource(ds.value)} + /> + + + setInput(e.target.value)} + fullWidth + compressed + prepend={} + placeholder="Generate visualization with a natural language question." + onKeyDown={(e) => e.key === 'Enter' && onSubmit()} + /> + + + + + + ); + }; + + const loading = status === 'RUNNING' || savedObjectLoading || submitting; + const noResult = !loading && status === 'STOPPED' && !vegaSpec && !savedObjectLoading; + const resultLoaded = !loading && status === 'STOPPED' && vegaSpec; return ( - + - - - {i18n.translate('dashboardAssistant.feature.text2viz.save', { - defaultMessage: 'Save', - })} - - + + + {useUpdatedUX && {pageTitle}} + + + {useUpdatedUX && getInputSection()} + - - - - - - setSelectedSource(ds)} - /> - - - } - placeholder="Generate visualization with a natural language question." - /> - - - - - - {status === 'STOPPED' && !vegaSpec && ( - - - - - - )} - {status === 'RUNNING' && ( - - - - - - )} - {status === 'STOPPED' && vis && ( - - - {factory && ( - - )} - - - )} - - - + {!useUpdatedUX && ( + <> + + {getInputSection()} + + + + )} + {noResult && } + {loading && } + {resultLoaded && factory && ( + + {(EuiResizablePanel, EuiResizableButton) => { + return ( + <> + + + + + + setEditorInput(v)} /> + + + ); + }} + + )} ); }; diff --git a/public/components/visualization/text2viz_empty.tsx b/public/components/visualization/text2viz_empty.tsx index f158f6a1..3f3e116c 100644 --- a/public/components/visualization/text2viz_empty.tsx +++ b/public/components/visualization/text2viz_empty.tsx @@ -10,6 +10,7 @@ import { i18n } from '@osd/i18n'; export const Text2VizEmpty = () => { return ( diff --git a/public/components/visualization/text2viz_loading.tsx b/public/components/visualization/text2viz_loading.tsx index 8d21fd80..4dcf7be1 100644 --- a/public/components/visualization/text2viz_loading.tsx +++ b/public/components/visualization/text2viz_loading.tsx @@ -7,17 +7,25 @@ import React from 'react'; import { EuiEmptyPrompt, EuiLoadingLogo } from '@elastic/eui'; import { i18n } from '@osd/i18n'; -export const Text2VizLoading = () => { +interface Props { + type: 'loading' | 'generating'; +} + +const MESSAGES = { + loading: i18n.translate('dashboardAssistant.feature.text2viz.loading', { + defaultMessage: 'Loading Visualization', + }), + generating: i18n.translate('dashboardAssistant.feature.text2viz.generating', { + defaultMessage: 'Generating Visualization', + }), +}; + +export const Text2VizLoading = ({ type }: Props) => { return ( } - title={ -

- {i18n.translate('dashboardAssistant.feature.text2viz.loading', { - defaultMessage: 'Generating Visualization', - })} -

- } + title={

{MESSAGES[type]}

} /> ); }; diff --git a/public/index.scss b/public/index.scss index 9b7f9e77..be693d9f 100644 --- a/public/index.scss +++ b/public/index.scss @@ -183,3 +183,8 @@ button.llm-chat-error-refresh-button.llm-chat-error-refresh-button { .osdQueryEditorExtensionComponent__assistant-query-actions { margin-left: auto; } + +.text2viz-wrapper { + display: flex; + flex-grow: 1; +} diff --git a/public/plugin.tsx b/public/plugin.tsx index bc152665..2a54c0cb 100644 --- a/public/plugin.tsx +++ b/public/plugin.tsx @@ -37,9 +37,12 @@ import { ConversationsService, setChrome, setNotifications, + setIndexPatterns, setIncontextInsightRegistry, setConfigSchema, setUiActions, + setExpressions, + setHttp, } from './services'; import { ConfigSchema } from '../common/types/config'; import { DataSourceService } from './services/data_source_service'; @@ -49,6 +52,13 @@ import { AssistantService } from './services/assistant_service'; import { ActionContextMenu } from './components/ui_action_context_menu'; import { AI_ASSISTANT_QUERY_EDITOR_TRIGGER, bootstrap } from './ui_triggers'; import { TEXT2VIZ_APP_ID } from './text2viz'; +import { VIS_NLQ_APP_ID, VIS_NLQ_SAVED_OBJECT } from '../common/constants/vis_type_nlq'; +import { + createVisNLQSavedObjectLoader, + setVisNLQSavedObjectLoader, +} from './vis_nlq/saved_object_loader'; +import { NLQVisualizationEmbeddableFactory } from './components/visualization/embeddable/nlq_vis_embeddable_factory'; +import { NLQ_VISUALIZATION_EMBEDDABLE_TYPE } from './components/visualization/embeddable/nlq_vis_embeddable'; export const [getCoreStart, setCoreStart] = createGetterSetter('CoreStart'); @@ -114,11 +124,16 @@ export class AssistantPlugin dataSourceManagement: setupDeps.dataSourceManagement, }); - if (this.config.next.enabled) { + if (this.config.text2viz.enabled) { + setupDeps.embeddable.registerEmbeddableFactory( + NLQ_VISUALIZATION_EMBEDDABLE_TYPE, + new NLQVisualizationEmbeddableFactory() + ); + setupDeps.visualizations.registerAlias({ name: 'text2viz', aliasPath: '#/', - aliasApp: 'text2viz', + aliasApp: VIS_NLQ_APP_ID, title: i18n.translate('dashboardAssistant.feature.text2viz.title', { defaultMessage: 'Natural language', }), @@ -136,6 +151,23 @@ export class AssistantPlugin 'Not sure which visualization to choose? Generate visualization previews with a natural language question.', }), }, + appExtensions: { + visualizations: { + docTypes: [VIS_NLQ_SAVED_OBJECT], + toListItem: ({ id, attributes, updated_at: updatedAt }) => ({ + description: attributes?.description, + editApp: VIS_NLQ_APP_ID, + editUrl: `/edit/${encodeURIComponent(id)}`, + icon: 'chatRight', + id, + savedObjectType: VIS_NLQ_SAVED_OBJECT, + title: attributes?.title, + typeTitle: 'NLQ', + updated_at: updatedAt, + stage: 'experimental', + }), + }, + }, }); core.application.register({ @@ -146,12 +178,18 @@ export class AssistantPlugin navLinkStatus: AppNavLinkStatus.hidden, mount: async (params: AppMountParameters) => { const [coreStart, pluginsStart] = await core.getStartServices(); + params.element.classList.add('text2viz-wrapper'); const { renderText2VizApp } = await import('./text2viz'); - return renderText2VizApp(params, { - ...coreStart, + const unmount = renderText2VizApp(params, { ...pluginsStart, + ...coreStart, setHeaderActionMenu: params.setHeaderActionMenu, }); + + return () => { + unmount(); + params.element.classList.remove('text2viz-wrapper'); + }; }, }); } @@ -222,6 +260,13 @@ export class AssistantPlugin }, chatEnabled: () => this.config.chat.enabled, nextEnabled: () => this.config.next.enabled, + getFeatureStatus: () => ({ + chat: this.config.chat.enabled, + next: this.config.next.enabled, + text2viz: this.config.text2viz.enabled, + alertInsight: this.config.alertInsight.enabled, + smartAnomalyDetector: this.config.smartAnomalyDetector.enabled, + }), assistantActions, assistantTriggers: { AI_ASSISTANT_QUERY_EDITOR_TRIGGER, @@ -240,7 +285,7 @@ export class AssistantPlugin public start( core: CoreStart, - { data, uiActions }: AssistantPluginStartDependencies + { data, expressions, uiActions }: AssistantPluginStartDependencies ): AssistantStart { const assistantServiceStart = this.assistantService.start(core.http); setCoreStart(core); @@ -249,7 +294,7 @@ export class AssistantPlugin setConfigSchema(this.config); setUiActions(uiActions); - if (this.config.next.enabled) { + if (this.config.text2viz.enabled) { uiActions.addTriggerAction(AI_ASSISTANT_QUERY_EDITOR_TRIGGER, { id: 'assistant_generate_visualization_action', order: 1, @@ -259,8 +304,20 @@ export class AssistantPlugin core.application.navigateToApp(TEXT2VIZ_APP_ID); }, }); + const savedVisNLQLoader = createVisNLQSavedObjectLoader({ + savedObjectsClient: core.savedObjects.client, + indexPatterns: data.indexPatterns, + search: data.search, + chrome: core.chrome, + overlays: core.overlays, + }); + setVisNLQSavedObjectLoader(savedVisNLQLoader); } + setIndexPatterns(data.indexPatterns); + setExpressions(expressions); + setHttp(core.http); + return { dataSource: this.dataSourceService.start(), assistantClient: assistantServiceStart.client, diff --git a/public/services/index.ts b/public/services/index.ts index c6963e74..e515769d 100644 --- a/public/services/index.ts +++ b/public/services/index.ts @@ -5,9 +5,11 @@ import { createGetterSetter } from '../../../../src/plugins/opensearch_dashboards_utils/public'; import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; -import { ChromeStart, NotificationsStart } from '../../../../src/core/public'; +import { ChromeStart, HttpStart, NotificationsStart } from '../../../../src/core/public'; import { IncontextInsightRegistry } from './incontext_insight'; import { ConfigSchema } from '../../common/types/config'; +import { IndexPatternsContract } from '../../../../src/plugins/data/public'; +import { ExpressionsStart } from '../../../../src/plugins/expressions/public'; export * from './incontext_insight'; export { ConversationLoadService } from './conversation_load_service'; @@ -27,4 +29,12 @@ export const [getConfigSchema, setConfigSchema] = createGetterSetter('uiActions'); +export const [getIndexPatterns, setIndexPatterns] = createGetterSetter( + 'IndexPatterns' +); + +export const [getExpressions, setExpressions] = createGetterSetter('Expressions'); + +export const [getHttp, setHttp] = createGetterSetter('Http'); + export { DataSourceService, DataSourceServiceContract } from './data_source_service'; diff --git a/public/text2viz.tsx b/public/text2viz.tsx index 5b066d90..7ffef5b1 100644 --- a/public/text2viz.tsx +++ b/public/text2viz.tsx @@ -5,6 +5,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { Route, Router, Switch } from 'react-router-dom'; + import { AppMountParameters } from '../../../src/core/public'; import { Text2Viz } from './components/visualization/text2viz'; import { OpenSearchDashboardsContextProvider } from '../../../src/plugins/opensearch_dashboards_react/public'; @@ -15,7 +17,11 @@ export const TEXT2VIZ_APP_ID = 'text2viz'; export const renderText2VizApp = (params: AppMountParameters, services: StartServices) => { ReactDOM.render( - + + + + + , params.element diff --git a/public/types.ts b/public/types.ts index b7250bab..0212dc40 100644 --- a/public/types.ts +++ b/public/types.ts @@ -18,6 +18,8 @@ import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../src/plugi import { AppMountParameters, CoreStart } from '../../../src/core/public'; import { AssistantClient } from './services/assistant_client'; import { UiActionsSetup, UiActionsStart } from '../../../src/plugins/ui_actions/public'; +import { ExpressionsSetup, ExpressionsStart } from '../../../src/plugins/expressions/public'; +import { SavedObjectsStart } from '../../../src/plugins/saved_objects/public'; export interface RenderProps { props: MessageContentProps; @@ -42,6 +44,8 @@ export interface AssistantPluginStartDependencies { embeddable: EmbeddableStart; dashboard: DashboardStart; uiActions: UiActionsStart; + expressions: ExpressionsStart; + savedObjects: SavedObjectsStart; } export interface AssistantPluginSetupDependencies { @@ -50,6 +54,7 @@ export interface AssistantPluginSetupDependencies { embeddable: EmbeddableSetup; dataSourceManagement?: DataSourceManagementPluginSetup; uiActions: UiActionsSetup; + expressions: ExpressionsSetup; } export interface AssistantSetup { @@ -57,13 +62,22 @@ export interface AssistantSetup { registerMessageRenderer: (contentType: string, render: MessageRenderer) => void; registerActionExecutor: (actionType: string, execute: ActionExecutor) => void; /** + * @deprecated please use `getFeatureStatus()` * Returns true if chat UI is enabled. */ chatEnabled: () => boolean; /** + * @deprecated please use `getFeatureStatus()` * Returns true if contextual assistant is enabled. */ nextEnabled: () => boolean; + getFeatureStatus: () => { + chat: boolean; + next: boolean; + text2viz: boolean; + alertInsight: boolean; + smartAnomalyDetector: boolean; + }; assistantActions: Omit; assistantTriggers: { AI_ASSISTANT_QUERY_EDITOR_TRIGGER: string }; registerIncontextInsight: IncontextInsightRegistry['register']; @@ -76,7 +90,7 @@ export interface AssistantStart { } export type StartServices = CoreStart & - AssistantPluginStartDependencies & { + Omit & { setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; }; diff --git a/public/vis_nlq/saved_object_loader.ts b/public/vis_nlq/saved_object_loader.ts new file mode 100644 index 00000000..889da723 --- /dev/null +++ b/public/vis_nlq/saved_object_loader.ts @@ -0,0 +1,83 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + createSavedObjectClass, + SavedObjectLoader, + SavedObjectOpenSearchDashboardsServices, +} from '../../../../src/plugins/saved_objects/public'; +import { SavedObjectReference } from '../../../../src/core/public'; +import { injectSearchSourceReferences } from '../../../../src/plugins/data/public'; +import { createGetterSetter } from '../../../../src/plugins/opensearch_dashboards_utils/common'; +import { VIS_NLQ_APP_ID, VIS_NLQ_SAVED_OBJECT } from '../../common/constants/vis_type_nlq'; +import { VisNLQSavedObject } from './types'; + +export type VisNLQSavedObjectLoader = ReturnType; + +function injectReferences(savedObject: VisNLQSavedObject, references: SavedObjectReference[]) { + if (savedObject.searchSourceFields) { + savedObject.searchSourceFields = injectSearchSourceReferences( + savedObject.searchSourceFields, + references + ); + } +} + +function createVisNLQSavedObjectClass(services: SavedObjectOpenSearchDashboardsServices) { + const SavedObjectClass = createSavedObjectClass(services); + + class VisNLQSavedObjectClass extends SavedObjectClass { + public static type = VIS_NLQ_SAVED_OBJECT; + + // if type:visualization-nlq has no mapping, we push this mapping into OpenSearch + public static mapping = { + title: 'text', + description: 'text', + visualizationState: 'text', + uiState: 'text', + version: 'integer', + }; + + // Order these fields to the top, the rest are alphabetical + static fieldOrder = ['title', 'description']; + + // ID is optional, without it one will be generated on save. + constructor(id: string) { + super({ + type: VisNLQSavedObjectClass.type, + mapping: VisNLQSavedObjectClass.mapping, + injectReferences, + + // if this is null/undefined then the SavedObject will be assigned the defaults + id, + + // default values that will get assigned if the doc is new + defaults: { + title: '', + description: '', + visualizationState: '{}', + uiState: '{}', + version: 1, + }, + }); + this.showInRecentlyAccessed = true; + this.getFullPath = () => `/app/${VIS_NLQ_APP_ID}/edit/${this.id}`; + this.getOpenSearchType = () => VIS_NLQ_SAVED_OBJECT; + } + } + + return VisNLQSavedObjectClass; +} + +export const [getVisNLQSavedObjectLoader, setVisNLQSavedObjectLoader] = createGetterSetter< + VisNLQSavedObjectLoader +>('VisNLQSavedObjectLoader'); + +export function createVisNLQSavedObjectLoader(services: SavedObjectOpenSearchDashboardsServices) { + const { savedObjectsClient } = services; + const savedObjectClass = createVisNLQSavedObjectClass(services); + + return new SavedObjectLoader(savedObjectClass, savedObjectsClient); +} diff --git a/public/vis_nlq/types.ts b/public/vis_nlq/types.ts new file mode 100644 index 00000000..18084f25 --- /dev/null +++ b/public/vis_nlq/types.ts @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObject } from '../../../../src/plugins/saved_objects/public'; + +export interface VisNLQSavedObject extends SavedObject { + id?: string; + title: string; + description?: string; + visualizationState: string; + uiState: string; + version?: number; +} diff --git a/server/index.ts b/server/index.ts index 4a0d1bab..6bbdce05 100644 --- a/server/index.ts +++ b/server/index.ts @@ -12,6 +12,9 @@ export const config: PluginConfigDescriptor = { chat: true, incontextInsight: true, next: true, + text2viz: true, + alertInsight: true, + smartAnomalyDetector: true, }, schema: configSchema, }; diff --git a/server/plugin.ts b/server/plugin.ts index 0499bc5c..b415392c 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -19,6 +19,8 @@ import { registerChatRoutes } from './routes/chat_routes'; import { registerText2VizRoutes } from './routes/text2viz_routes'; import { AssistantService } from './services/assistant_service'; import { registerAgentRoutes } from './routes/agent_routes'; +import { capabilitiesProvider } from './vis_type_nlq/capabilities_provider'; +import { visNLQSavedObjectType } from './vis_type_nlq/saved_object_type'; export class AssistantPlugin implements Plugin { private readonly logger: Logger; @@ -56,8 +58,10 @@ export class AssistantPlugin implements Plugin ({ diff --git a/server/routes/text2viz_routes.ts b/server/routes/text2viz_routes.ts index ec563ffc..f77d9f2a 100644 --- a/server/routes/text2viz_routes.ts +++ b/server/routes/text2viz_routes.ts @@ -5,11 +5,20 @@ import { schema } from '@osd/config-schema'; import { IRouter } from '../../../../src/core/server'; -import { TEXT2VIZ_API } from '../../common/constants/llm'; +import { TEXT2VEGA_INPUT_SIZE_LIMIT, TEXT2VIZ_API } from '../../common/constants/llm'; import { AssistantServiceSetup } from '../services/assistant_service'; -const TEXT2VEGA_AGENT_CONFIG_ID = 'text2vega'; -const TEXT2PPL_AGENT_CONFIG_ID = 'text2ppl'; +const TEXT2VEGA_AGENT_CONFIG_ID = 'os_text2vega'; +const TEXT2PPL_AGENT_CONFIG_ID = 'os_query_assist_ppl'; + +const inputSchema = schema.string({ + maxLength: TEXT2VEGA_INPUT_SIZE_LIMIT, + validate(value) { + if (!value || value.trim().length === 0) { + return "can't be empty or blank."; + } + }, +}); export function registerText2VizRoutes(router: IRouter, assistantService: AssistantServiceSetup) { router.post( @@ -17,7 +26,8 @@ export function registerText2VizRoutes(router: IRouter, assistantService: Assist path: TEXT2VIZ_API.TEXT2VEGA, validate: { body: schema.object({ - input: schema.string(), + input_question: inputSchema, + input_instruction: schema.maybe(inputSchema), ppl: schema.string(), dataSchema: schema.string(), sampleData: schema.string(), @@ -31,7 +41,8 @@ export function registerText2VizRoutes(router: IRouter, assistantService: Assist const assistantClient = assistantService.getScopedClient(req, context); try { const response = await assistantClient.executeAgentByName(TEXT2VEGA_AGENT_CONFIG_ID, { - input: req.body.input, + input_question: req.body.input_question, + input_instruction: req.body.input_instruction, ppl: req.body.ppl, dataSchema: req.body.dataSchema, sampleData: req.body.sampleData, @@ -63,7 +74,7 @@ export function registerText2VizRoutes(router: IRouter, assistantService: Assist validate: { body: schema.object({ index: schema.string(), - question: schema.string(), + question: inputSchema, }), query: schema.object({ dataSourceId: schema.maybe(schema.string()), diff --git a/server/vis_type_nlq/capabilities_provider.ts b/server/vis_type_nlq/capabilities_provider.ts new file mode 100644 index 00000000..ec24fe86 --- /dev/null +++ b/server/vis_type_nlq/capabilities_provider.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VIS_NLQ_SAVED_OBJECT } from '../../common/constants/vis_type_nlq'; + +export const capabilitiesProvider = () => ({ + [VIS_NLQ_SAVED_OBJECT]: { + show: true, + }, +}); diff --git a/server/vis_type_nlq/saved_object_type.ts b/server/vis_type_nlq/saved_object_type.ts new file mode 100644 index 00000000..5723fda3 --- /dev/null +++ b/server/vis_type_nlq/saved_object_type.ts @@ -0,0 +1,60 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VIS_NLQ_SAVED_OBJECT } from '../../common/constants/vis_type_nlq'; +import { SavedObject, SavedObjectsType } from '../../../../src/core/server'; +import { SavedObjectAttributes } from '../../../../src/core/types'; + +export interface VisNLQSavedObjectAttributes extends SavedObjectAttributes { + title: string; + description?: string; + visualizationState?: string; + updated_at?: string; + uiState?: string; + version: number; + searchSourceFields?: { + index?: string; + }; +} + +export const visNLQSavedObjectType: SavedObjectsType = { + name: VIS_NLQ_SAVED_OBJECT, + hidden: false, + namespaceType: 'single', + management: { + defaultSearchField: 'title', + importableAndExportable: true, + getTitle: ({ attributes: { title } }: SavedObject) => title, + getInAppUrl({ id }: SavedObject) { + return { + path: `/app/text2viz/edit/${encodeURIComponent(id)}`, + uiCapabilitiesPath: `${VIS_NLQ_SAVED_OBJECT}.show`, + }; + }, + }, + migrations: {}, + mappings: { + properties: { + title: { + type: 'text', + }, + description: { + type: 'text', + }, + visualizationState: { + type: 'text', + index: false, + }, + uiState: { + type: 'text', + index: false, + }, + version: { type: 'integer' }, + kibanaSavedObjectMeta: { + properties: { searchSourceJSON: { type: 'text', index: false } }, + }, + }, + }, +};