Skip to content

Commit

Permalink
feat: add new feature to support text to visualization (#264) (#292)
Browse files Browse the repository at this point in the history
* feat: add new feature to support text to visualization

A new visualization type visualization-nlq is added to support creating
visualization from natural language.

Signed-off-by: Yulong Ruan <[email protected]>

* update text2ppl agent name to be the same as query assistant

Signed-off-by: Yulong Ruan <[email protected]>

* move savedObjects to requiredBundles

Signed-off-by: Yulong Ruan <[email protected]>

* fix: move savedVisNLQLoader to be under text2viz feature flag

Signed-off-by: Yulong Ruan <[email protected]>

* fix: CI build error

Signed-off-by: Yulong Ruan <[email protected]>

* fix build error

Signed-off-by: Yulong Ruan <[email protected]>

* feat: limit text to vega input size to 400

+ change the agent name of text2vega

Signed-off-by: Yulong Ruan <[email protected]>

---------

Signed-off-by: Yulong Ruan <[email protected]>
(cherry picked from commit 4d60d51)
Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

# Conflicts:
#	CHANGELOG.md

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
1 parent 8debb98 commit 43a29fb
Show file tree
Hide file tree
Showing 25 changed files with 1,090 additions and 188 deletions.
2 changes: 2 additions & 0 deletions common/constants/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ export const NOTEBOOK_API = {
};

export const DEFAULT_USER_NAME = 'User';

export const TEXT2VEGA_INPUT_SIZE_LIMIT = 400;
7 changes: 7 additions & 0 deletions common/constants/vis_type_nlq.ts
Original file line number Diff line number Diff line change
@@ -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';
12 changes: 10 additions & 2 deletions common/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
}),
Expand All @@ -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<typeof configSchema>;
1 change: 1 addition & 0 deletions opensearch_dashboards.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"data",
"dashboard",
"embeddable",
"expressions",
"opensearchDashboardsReact",
"opensearchDashboardsUtils",
"visualizations",
Expand Down
120 changes: 120 additions & 0 deletions public/components/visualization/editor_panel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div style={{ height: 'calc(100% - 40px)' }}>
<CodeEditor
languageId="xjson"
languageConfiguration={{
autoClosingPairs: [
{
open: '(',
close: ')',
},
{
open: '"',
close: '"',
},
],
}}
value={editorInput}
onChange={(v) => editorInputRef.current.next(v)}
options={{
readOnly: false,
lineNumbers: 'on',
fontSize: 12,
minimap: {
enabled: false,
},
scrollBeyondLastLine: false,
wordWrap: 'on',
wrappingIndent: 'indent',
folding: true,
automaticLayout: true,
}}
/>
</div>
<EuiFlexGroup alignItems="flexStart" gutterSize="s" style={{ height: 40, paddingTop: 8 }}>
{!autoUpdate && (
<>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
disabled={!editInputChanged}
iconType="cross"
onClick={() => editorInputRef.current.next(props.originalValue)}
>
{i18n.translate('dashboardAssistant.feature.text2viz.discardVegaSpecChange', {
defaultMessage: 'Discard',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ marginLeft: 'auto' }}>
<EuiButton
fill
disabled={!editInputChanged}
size="s"
iconType="play"
onClick={() => props.onApply(editorInput)}
>
{i18n.translate('dashboardAssistant.feature.text2viz.updateVegaSpec', {
defaultMessage: 'Update',
})}
</EuiButton>
</EuiFlexItem>
</>
)}
<EuiFlexItem grow={false} style={autoUpdate ? { marginLeft: 'auto' } : {}}>
<EuiButtonIcon
aria-label="Apply auto refresh"
display={autoUpdate ? 'fill' : 'base'}
size="s"
iconType="refresh"
onClick={() => setAutoUpdate((v) => !v)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};
189 changes: 189 additions & 0 deletions public/components/visualization/embeddable/nlq_vis_embeddable.ts
Original file line number Diff line number Diff line change
@@ -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<ExpressionsStart['ExpressionLoader']>;

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<NLQVisualizationInput>): 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();
}
}
}
Loading

0 comments on commit 43a29fb

Please sign in to comment.