Skip to content

Commit 016e0f2

Browse files
joshuali925kavilla
authored andcommitted
[Discover-next] add query assist to query enhancements plugin (#6895)
it adds query assist specific logic in query enhancements plugin to show a UI above the PPL search bar if user has configured PPL agent. Issues Resolved: #6820 * add query assist to query enhancements Signed-off-by: Joshua Li <joshuali925@gmail.com> * align language to uppercase Signed-off-by: Joshua Li <joshuali925@gmail.com> * pick PR 6167 Signed-off-by: Joshua Li <joshuali925@gmail.com> * use useState hooks for query assist There is a bug in data explorer `AppContainer` where memorized `DiscoverCanvas` gets unmounted after `setQuery`. PR 6167 works around it by memorizing `AppContainer`. As query assist is no longer being unmounted, we can use proper hooks to persist state now. Signed-off-by: Joshua Li <joshuali925@gmail.com> * Revert "pick PR 6167" This reverts commit acb0d41. Wait for official 6167 to merge to avoid conflict Signed-off-by: Joshua Li <joshuali925@gmail.com> * address comments for PR 6894 Signed-off-by: Joshua Li <joshuali925@gmail.com> --------- Signed-off-by: Joshua Li <joshuali925@gmail.com>
1 parent e748e81 commit 016e0f2

29 files changed

+631
-46
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { TimeRange } from '../../../../src/plugins/data/common';
2+
3+
export const ERROR_DETAILS = { GUARDRAILS_TRIGGERED: 'guardrails triggered' };
4+
5+
export interface QueryAssistResponse {
6+
query: string;
7+
timeRange?: TimeRange;
8+
}
9+
10+
export interface QueryAssistParameters {
11+
question: string;
12+
index: string;
13+
language: string;
14+
}

plugins-extra/query_enhancements/opensearch_dashboards.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
"ui": true,
77
"requiredPlugins": ["data"],
88
"optionalPlugins": ["home"],
9-
"requiredBundles": []
9+
"requiredBundles": ["opensearchDashboardsUtils", "opensearchDashboardsReact"]
1010
}
Loading

plugins-extra/query_enhancements/public/plugin.ts plugins-extra/query_enhancements/public/plugin.tsx

+20-5
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,29 @@
11
import moment from 'moment';
22
import { CoreSetup, CoreStart, Plugin } from '../../../src/core/public';
3+
import { IStorageWrapper, Storage } from '../../../src/plugins/opensearch_dashboards_utils/public';
4+
import { createQueryAssistExtension } from './query_assist';
5+
import { PPLQlSearchInterceptor } from './search/ppl_search_interceptor';
6+
import { SQLQlSearchInterceptor } from './search/sql_search_interceptor';
7+
import { setData, setStorage } from './services';
38
import {
49
QueryEnhancementsPluginSetup,
5-
QueryEnhancementsPluginStart,
610
QueryEnhancementsPluginSetupDependencies,
11+
QueryEnhancementsPluginStart,
12+
QueryEnhancementsPluginStartDependencies,
713
} from './types';
8-
import { PPLQlSearchInterceptor } from './search/ppl_search_interceptor';
9-
import { SQLQlSearchInterceptor } from './search/sql_search_interceptor';
1014

