Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add new feature to support text to visualization #264

Merged
merged 7 commits into from
Sep 11, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- feat: expose a general function for agent execution([268](https://github.com/opensearch-project/dashboards-assistant/pull/268))
- Fix CVE-2024-4067 ([#269](https://github.com/opensearch-project/dashboards-assistant/pull/269))
- feat: add a dashboards-assistant trigger in query editor([265](https://github.com/opensearch-project/dashboards-assistant/pull/265))
- fix: make sure $schema always added to LLM generated vega json object([#252](https://github.com/opensearch-project/dashboards-assistant/pull/252))
- feat: added a new visualization type visualization-nlq to support creating visualization from natural language([#264](https://github.com/opensearch-project/dashboards-assistant/pull/264))

### 📈 Features/Enhancements

Expand Down
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>;
6 changes: 4 additions & 2 deletions opensearch_dashboards.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@
"data",
"dashboard",
"embeddable",
"expressions",
"opensearchDashboardsReact",
"opensearchDashboardsUtils",
"visualizations",
"savedObjects",
"uiActions"
],
"optionalPlugins": [
"dataSource",
"dataSourceManagement"
],
"requiredBundles": [],
"requiredBundles": [
"savedObjects"
],
"configPath": [
"assistant"
]
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
Loading