diff --git a/common/constants/index.ts b/common/constants/index.ts new file mode 100644 index 00000000..3b56cf21 --- /dev/null +++ b/common/constants/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const INCONTEXT_INSIGHT_INITIAL_ONLOAD_TIME_SETTING = 'incontextInsight:initialOnloadTime'; diff --git a/public/components/__tests__/incontext_insight.test.tsx b/public/components/__tests__/incontext_insight.test.tsx index 0dcd9965..65043e52 100644 --- a/public/components/__tests__/incontext_insight.test.tsx +++ b/public/components/__tests__/incontext_insight.test.tsx @@ -6,7 +6,12 @@ import React from 'react'; import { render, cleanup } from '@testing-library/react'; import { IncontextInsight } from '../incontext_insight'; -import { getChrome, getNotifications, getIncontextInsightRegistry } from '../../services'; +import { + getChrome, + getNotifications, + getIncontextInsightRegistry, + getUISettings, +} from '../../services'; jest.mock('../../services'); @@ -21,6 +26,9 @@ beforeEach(() => { }, })); (getIncontextInsightRegistry as jest.Mock).mockImplementation(() => {}); + (getUISettings as jest.Mock).mockImplementation(() => ({ + get: jest.fn(), + })); }); describe('IncontextInsight', () => { diff --git a/public/components/incontext_insight/components/chat_popover_body.tsx b/public/components/incontext_insight/components/chat_popover_body.tsx new file mode 100644 index 00000000..861d2911 --- /dev/null +++ b/public/components/incontext_insight/components/chat_popover_body.tsx @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiButton, EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; +import React from 'react'; +import { IToasts } from '../../../../../../src/core/public'; + +export interface ChatPopoverBodyProps { + toasts: IToasts; +} + +export const ChatPopoverBody: React.FC = ({ toasts }) => ( + + + + + + + + toasts.addDanger('To be implemented...')} + > + Go + + + +); diff --git a/public/components/incontext_insight/components/chat_with_suggestions_popover.tsx b/public/components/incontext_insight/components/chat_with_suggestions_popover.tsx new file mode 100644 index 00000000..462a24e2 --- /dev/null +++ b/public/components/incontext_insight/components/chat_with_suggestions_popover.tsx @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { IncontextInsight as IncontextInsightInput } from '../../../types'; +import { SuggestionsPopoverFooter } from './suggestions_popover_footer'; +import { ChatPopoverBody } from './chat_popover_body'; +import { IToasts } from '../../../../../../src/core/public'; + +export interface ChatWithSuggestionsProps { + toasts: IToasts; + incontextInsight: IncontextInsightInput; + suggestions: string[]; + onSubmitClick: (incontextInsight: IncontextInsightInput, suggestion: string) => void; +} + +export const ChatWithSuggestionsPopover: React.FC = ({ + toasts, + incontextInsight, + suggestions, + onSubmitClick, +}) => ( + <> + {} + { + + } + +); diff --git a/public/components/incontext_insight/components/generate_summary_popover_body.tsx b/public/components/incontext_insight/components/generate_summary_popover_body.tsx new file mode 100644 index 00000000..eeb45641 --- /dev/null +++ b/public/components/incontext_insight/components/generate_summary_popover_body.tsx @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiButton } from '@elastic/eui'; +import React from 'react'; +import { IToasts } from '../../../../../../src/core/public'; + +export interface GenerateSummaryPopoverBodyProps { + toasts: IToasts; +} + +export const GenerateSummaryPopoverBody: React.FC = ({ + toasts, +}) => ( + toasts.addDanger('To be implemented...')}>Generate summary +); diff --git a/public/components/incontext_insight/components/index.ts b/public/components/incontext_insight/components/index.ts new file mode 100644 index 00000000..e2ba82de --- /dev/null +++ b/public/components/incontext_insight/components/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { ChatPopoverBody } from './chat_popover_body'; +export { ChatWithSuggestionsPopover } from './chat_with_suggestions_popover'; +export { GenerateSummaryPopoverBody } from './generate_summary_popover_body'; +export { SuggestionsPopoverFooter } from './suggestions_popover_footer'; +export { SummaryPopoverBody } from './summary_popover_body'; +export { SummaryWithSuggestionsPopover } from './summary_with_suggestions_popover'; diff --git a/public/components/incontext_insight/components/suggestions_popover_footer.tsx b/public/components/incontext_insight/components/suggestions_popover_footer.tsx new file mode 100644 index 00000000..116ee979 --- /dev/null +++ b/public/components/incontext_insight/components/suggestions_popover_footer.tsx @@ -0,0 +1,54 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { EuiText, EuiPopoverFooter, EuiSpacer, EuiListGroup, EuiListGroupItem } from '@elastic/eui'; +import React from 'react'; +import { IncontextInsight as IncontextInsightInput } from '../../../types'; + +export interface SuggestionsPopoverFooterProps { + incontextInsight: IncontextInsightInput; + suggestions: string[]; + onSubmitClick: (incontextInsight: IncontextInsightInput, suggestion: string) => void; +} + +export const SuggestionsPopoverFooter: React.FC = ({ + incontextInsight, + suggestions, + onSubmitClick, +}) => ( + + + {i18n.translate('assistantDashboards.incontextInsight.availableSuggestions', { + defaultMessage: 'Available suggestions', + })} + + + {suggestions.map((suggestion, index) => ( +
+ + onSubmitClick(incontextInsight, suggestion)} + aria-label={suggestion} + wrapText + size="xs" + extraAction={{ + onClick: () => onSubmitClick(incontextInsight, suggestion), + iconType: 'sortRight', + iconSize: 's', + alwaysShow: true, + color: 'subdued', + }} + /> +
+ ))} +
+
+); diff --git a/public/components/incontext_insight/components/summary_popover_body.tsx b/public/components/incontext_insight/components/summary_popover_body.tsx new file mode 100644 index 00000000..3f6fe553 --- /dev/null +++ b/public/components/incontext_insight/components/summary_popover_body.tsx @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiText, EuiPanel } from '@elastic/eui'; +import React from 'react'; +import { IncontextInsight as IncontextInsightInput } from '../../../types'; + +export interface SummaryPopoverBodyProps { + incontextInsight: IncontextInsightInput; +} + +export const SummaryPopoverBody: React.FC = ({ incontextInsight }) => ( + + {incontextInsight.summary} + +); diff --git a/public/components/incontext_insight/components/summary_with_suggestions_popover.tsx b/public/components/incontext_insight/components/summary_with_suggestions_popover.tsx new file mode 100644 index 00000000..18ad9185 --- /dev/null +++ b/public/components/incontext_insight/components/summary_with_suggestions_popover.tsx @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { IncontextInsight as IncontextInsightInput } from '../../../types'; +import { SummaryPopoverBody } from './summary_popover_body'; +import { SuggestionsPopoverFooter } from './suggestions_popover_footer'; + +export interface SummaryWithSuggestionsProps { + incontextInsight: IncontextInsightInput; + suggestions: string[]; + onSubmitClick: (incontextInsight: IncontextInsightInput, suggestion: string) => void; +} + +export const SummaryWithSuggestionsPopover: React.FC = ({ + incontextInsight, + suggestions, + onSubmitClick, +}) => ( + <> + {} + { + + } + +); diff --git a/public/components/incontext_insight/index.scss b/public/components/incontext_insight/index.scss index 8b67e090..0d4ef04a 100644 --- a/public/components/incontext_insight/index.scss +++ b/public/components/incontext_insight/index.scss @@ -110,15 +110,8 @@ width: 300px; } -.incontextInsightSummary { - border: $euiBorderThin; - border-radius: 4px; -} - .incontextInsightSuggestionListItem { margin-top: 0; - border: $euiBorderThin; - border-radius: 4px; .euiListGroupItem__button { padding: 0; diff --git a/public/components/incontext_insight/index.tsx b/public/components/incontext_insight/index.tsx index 736d17df..c5cfe189 100644 --- a/public/components/incontext_insight/index.tsx +++ b/public/components/incontext_insight/index.tsx @@ -8,28 +8,28 @@ import './index.scss'; import { i18n } from '@osd/i18n'; import { EuiWrappingPopover, - EuiButton, - EuiFieldText, EuiFlexGroup, EuiFlexItem, - EuiFormRow, EuiPopoverTitle, - EuiText, - EuiPopoverFooter, EuiBadge, - EuiSpacer, - EuiListGroup, - EuiListGroupItem, - EuiPanel, keys, EuiIcon, EuiButtonIcon, } from '@elastic/eui'; import React, { Children, isValidElement, useEffect, useRef, useState } from 'react'; import { IncontextInsight as IncontextInsightInput } from '../../types'; -import { getIncontextInsightRegistry, getNotifications } from '../../services'; +import { getIncontextInsightRegistry, getNotifications, getUISettings } from '../../services'; // TODO: Replace with getChrome().logos.Chat.url import chatIcon from '../../assets/chat.svg'; +import { + ChatPopoverBody, + ChatWithSuggestionsPopover, + GenerateSummaryPopoverBody, + SuggestionsPopoverFooter, + SummaryPopoverBody, + SummaryWithSuggestionsPopover, +} from './components'; +import { INCONTEXT_INSIGHT_INITIAL_ONLOAD_TIME_SETTING } from '../../../common/constants'; export interface IncontextInsightProps { children?: React.ReactNode; @@ -39,6 +39,11 @@ export interface IncontextInsightProps { export const IncontextInsight = ({ children }: IncontextInsightProps) => { const anchor = useRef(null); const [isVisible, setIsVisible] = useState(false); + const registry = getIncontextInsightRegistry(); + const toasts = getNotifications().toasts; + const initialOnloadTime = getUISettings().get(INCONTEXT_INSIGHT_INITIAL_ONLOAD_TIME_SETTING); + let target: React.ReactNode; + let input: IncontextInsightInput; useEffect(() => { // TODO: use animation when not using display: none @@ -61,7 +66,7 @@ export const IncontextInsight = ({ children }: IncontextInsightProps) => { 'incontextInsightHoverEffect100' ); - setTimeout(() => { + const fadeOut = () => { let opacityLevel = 100; const intervalId = setInterval(() => { incontextInsightAnchorIconClassList.remove(`incontextInsightHoverEffect${opacityLevel}`); @@ -70,15 +75,24 @@ export const IncontextInsight = ({ children }: IncontextInsightProps) => { clearInterval(intervalId); } opacityLevel -= 25; - }, 25); - }, 1250); - } - }, []); + }, 45); + }; - const registry = getIncontextInsightRegistry(); - const toasts = getNotifications().toasts; - let target: React.ReactNode; - let input: IncontextInsightInput; + const handleAnyClickEvent = (_: MouseEvent) => { + fadeOut(); + }; + + document.addEventListener('click', handleAnyClickEvent); + + setTimeout(() => { + fadeOut(); + }, initialOnloadTime); + + return () => { + document.removeEventListener('click', handleAnyClickEvent); + }; + } + }, [initialOnloadTime]); const findIncontextInsight = (node: React.ReactNode): React.ReactNode => { try { @@ -137,93 +151,6 @@ export const IncontextInsight = ({ children }: IncontextInsightProps) => { } }; - const SuggestionsPopoverFooter: React.FC<{ incontextInsight: IncontextInsightInput }> = ({ - incontextInsight, - }) => ( - - - {i18n.translate('assistantDashboards.incontextInsight.availableSuggestions', { - defaultMessage: 'Available suggestions', - })} - - - {registry.getSuggestions(incontextInsight.key).map((suggestion, index) => ( -
- - onSubmitClick(incontextInsight, suggestion)} - aria-label={suggestion} - wrapText - size="xs" - extraAction={{ - onClick: () => onSubmitClick(incontextInsight, suggestion), - iconType: 'sortRight', - iconSize: 's', - alwaysShow: true, - color: 'subdued', - }} - /> -
- ))} -
-
- ); - - const GeneratePopoverBody: React.FC<{}> = ({}) => ( - toasts.addDanger('To be implemented...')}>Generate summary - ); - - const SummaryPopoverBody: React.FC<{ incontextInsight: IncontextInsightInput }> = ({ - incontextInsight, - }) => ( - - {incontextInsight.summary} - - ); - - const SummaryWithSuggestionsPopoverBody: React.FC<{ - incontextInsight: IncontextInsightInput; - }> = ({ incontextInsight }) => ( - <> - {} - {} - - ); - - const ChatPopoverBody: React.FC<{}> = ({}) => ( - - - - - - - - toasts.addDanger('To be implemented...')} - > - Go - - - - ); - - const ChatWithSuggestionsPopoverBody: React.FC<{ incontextInsight: IncontextInsightInput }> = ({ - incontextInsight, - }) => ( - <> - {} - {} - - ); - const renderAnchor = () => { if (!input || !target) return children; @@ -253,19 +180,44 @@ export const IncontextInsight = ({ children }: IncontextInsightProps) => { const popoverBody = () => { switch (input.type) { case 'suggestions': - return ; + return ( + + ); case 'generate': - return ; + return ; case 'summary': return ; case 'summaryWithSuggestions': - return ; + return ( + + ); case 'chat': - return ; + return ; case 'chatWithSuggestions': - return ; + return ( + + ); default: - return ; + return ( + + ); } }; diff --git a/public/plugin.tsx b/public/plugin.tsx index 2d15682f..10565ebe 100644 --- a/public/plugin.tsx +++ b/public/plugin.tsx @@ -29,8 +29,10 @@ import { setChrome, setNotifications, setIncontextInsightRegistry, + setUISettings, } from './services'; import { ConfigSchema } from '../common/types/config'; +import { INCONTEXT_INSIGHT_INITIAL_ONLOAD_TIME_SETTING } from '../common/constants'; export const [getCoreStart, setCoreStart] = createGetterSetter('CoreStart'); @@ -109,7 +111,10 @@ export class AssistantPlugin const account = await getAccount(); const username = account.data.user_name; const tenant = account.data.user_requested_tenant ?? ''; - this.incontextInsightRegistry?.setIsEnabled(this.config.incontextInsight.enabled); + this.incontextInsightRegistry?.setIsEnabled( + this.config.incontextInsight.enabled && + coreStart.uiSettings.get(INCONTEXT_INSIGHT_INITIAL_ONLOAD_TIME_SETTING) >= 0 + ); coreStart.chrome.navControls.registerRight({ order: 10000, @@ -159,6 +164,7 @@ export class AssistantPlugin setCoreStart(core); setChrome(core.chrome); setNotifications(core.notifications); + setUISettings(core.uiSettings); return {}; } diff --git a/public/services/__tests__/incontext_insight_registry.test.ts b/public/services/__tests__/incontext_insight_registry.test.ts index b38d32bc..a84e972c 100644 --- a/public/services/__tests__/incontext_insight_registry.test.ts +++ b/public/services/__tests__/incontext_insight_registry.test.ts @@ -5,6 +5,7 @@ import { IncontextInsightRegistry } from '../incontext_insight'; import { IncontextInsight } from '../../types'; +import { ISuggestedAction, Interaction } from '../../../common/types/chat_saved_object_attributes'; describe('IncontextInsightRegistry', () => { let registry: IncontextInsightRegistry; @@ -14,9 +15,9 @@ describe('IncontextInsightRegistry', () => { beforeEach(() => { registry = new IncontextInsightRegistry(); insight = { - key: 'test', + key: 'test1', summary: 'test', - suggestions: [], + suggestions: ['suggestion1'], }; insight2 = { key: 'test2', @@ -54,4 +55,52 @@ describe('IncontextInsightRegistry', () => { registry.setIsEnabled(true); expect(registry.isEnabled()).toBe(true); }); + + it('sets interactionId when setInteraction is called', () => { + registry.register([insight, insight2]); + + const interaction: Interaction = { + interaction_id: '123', + input: insight.suggestions![0], + response: 'test response', + conversation_id: '321', + create_time: new Date().toISOString(), + }; + + registry.setInteraction(interaction); + + const updatedInsight = registry.get(insight.key); + expect(updatedInsight.interactionId).toBe(interaction.interaction_id); + + const nonUpdatedInsight = registry.get(insight2.key); + expect(nonUpdatedInsight.interactionId).toBeUndefined(); + }); + + it('sets suggestions when setSuggestionsByInteractionId is called', () => { + registry.register([insight, insight2]); + + const interaction: Interaction = { + interaction_id: '123', + input: insight.suggestions![0], + response: 'test response', + conversation_id: '321', + create_time: new Date().toISOString(), + }; + + registry.setInteraction(interaction); + const updatedInsight = registry.get(insight.key); + + const interactionId = updatedInsight.interactionId; + const suggestedActions: ISuggestedAction[] = [ + { actionType: 'send_as_input', message: 'suggestion2' }, + { actionType: 'send_as_input', message: 'suggestion3' }, + ]; + + registry.setSuggestionsByInteractionId(interactionId, suggestedActions); + + expect(registry.getSuggestions(insight.key)).toEqual( + suggestedActions.map(({ message }) => message) + ); + expect(registry.getSuggestions(insight2.key)).toEqual([]); + }); }); diff --git a/public/services/incontext_insight/incontext_insight_registry.ts b/public/services/incontext_insight/incontext_insight_registry.ts index 08ca8f34..ae36e9c4 100644 --- a/public/services/incontext_insight/incontext_insight_registry.ts +++ b/public/services/incontext_insight/incontext_insight_registry.ts @@ -7,6 +7,7 @@ import EventEmitter from 'events'; import { IncontextInsight, IncontextInsights } from '../../types'; import { ISuggestedAction, Interaction } from '../../../common/types/chat_saved_object_attributes'; +// TODO: implement chat to incontext insight interaction export class IncontextInsightRegistry extends EventEmitter { private registry: IncontextInsights = new Map(); private enabled: boolean = false; @@ -82,7 +83,7 @@ export class IncontextInsightRegistry extends EventEmitter { .map(({ message }) => message); } - public setInteractionId(interaction: Interaction | undefined) { + public setInteraction(interaction: Interaction | undefined) { if (!interaction || !interaction.interaction_id || !interaction.input) return; const incontextInsight = Array.from(this.registry.values()).find( (value) => value.suggestions && value.suggestions.includes(interaction.input) @@ -90,7 +91,4 @@ export class IncontextInsightRegistry extends EventEmitter { if (!incontextInsight) return; this.registry.get(incontextInsight.key)!.interactionId = interaction.interaction_id; } - - // TODO: two way service pltr component to chat bot - // TODO: two way service chat bot to pltr component } diff --git a/public/services/index.ts b/public/services/index.ts index 72243960..ecbf9425 100644 --- a/public/services/index.ts +++ b/public/services/index.ts @@ -4,7 +4,7 @@ */ import { createGetterSetter } from '../../../../src/plugins/opensearch_dashboards_utils/public'; -import { ChromeStart, NotificationsStart } from '../../../../src/core/public'; +import { ChromeStart, NotificationsStart, IUiSettingsClient } from '../../../../src/core/public'; import { IncontextInsightRegistry } from './incontext_insight'; export * from './incontext_insight'; @@ -20,3 +20,5 @@ export const [getChrome, setChrome] = createGetterSetter('Chrome'); export const [getNotifications, setNotifications] = createGetterSetter( 'Notifications' ); + +export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); diff --git a/public/tabs/chat/chat_page_content.test.tsx b/public/tabs/chat/chat_page_content.test.tsx index 67051717..2b458f2e 100644 --- a/public/tabs/chat/chat_page_content.test.tsx +++ b/public/tabs/chat/chat_page_content.test.tsx @@ -29,7 +29,7 @@ jest.mock('./messages/message_content', () => { beforeEach(() => { (getIncontextInsightRegistry as jest.Mock).mockImplementation(() => ({ setSuggestionsByInteractionId: jest.fn(), - setInteractionId: jest.fn(), + setInteraction: jest.fn(), })); }); diff --git a/public/tabs/chat/chat_page_content.tsx b/public/tabs/chat/chat_page_content.tsx index 89f518cc..d6c1da36 100644 --- a/public/tabs/chat/chat_page_content.tsx +++ b/public/tabs/chat/chat_page_content.tsx @@ -121,7 +121,7 @@ export const ChatPageContent: React.FC = React.memo((props interaction = chatState.interactions.find( (item) => item.interaction_id === message.interactionId ); - registry.setInteractionId(interaction); + registry.setInteraction(interaction); } return ( diff --git a/server/plugin.ts b/server/plugin.ts index 26b86e17..413c192d 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -16,6 +16,7 @@ import { setupRoutes } from './routes/index'; import { AssistantPluginSetup, AssistantPluginStart, MessageParser } from './types'; import { BasicInputOutputParser } from './parsers/basic_input_output_parser'; import { VisualizationCardParser } from './parsers/visualization_card_parser'; +import { uiSettings } from './ui_settings'; export class AssistantPlugin implements Plugin { private readonly logger: Logger; @@ -52,6 +53,8 @@ export class AssistantPlugin implements Plugin { const findItem = this.messageParsers.find((item) => item.id === messageParser.id); if (findItem) { diff --git a/server/ui_settings.ts b/server/ui_settings.ts new file mode 100644 index 00000000..fed21704 --- /dev/null +++ b/server/ui_settings.ts @@ -0,0 +1,30 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { schema } from '@osd/config-schema'; + +import { UiSettingsParams } from 'opensearch-dashboards/server'; +import { INCONTEXT_INSIGHT_INITIAL_ONLOAD_TIME_SETTING } from '../common/constants'; + +export const uiSettings: Record = { + [INCONTEXT_INSIGHT_INITIAL_ONLOAD_TIME_SETTING]: { + name: i18n.translate('assistantDashboards.advancedSettings.incontextInsightInitialOnloadTime', { + defaultMessage: 'Incontext insight initial onload time', + }), + value: 10000, + description: i18n.translate( + 'assistantDashboards.advancedSettings.incontextInsightInitialOnloadTimeText', + { + defaultMessage: + 'The time in milliseconds for the initial onload state for incontext insights where the user sees the chat icon. ' + + 'Setting to a negative value will completely disable incontext insights.', + } + ), + requiresPageReload: true, + category: ['assistant'], + schema: schema.number(), + }, +}; diff --git a/tsconfig.json b/tsconfig.json index b9baab8a..5f3296fd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,12 +18,14 @@ "useUnknownInCatchVariables": false, "alwaysStrict": false, "noImplicitUseStrict": false, - "types": ["jest", "node"] + "types": ["jest", "node"], + "paths": { + "opensearch-dashboards/server": ["../../src/core/server"], + }, }, "include": [ "test/**/*", "index.ts", - "config.ts", "public/**/*.ts", "public/**/*.tsx", "server/**/*.ts",