1115
export class QueryEnhancementsPlugin
1216
implements Plugin<QueryEnhancementsPluginSetup, QueryEnhancementsPluginStart> {
17+
private readonly storage: IStorageWrapper;
18+
19+
constructor() {
20+
this.storage = new Storage(window.localStorage);
21+
}
22+
1323
public setup(
1424
core: CoreSetup,
1525
{ data }: QueryEnhancementsPluginSetupDependencies
1626
): QueryEnhancementsPluginSetup {
17-
1827
const pplSearchInterceptor = new PPLQlSearchInterceptor({
1928
toasts: core.notifications.toasts,
2029
http: core.http,
@@ -43,6 +52,7 @@ export class QueryEnhancementsPlugin
4352
initialTo: moment().add(2, 'days').toISOString(),
4453
},
4554
showFilterBar: false,
55+
extensions: [createQueryAssistExtension(core.http)],
4656
},
4757
fields: {
4858
visualizable: false,
@@ -75,7 +85,12 @@ export class QueryEnhancementsPlugin
7585
return {};
7686
}
7787

78-
public start(core: CoreStart): QueryEnhancementsPluginStart {
88+
public start(
89+
core: CoreStart,
90+
deps: QueryEnhancementsPluginStartDependencies
91+
): QueryEnhancementsPluginStart {
92+
setStorage(this.storage);
93+
setData(deps.data);
7994
return {};
8095
}
8196

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { EuiCallOut, EuiCallOutProps } from '@elastic/eui';
2+
import React from 'react';
3+
4+
type CalloutDismiss = Required<Pick<EuiCallOutProps, 'onDismiss'>>;
5+
interface QueryAssistCallOutProps extends CalloutDismiss {
6+
type: QueryAssistCallOutType;
7+
}
8+
9+
export type QueryAssistCallOutType =
10+
| undefined
11+
| 'invalid_query'
12+
| 'prohibited_query'
13+
| 'empty_query'
14+
| 'empty_index'
15+
| 'query_generated';
16+
17+
const EmptyIndexCallOut: React.FC<CalloutDismiss> = (props) => (
18+
<EuiCallOut
19+
data-test-subj="query-assist-empty-index-callout"
20+
title="Select a data source or index to ask a question."
21+
size="s"
22+
color="warning"
23+
iconType="iInCircle"
24+
dismissible
25+
onDismiss={props.onDismiss}
26+
/>
27+
);
28+
29+
const ProhibitedQueryCallOut: React.FC<CalloutDismiss> = (props) => (
30+
<EuiCallOut
31+
data-test-subj="query-assist-guard-callout"
32+
title="I am unable to respond to this query. Try another question."
33+
size="s"
34+
color="danger"
35+
iconType="alert"
36+
dismissible
37+
onDismiss={props.onDismiss}
38+
/>
39+
);
40+
41+
const EmptyQueryCallOut: React.FC<CalloutDismiss> = (props) => (
42+
<EuiCallOut
43+
data-test-subj="query-assist-empty-query-callout"
44+
title="Enter a natural language question to automatically generate a query to view results."
45+
size="s"
46+
color="warning"
47+
iconType="iInCircle"
48+
dismissible
49+
onDismiss={props.onDismiss}
50+
/>
51+
);
52+
53+
const PPLGeneratedCallOut: React.FC<CalloutDismiss> = (props) => (
54+
<EuiCallOut
55+
data-test-subj="query-assist-ppl-callout"
56+
title="PPL query generated"
57+
size="s"
58+
color="success"
59+
iconType="check"
60+
dismissible
61+
onDismiss={props.onDismiss}
62+
/>
63+
);
64+
65+
export const QueryAssistCallOut: React.FC<QueryAssistCallOutProps> = (props) => {
66+
switch (props.type) {
67+
case 'empty_query':
68+
return <EmptyQueryCallOut onDismiss={props.onDismiss} />;
69+
case 'empty_index':
70+
return <EmptyIndexCallOut onDismiss={props.onDismiss} />;
71+
case 'invalid_query':
72+
return <ProhibitedQueryCallOut onDismiss={props.onDismiss} />;
73+
case 'query_generated':
74+
return <PPLGeneratedCallOut onDismiss={props.onDismiss} />;
75+
default:
76+
break;
77+
}
78+
return null;
79+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { QueryAssistBar } from './query_assist_bar';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { EuiFlexGroup, EuiFlexItem, EuiForm, EuiFormRow } from '@elastic/eui';
2+
import React, { SyntheticEvent, useEffect, useMemo, useRef, useState } from 'react';
3+
import { IDataPluginServices, PersistedLog } from '../../../../../src/plugins/data/public';
4+
import { SearchBarExtensionDependencies } from '../../../../../src/plugins/data/public/ui/search_bar_extensions/search_bar_extension';
5+
import { useOpenSearchDashboards } from '../../../../../src/plugins/opensearch_dashboards_react/public';
6+
import { getStorage } from '../../services';
7+
import { useGenerateQuery } from '../hooks';
8+
import { getPersistedLog, ProhibitedQueryError } from '../utils';
9+
import { QueryAssistCallOut, QueryAssistCallOutType } from './call_outs';
10+
import { QueryAssistInput } from './query_assist_input';
11+
import { QueryAssistSubmitButton } from './submit_button';
12+
13+
interface QueryAssistInputProps {
14+
dependencies: SearchBarExtensionDependencies;
15+
}
16+
17+
export const QueryAssistBar: React.FC<QueryAssistInputProps> = (props) => {
18+
const { services } = useOpenSearchDashboards<IDataPluginServices>();
19+
const inputRef = useRef<HTMLInputElement>(null);
20+
const storage = getStorage();
21+
const persistedLog: PersistedLog = useMemo(
22+
() => getPersistedLog(services.uiSettings, storage, 'query-assist'),
23+
[services.uiSettings, storage]
24+
);
25+
const { generateQuery, loading } = useGenerateQuery();
26+
const [callOutType, setCallOutType] = useState<QueryAssistCallOutType>();
27+
const dismissCallout = () => setCallOutType(undefined);
28+
const mounted = useRef(false);
29+
const selectedIndex = props.dependencies.indexPatterns?.at(0)?.title;
30+
const previousQuestionRef = useRef<string>();
31+
32+
useEffect(() => {
33+
mounted.current = true;
34+
return () => {
35+
mounted.current = false;
36+
};
37+
}, []);
38+
39+
const onSubmit = async (e: SyntheticEvent) => {
40+
e.preventDefault();
41+
if (!inputRef.current?.value) {
42+
setCallOutType('empty_query');
43+
return;
44+
}
45+
if (!selectedIndex) {
46+
setCallOutType('empty_index');
47+
return;
48+
}
49+
dismissCallout();
50+
previousQuestionRef.current = inputRef.current.value;
51+
persistedLog.add(inputRef.current.value);
52+
const params = {
53+
question: inputRef.current.value,
54+
index: selectedIndex,
55+
language: 'PPL',
56+
};
57+
const { response, error } = await generateQuery(params);
58+
if (!mounted.current) return;
59+
if (error) {
60+
if (error instanceof ProhibitedQueryError) {
61+
setCallOutType('invalid_query');
62+
} else {
63+
services.notifications.toasts.addError(error, { title: 'Failed to generate results' });
64+
}
65+
} else if (response) {
66+
services.data.query.queryString.setQuery({
67+
query: response.query,
68+
language: params.language,
69+
});
70+
if (response.timeRange) services.data.query.timefilter.timefilter.setTime(response.timeRange);
71+
setCallOutType('query_generated');
72+
}
73+
};
74+
75+
return (
76+
<EuiForm component="form" onSubmit={onSubmit}>
77+
<EuiFormRow fullWidth>
78+
<EuiFlexGroup gutterSize="s" responsive={false} alignItems="center">
79+
<EuiFlexItem>
80+
<QueryAssistInput
81+
inputRef={inputRef}
82+
persistedLog={persistedLog}
83+
selectedIndex={selectedIndex}
84+
previousQuestion={previousQuestionRef.current}
85+
/>
86+
</EuiFlexItem>
87+
<EuiFlexItem grow={false}>
88+
<QueryAssistSubmitButton isDisabled={loading} />
89+
</EuiFlexItem>
90+
</EuiFlexGroup>
91+
</EuiFormRow>
92+
<QueryAssistCallOut type={callOutType} onDismiss={dismissCallout} />
93+
</EuiForm>
94+
);
95+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { EuiFieldText, EuiIcon, EuiOutsideClickDetector, EuiPortal } from '@elastic/eui';
2+
import React, { useMemo, useState } from 'react';
3+
import { PersistedLog, QuerySuggestionTypes } from '../../../../../src/plugins/data/public';
4+
import assistantLogo from '../../assets/query_assist_logo.svg';
5+
import { getData } from '../../services';
6+
7+
interface QueryAssistInputProps {
8+
inputRef: React.RefObject<HTMLInputElement>;
9+
persistedLog: PersistedLog;
10+
initialValue?: string;
11+
selectedIndex?: string;
12+
previousQuestion?: string;
13+
}
14+
15+
export const QueryAssistInput: React.FC<QueryAssistInputProps> = (props) => {
16+
const {
17+
ui: { SuggestionsComponent },
18+
} = getData();
19+
const [isSuggestionsVisible, setIsSuggestionsVisible] = useState(false);
20+
const [suggestionIndex, setSuggestionIndex] = useState<number | null>(null);
21+
const [value, setValue] = useState(props.initialValue ?? '');
22+
23+
const recentSearchSuggestions = useMemo(() => {
24+
if (!props.persistedLog) return [];
25+
return props.persistedLog
26+
.get()
27+
.filter((recentSearch) => recentSearch.includes(value))
28+
.map((recentSearch) => ({
29+
type: QuerySuggestionTypes.RecentSearch,
30+
text: recentSearch,
31+
start: 0,
32+
end: value.length,
33+
}));
34+
}, [props.persistedLog, value]);
35+
36+
return (
37+
<EuiOutsideClickDetector onOutsideClick={() => setIsSuggestionsVisible(false)}>
38+
<div>
39+
<EuiFieldText
40+
inputRef={props.inputRef}
41+
value={value}
42+
onClick={() => setIsSuggestionsVisible(true)}
43+
onChange={(e) => setValue(e.target.value)}
44+
onKeyDown={() => setIsSuggestionsVisible(true)}
45+
placeholder={
46+
props.previousQuestion ||
47+
(props.selectedIndex
48+
? `Ask a natural language question about ${props.selectedIndex} to generate a query`
49+
: 'Select an index pattern to ask a question')
50+
}
51+
prepend={<EuiIcon type={assistantLogo} />}
52+
fullWidth
53+
/>
54+
<EuiPortal>
55+
<SuggestionsComponent
56+
show={isSuggestionsVisible}
57+
suggestions={recentSearchSuggestions}
58+
index={suggestionIndex}
59+
onClick={(suggestion) => {
60+
if (!props.inputRef.current) return;
61+
setValue(suggestion.text);
62+
setIsSuggestionsVisible(false);
63+
setSuggestionIndex(null);
64+
props.inputRef.current.focus();
65+
}}
66+
onMouseEnter={(i) => setSuggestionIndex(i)}
67+
loadMore={() => {}}
68+
queryBarRect={props.inputRef.current?.getBoundingClientRect()}
69+
size="s"
70+
/>
71+
</EuiPortal>
72+
</div>
73+
</EuiOutsideClickDetector>
74+
);
75+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from 'react';
2+
import { EuiButtonIcon } from '@elastic/eui';
3+
4+
interface SubmitButtonProps {
5+
isDisabled: boolean;
6+
}
7+
8+
export const QueryAssistSubmitButton: React.FC<SubmitButtonProps> = (props) => {
9+
return (
10+
<EuiButtonIcon
11+
iconType="returnKey"
12+
display="base"
13+
isDisabled={props.isDisabled}
14+
size="s"
15+
type="submit"
16+
aria-label="submit-question"
17+
/>
18+
);
19+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './use_generate';

0 commit comments

Comments
 (0)