diff --git a/public/components/visualization/text2vega.ts b/public/components/visualization/text2vega.ts index ea8b0936..10108aa5 100644 --- a/public/components/visualization/text2vega.ts +++ b/public/components/visualization/text2vega.ts @@ -13,13 +13,14 @@ import { DataSourceAttributes } from '../../../../../src/plugins/data_source/com const topN = (ppl: string, n: number) => `${ppl} | head ${n}`; interface Input { - prompt: string; + inputQuestion: string; + inputInstruction?: string; index: string; dataSourceId?: string; } export class Text2Vega { - input$ = new BehaviorSubject({ prompt: '', index: '' }); + input$ = new BehaviorSubject({ inputQuestion: '', index: '' }); // eslint-disable-next-line @typescript-eslint/no-explicit-any result$: Observable | { error: any }>; status$ = new BehaviorSubject<'RUNNING' | 'STOPPED'>('STOPPED'); @@ -37,7 +38,7 @@ export class Text2Vega { this.savedObjects = savedObjects; this.result$ = this.input$ .pipe( - filter((v) => v.prompt.length > 0), + filter((v) => v.inputQuestion.length > 0), tap(() => this.status$.next('RUNNING')), debounceTime(200) ) @@ -46,7 +47,7 @@ export class Text2Vega { of(v).pipe( // text to ppl switchMap(async (value) => { - const pplQuestion = value.prompt.split('//')[0]; + const pplQuestion = value.inputQuestion; const ppl = await this.text2ppl(pplQuestion, value.index, value.dataSourceId); return { ...value, @@ -71,7 +72,8 @@ export class Text2Vega { // call llm to generate vega switchMap(async (value) => { const result = await this.text2vega({ - input: value.prompt, + inputQuestion: value.inputQuestion, + inputInstruction: value.inputInstruction, ppl: value.ppl, sampleData: JSON.stringify(value.sample.jsonData), dataSchema: JSON.stringify(value.sample.schema), @@ -96,13 +98,15 @@ export class Text2Vega { } async text2vega({ - input, + inputQuestion, + inputInstruction = '', ppl, sampleData, dataSchema, dataSourceId, }: { - input: string; + inputQuestion: string; + inputInstruction?: string; ppl: string; sampleData: string; dataSchema: string; @@ -122,7 +126,6 @@ export class Text2Vega { } } }; - const [inputQuestion, inputInstruction = ''] = input.split('//'); const res = await this.http.post(TEXT2VIZ_API.TEXT2VEGA, { body: JSON.stringify({ input_question: inputQuestion.trim(), diff --git a/public/components/visualization/text2viz.scss b/public/components/visualization/text2viz.scss index c917471e..bc27122a 100644 --- a/public/components/visualization/text2viz.scss +++ b/public/components/visualization/text2viz.scss @@ -17,10 +17,26 @@ padding-left: 30px; } - .feedback_thumbs { + .text2viz__actionContainer { position: absolute; - right: 16px; top: 4px; + right: 16px; z-index: 9999; + + // No existing button from OUI with the same style, have to customize here + .vizStyleEditor__editButton { + height: 22px; + padding: 2px; + font-size: $ouiFontSizeXS; + } + + .text2viz__feedbackContainer { + padding-right: $euiSizeS; + border-right: 1px solid $euiColorLightShade + } + + .text2viz__vizStyleEditorContainer { + padding-left: $euiSizeS; + } } } diff --git a/public/components/visualization/text2viz.tsx b/public/components/visualization/text2viz.tsx index bdc4f8ab..c4a76dec 100644 --- a/public/components/visualization/text2viz.tsx +++ b/public/components/visualization/text2viz.tsx @@ -51,6 +51,7 @@ import { VIS_NLQ_APP_ID, VIS_NLQ_SAVED_OBJECT } from '../../../common/constants/ import { HeaderVariant } from '../../../../../src/core/public'; import { TEXT2VEGA_INPUT_SIZE_LIMIT } from '../../../common/constants/llm'; import { FeedbackThumbs } from '../feedback_thumbs'; +import { VizStyleEditor } from './viz_style_editor'; export const INDEX_PATTERN_URL_SEARCH_KEY = 'indexPatternId'; export const ASSISTANT_INPUT_URL_SEARCH_KEY = 'assistantInput'; @@ -96,7 +97,10 @@ export const Text2Viz = () => { const useUpdatedUX = uiSettings.get('home:useNewHomePage'); - const [input, setInput] = useState(searchParams.get(ASSISTANT_INPUT_URL_SEARCH_KEY) ?? ''); + const [inputQuestion, setInputQuestion] = useState( + searchParams.get(ASSISTANT_INPUT_URL_SEARCH_KEY) ?? '' + ); + const [currentInstruction, setCurrentInstruction] = useState(''); const [editorInput, setEditorInput] = useState(''); const text2vegaRef = useRef(new Text2Vega(http, data.search, savedObjects)); @@ -174,7 +178,8 @@ export const Text2Viz = () => { } } if (savedVis?.uiState) { - setInput(JSON.parse(savedVis.uiState ?? '{}').input); + setInputQuestion(JSON.parse(savedVis.uiState ?? '{}').input ?? ''); + setCurrentInstruction(JSON.parse(savedVis.uiState ?? '{}').instruction ?? ''); } }) .catch(() => { @@ -196,42 +201,48 @@ export const Text2Viz = () => { /** * Submit user's natural language input to generate visualization */ - const onSubmit = useCallback(async () => { - 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; - } + const onSubmit = useCallback( + async (inputInstruction: string = '') => { + setCurrentInstruction(inputInstruction); + + if (status === 'RUNNING' || !selectedSource) return; + + 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; + } - setSubmitting(true); + setSubmitting(true); - const indexPatterns = getIndexPatterns(); - const indexPattern = await indexPatterns.get(selectedSource); - currentUsedIndexPatternRef.current = indexPattern; + 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, - }); + const text2vega = text2vegaRef.current; + text2vega.invoke({ + index: indexPattern.title, + inputQuestion, + inputInstruction, + dataSourceId: indexPattern.dataSourceRef?.id, + }); - setSubmitting(false); - }, [selectedSource, input, status, notifications.toasts]); + setSubmitting(false); + }, + [selectedSource, inputQuestion, status, notifications.toasts] + ); /** * Display the save visualization dialog to persist the current generated visualization @@ -252,7 +263,8 @@ export const Text2Viz = () => { }, }); savedVis.uiState = JSON.stringify({ - input, + input: inputQuestion, + instruction: currentInstruction, }); savedVis.searchSourceFields = { index: indexPattern }; savedVis.title = onSaveProps.newTitle; @@ -311,7 +323,16 @@ export const Text2Viz = () => { /> ) ); - }, [notifications, vegaSpec, input, overlays, selectedSource, savedObjectId, usageCollection]); + }, [ + notifications, + vegaSpec, + inputQuestion, + overlays, + selectedSource, + savedObjectId, + usageCollection, + currentInstruction, + ]); const pageTitle = savedObjectId ? i18n.translate('dashboardAssistant.feature.text2viz.breadcrumbs.editVisualization', { @@ -380,8 +401,8 @@ export const Text2Viz = () => { setInput(e.target.value)} + value={inputQuestion} + onChange={(e) => setInputQuestion(e.target.value)} fullWidth compressed prepend={} @@ -393,8 +414,8 @@ export const Text2Viz = () => { onSubmit()} + isDisabled={loading || inputQuestion.trim().length === 0 || !selectedSource} display="base" size="s" iconType="returnKey" @@ -453,13 +474,23 @@ export const Text2Viz = () => { paddingSize="none" scrollable={false} > - {usageCollection ? ( - - ) : null} + + {usageCollection ? ( + + + + ) : null} + + onSubmit(instruction)} + value={currentInstruction} + /> + + diff --git a/public/components/visualization/viz_style_editor.test.tsx b/public/components/visualization/viz_style_editor.test.tsx new file mode 100644 index 00000000..7c2c6a61 --- /dev/null +++ b/public/components/visualization/viz_style_editor.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { VizStyleEditor } from './viz_style_editor'; + +describe('', () => { + test('should render visual style editor', () => { + const onApplyFn = jest.fn(); + render(); + expect(screen.queryByText('Edit visual')).toBeInTheDocument(); + + // click Edit visual button to open the modal + expect(screen.queryByTestId('text2vizStyleEditorModal')).toBe(null); + fireEvent.click(screen.getByText('Edit visual')); + expect(screen.queryByTestId('text2vizStyleEditorModal')).toBeInTheDocument(); + + // Click cancel to close the modal + fireEvent.click(screen.getByText('Cancel')); + expect(screen.queryByTestId('text2vizStyleEditorModal')).toBe(null); + + // Apply button is disabled + fireEvent.click(screen.getByText('Edit visual')); + expect(screen.getByTestId('text2vizStyleEditorModalApply')).toBeDisabled(); + + // After input text, Apply button is enabled + fireEvent.input(screen.getByLabelText('Input instructions to tweak the visual'), { + target: { value: 'test input' }, + }); + expect(screen.getByTestId('text2vizStyleEditorModalApply')).not.toBeDisabled(); + fireEvent.click(screen.getByText('Apply')); + expect(onApplyFn).toHaveBeenCalledWith('test input'); + expect(screen.queryByTestId('text2vizStyleEditorModal')).toBe(null); + }); + + test('should open the modal with initial value', () => { + render(); + fireEvent.click(screen.getByText('Edit visual')); + expect(screen.getByDisplayValue('test input')).toBeInTheDocument(); + }); +}); diff --git a/public/components/visualization/viz_style_editor.tsx b/public/components/visualization/viz_style_editor.tsx new file mode 100644 index 00000000..0d35796e --- /dev/null +++ b/public/components/visualization/viz_style_editor.tsx @@ -0,0 +1,101 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, + EuiTextArea, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; + +interface Props { + onApply: (input: string) => void; + value?: string; + iconType: string; + className?: string; +} + +export const VizStyleEditor = ({ onApply, className, iconType, value }: Props) => { + const [modalVisible, setModalVisible] = useState(false); + const [inputValue, setInputValue] = useState(''); + + const onApplyClick = useCallback(() => { + onApply(inputValue.trim()); + setModalVisible(false); + }, [inputValue, onApply]); + + const openModal = useCallback(() => { + if (value) { + setInputValue(value); + } + setModalVisible(true); + }, [value]); + + return ( +
+ + {i18n.translate('dashboardAssistant.feature.text2viz.editVisualButton.label', { + defaultMessage: 'Edit visual', + })} + + {modalVisible && ( + setModalVisible(false)}> + + +

+ {i18n.translate('dashboardAssistant.feature.text2viz.editVisualModal.title', { + defaultMessage: 'Edit visual', + })} +

+
+
+ + {i18n.translate('dashboardAssistant.feature.text2viz.editVisualModal.body', { + defaultMessage: 'How would you like to edit the visual?', + })} + + setInputValue(e.target.value)} + /> + + + setModalVisible(false)}> + {i18n.translate('dashboardAssistant.feature.text2viz.editVisualModal.cancel', { + defaultMessage: 'Cancel', + })} + + + {i18n.translate('dashboardAssistant.feature.text2viz.editVisualModal.apply', { + defaultMessage: 'Apply', + })} + + +
+ )} +
+ ); +}; diff --git a/server/routes/agent_routes.ts b/server/routes/agent_routes.ts index 227143a7..57e85e66 100644 --- a/server/routes/agent_routes.ts +++ b/server/routes/agent_routes.ts @@ -39,7 +39,7 @@ export function registerAgentRoutes(router: IRouter, assistantService: Assistant ); return res.ok({ body: response }); } catch (e) { - return res.internalError(); + return res.badRequest(); } }) );