From 6dce0f66e8a808469502de2120a691ba87c9fb70 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Fri, 9 Feb 2024 10:50:24 +0800 Subject: [PATCH] [Incontext Insights] wrapper component and service (#53) (#144) * [Palantir] wrapper component and service Registry and component to be used by plugins. Example of usage: https://github.com/opensearch-project/alerting-dashboards-plugin/pull/852 Signed-off-by: Kawika Avilla * recursion Signed-off-by: Kawika Avilla * more styling on component Signed-off-by: Kawika Avilla * one more try Signed-off-by: Kawika Avilla * Using wrapper component Signed-off-by: Kawika Avilla * almost Signed-off-by: Kawika Avilla * add arrow Signed-off-by: Kawika Avilla * cross Signed-off-by: Kawika Avilla * renamed Signed-off-by: Kawika Avilla * no destory Signed-off-by: Kawika Avilla * hacky styling Signed-off-by: Kawika Avilla * some rough tests and readme Signed-off-by: Kawika Avilla * add more doc Signed-off-by: Kawika Avilla * re-add type Signed-off-by: Kawika Avilla * fix tests Signed-off-by: Kawika Avilla * add i18n Signed-off-by: Kawika Avilla * enable target feature Signed-off-by: Kawika Avilla * remove invalid test now Signed-off-by: Kawika Avilla * missing palantir ref Signed-off-by: Kawika Avilla * make callable render function Signed-off-by: Kawika Avilla * update docs Signed-off-by: Kawika Avilla * cleaner incode styling Signed-off-by: Kawika Avilla * build config Signed-off-by: Kawika Avilla * move to server Signed-off-by: Kawika Avilla * not available for assets Signed-off-by: Kawika Avilla * fix path Signed-off-by: Kawika Avilla * check on call Signed-off-by: Kawika Avilla * dont give id Signed-off-by: Kawika Avilla * clean up styles one more time Signed-off-by: Kawika Avilla * add config to disable incontext not matter what Signed-off-by: Kawika Avilla * add doc Signed-off-by: Kawika Avilla * update changelog Signed-off-by: Kawika Avilla * more tests Signed-off-by: Kawika Avilla * address naming of classes Signed-off-by: Kawika Avilla * Add unused container Signed-off-by: Kawika Avilla * Removed default enabled config Signed-off-by: Kawika Avilla --------- Signed-off-by: Kawika Avilla (cherry picked from commit 5e4c9711ea225fd63ce97e58578e5f6638157d09) Signed-off-by: github-actions[bot] # Conflicts: # CHANGELOG.md Co-authored-by: github-actions[bot] --- common/types/config.ts | 19 + docs/incontext_insight/component.md | 62 ++++ docs/incontext_insight/registry.md | 62 ++++ docs/incontext_insight/service.md | 81 +++++ public/chat_header_button.test.tsx | 11 + public/chat_header_button.tsx | 25 ++ .../__tests__/incontext_insight.test.tsx | 51 +++ .../components/incontext_insight/index.scss | 144 ++++++++ public/components/incontext_insight/index.tsx | 330 ++++++++++++++++++ public/contexts/core_context.tsx | 9 +- public/index.ts | 13 +- public/plugin.tsx | 73 +++- .../incontext_insight_registry.test.ts | 57 +++ .../incontext_insight_provider.ts | 15 + .../incontext_insight_registry.ts | 96 +++++ public/services/incontext_insight/index.ts | 11 + public/services/index.ts | 22 ++ public/tabs/chat/chat_page_content.test.tsx | 10 + public/tabs/chat/chat_page_content.tsx | 6 + public/tabs/chat/messages/message_bubble.tsx | 1 + public/tabs/chat_window_header.tsx | 1 + public/types.ts | 27 +- server/index.ts | 27 +- server/plugin.ts | 4 +- tsconfig.json | 1 + 25 files changed, 1108 insertions(+), 50 deletions(-) create mode 100644 common/types/config.ts create mode 100644 docs/incontext_insight/component.md create mode 100644 docs/incontext_insight/registry.md create mode 100644 docs/incontext_insight/service.md create mode 100644 public/components/__tests__/incontext_insight.test.tsx create mode 100644 public/components/incontext_insight/index.scss create mode 100644 public/components/incontext_insight/index.tsx create mode 100644 public/services/__tests__/incontext_insight_registry.test.ts create mode 100644 public/services/incontext_insight/incontext_insight_provider.ts create mode 100644 public/services/incontext_insight/incontext_insight_registry.ts create mode 100644 public/services/incontext_insight/index.ts create mode 100644 public/services/index.ts diff --git a/common/types/config.ts b/common/types/config.ts new file mode 100644 index 00000000..78f31871 --- /dev/null +++ b/common/types/config.ts @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +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 }), + chat: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), + incontextInsight: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), +}); + +export type ConfigSchema = TypeOf; diff --git a/docs/incontext_insight/component.md b/docs/incontext_insight/component.md new file mode 100644 index 00000000..7ab57e43 --- /dev/null +++ b/docs/incontext_insight/component.md @@ -0,0 +1,62 @@ +# IncontextInsight + +`IncontextInsight` is a React component that provides a context for displaying insights in your application. It uses services such as `getChrome`, `getNotifications`, and `getIncontextInsightRegistry` to manage and display insights. + + +## Props + +`IncontextInsight` takes the following props: + +- `children`: ReactNode. The child components to be rendered within the `IncontextInsight` context. + +## Usage + +```typescriptreact +import { IncontextInsight } from '../incontext_insight'; + + +
Your content here
+
+``` + +In usage of a plugin, IncontextInsight is used to wrap an element. The div and its content will be rendered within the context provided by IncontextInsight. +To ensure your plugin does not require the Assistant Dashboards plugin bundle define a functional component that will render a div with props by default and +if the Assistant Dashboards plugin is available then on plugin setup call renderIncontextInsightComponent passing the same props. For example: + +```typescriptreact +import React from 'react'; +import { OuiLink } from '@opensearch-project/oui'; + +// export default component +export let ExampleIncontextInsightComponent = (props: any) =>
; + +//====== plugin setup ======// +// check Assistant Dashboards is installed +if (assistantDashboards) { + // update default component + ExampleIncontextInsightComponent = (props: any) => ( + <>{assistantDashboards.renderIncontextInsight(props)} + ); +} +//====== plugin setup ======// + + +function ExampleComponent() { + return ( + // Use your component + + + Example Link + + + ); +} + +export default ExampleComponent; +``` + +The ExampleIncontextInsightComponent is a React component used in this code to wrap an OuiLink component with a `
` or the functional component defined by Assistant Dashboards. The OuiLink component is a part of the OpenSearch UI framework and is used to create a hyperlink. diff --git a/docs/incontext_insight/registry.md b/docs/incontext_insight/registry.md new file mode 100644 index 00000000..bd875e49 --- /dev/null +++ b/docs/incontext_insight/registry.md @@ -0,0 +1,62 @@ +# IncontextInsightRegistry + +`IncontextInsightRegistry` is a TypeScript class that manages the registration and retrieval of `IncontextInsight` items. + +## Methods + +### open(item: IncontextInsight, suggestion: string) + +This method emits an 'onSuggestion' event with the provided suggestion. + +### register(item: IncontextInsight | IncontextInsight[]) + +This method registers a single `IncontextInsight` item or an array of `IncontextInsight` items. Each item is mapped using the `mapper` method before being stored in the registry. + +### get(key: string): IncontextInsight + +This method retrieves an `IncontextInsight` item from the registry using its key. + +### getAll(): IncontextInsight[] + +This method retrieves all `IncontextInsight` items from the registry. + +### getSummary(key: string) + +This method retrieves the summary of an `IncontextInsight` item using its key. + +## Usage + +```typescript +import { IncontextInsightRegistry } from './incontext_insight_registry'; + +const registry = new IncontextInsightRegistry(); + +// Register a single item +registry.register({ + key: 'item1', + summary: 'This is item 1', + suggestions: ['suggestion1', 'suggestion2'], +}); + +// Register multiple items +registry.register([ + { + key: 'item2', + summary: 'This is item 2', + suggestions: ['suggestion3', 'suggestion4'], + }, + { + key: 'item3', + summary: 'This is item 3', + suggestions: ['suggestion5', 'suggestion6'], + }, +]); + +// Retrieve an item +const item1 = registry.get('item1'); + +// Retrieve all items +const allItems = registry.getAll(); + +// Retrieve an item's summary +const item1Summary = registry.getSummary('item1'); \ No newline at end of file diff --git a/docs/incontext_insight/service.md b/docs/incontext_insight/service.md new file mode 100644 index 00000000..271cbe04 --- /dev/null +++ b/docs/incontext_insight/service.md @@ -0,0 +1,81 @@ +# IncontextInsights and Chat Interaction + +`IncontextInsights` can be used to enhance the chat experience by providing contextual insights based on the ongoing conversation. + +The `assistantDashboards` is should be an optional property in the `PluginSetupDeps` interface. It represents a plugin that might be available during the setup phase of a plugin. + +Here's an example of how you might use the `assistantDashboards` plugin in the `AlertingPlugin` setup: + +```typescript +import { CoreSetup } from 'src/core/public'; +import { AssistantPublicPluginSetup } from 'src/plugins/assistant/public'; + +interface AlertingSetupDeps { + expressions: any; + uiActions: any; + assistantDashboards?: AssistantPublicPluginSetup; +} + +class AlertingPlugin implements Plugin<{}, {}, AlertingSetupDeps> { + public setup(core: CoreSetup, { assistantDashboards }: AlertingSetupDeps) { + if (assistantDashboards) { + // Use the assistantDashboards plugin + assistantDashboards.registerIncontextInsight([ + { + key: 'query_level_monitor', + summary: + 'Per query monitors are a type of alert monitor that can be used to identify and alert on specific queries that are run against an OpenSearch index; for example, queries that detect and respond to anomalies in specific queries. Per query monitors only trigger one alert at a time.', + suggestions: ['How to better configure my monitor?'], + }, + { + key: 'content_panel_Data source', + summary: + 'OpenSearch data sources are the applications that OpenSearch can connect to and ingest data from.', + suggestions: ['What are the indices in my cluster?'], + }, + ]); + } + } +} +``` + +In this example, we're checking if the `assistantDashboards` plugin is available during the setup phase. If it is, we're using it to register incontext insights for specific keys with a seed suggestion. + +## How it works + +When a chat message is sent or received, the `IncontextInsightRegistry` can be queried for relevant insights based on the content of the message. These insights can then be displayed in the chat interface to provide additional information or suggestions to the user. + +## Usage + +Here's an example of how you might use `IncontextInsights` in a chat application: + +```typescript +import { IncontextInsightRegistry } from './incontext_insight_registry'; + +const registry = new IncontextInsightRegistry(); + +// Register some insights +registry.register([ + { key: 'greeting', summary: 'This is a greeting', suggestions: ['Hello', 'Hi', 'Hey'] }, + { key: 'farewell', summary: 'This is a farewell', suggestions: ['Goodbye', 'See you', 'Take care'] }, +]); + +// When a message is sent or received... +const message = 'Hello, how are you?'; + +// Query the registry for relevant insights +const insights = registry.getAll().filter(insight => message.includes(insight.summary)); + +// Display the insights in the chat interface +insights.forEach(insight => { + console.log(`Suggestion: ${insight.suggestions[0]}`); +}); +``` + +## Disabling incontext insights + +By default, `IncontextInsights` will be enabled if chat is enabled. The following configuration disables this component: + +```yaml +assistant.incontextInsight.enabled: false +``` diff --git a/public/chat_header_button.test.tsx b/public/chat_header_button.test.tsx index f7c3c773..ad4825e4 100644 --- a/public/chat_header_button.test.tsx +++ b/public/chat_header_button.test.tsx @@ -13,6 +13,7 @@ import { BehaviorSubject } from 'rxjs'; let mockSend: jest.Mock; let mockLoadChat: jest.Mock; +let mockIncontextInsightRegistry: jest.Mock; jest.mock('./hooks/use_chat_actions', () => { mockSend = jest.fn(); @@ -46,6 +47,16 @@ jest.mock('./chat_flyout', () => { }; }); +jest.mock('./services', () => { + mockIncontextInsightRegistry = jest.fn().mockReturnValue({ + on: jest.fn(), + off: jest.fn(), + }); + return { + getIncontextInsightRegistry: mockIncontextInsightRegistry, + }; +}); + describe('', () => { afterEach(() => { jest.clearAllMocks(); diff --git a/public/chat_header_button.tsx b/public/chat_header_button.tsx index 2cda923d..4aee4d14 100644 --- a/public/chat_header_button.tsx +++ b/public/chat_header_button.tsx @@ -8,7 +8,9 @@ import classNames from 'classnames'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useEffectOnce } from 'react-use'; import { ApplicationStart } from '../../../src/core/public'; +// TODO: Replace with getChrome().logos.Chat.url import chatIcon from './assets/chat.svg'; +import { getIncontextInsightRegistry } from './services'; import { ChatFlyout } from './chat_flyout'; import { ChatContext, IChatContext } from './contexts/chat_context'; import { SetContext } from './contexts/set_context'; @@ -41,6 +43,7 @@ export const HeaderChatButton = (props: HeaderChatButtonProps) => { const [inputFocus, setInputFocus] = useState(false); const flyoutFullScreen = chatSize === 'fullscreen'; const inputRef = useRef(null); + const registry = getIncontextInsightRegistry(); if (!flyoutLoaded && flyoutVisible) flyoutLoaded = true; @@ -138,6 +141,28 @@ export const HeaderChatButton = (props: HeaderChatButtonProps) => { }; }, [props.userHasAccess]); + useEffect(() => { + const handleSuggestion = (event: { suggestion: string }) => { + if (!flyoutVisible) { + // open chat window + setFlyoutVisible(true); + // start a new chat + props.assistantActions.loadChat(); + } + // send message + props.assistantActions.send({ + type: 'input', + contentType: 'text', + content: event.suggestion, + context: { appId }, + }); + }; + registry.on('onSuggestion', handleSuggestion); + return () => { + registry.off('onSuggestion', handleSuggestion); + }; + }, [appId, flyoutVisible, props.assistantActions, registry]); + return ( <>
diff --git a/public/components/__tests__/incontext_insight.test.tsx b/public/components/__tests__/incontext_insight.test.tsx new file mode 100644 index 00000000..0dcd9965 --- /dev/null +++ b/public/components/__tests__/incontext_insight.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { IncontextInsight } from '../incontext_insight'; +import { getChrome, getNotifications, getIncontextInsightRegistry } from '../../services'; + +jest.mock('../../services'); + +beforeEach(() => { + (getChrome as jest.Mock).mockImplementation(() => ({ + logos: 'mocked logos', + })); + (getNotifications as jest.Mock).mockImplementation(() => ({ + toasts: { + addSuccess: jest.fn(), + addError: jest.fn(), + }, + })); + (getIncontextInsightRegistry as jest.Mock).mockImplementation(() => {}); +}); + +describe('IncontextInsight', () => { + afterEach(cleanup); + + it('renders the child', () => { + const { getByText } = render( + +
Test child
+
+ ); + + expect(getByText('Test child')).toBeInTheDocument(); + }); + + it('renders the children', () => { + const { getByText } = render( + +
+

Test child

+
Test child 2
+
+
+ ); + + expect(getByText('Test child')).toBeInTheDocument(); + }); +}); diff --git a/public/components/incontext_insight/index.scss b/public/components/incontext_insight/index.scss new file mode 100644 index 00000000..8b67e090 --- /dev/null +++ b/public/components/incontext_insight/index.scss @@ -0,0 +1,144 @@ +// TODO: fix the styling due to not wanting the chat icon to push elements +// Might be worth reconsidering the icon for absolute positioning +.incontextInsightHoverEffectUnderline { + border: solid 2px transparent !important; + margin: -2px -5px -5px -7px !important; +} + +.incontextInsightHoverEffect0 { + margin-left: 6px; + margin-right: 2px; + padding: 0 2px; + opacity: 0 !important; + display: block !important; +} + +.incontextInsightHoverEffect25 { + opacity: .25 !important; + display: block !important; +} + +.incontextInsightHoverEffect50 { + opacity: .5 !important; + display: block !important; +} + +.incontextInsightHoverEffect75 { + opacity: .75 !important; + display: block !important; +} + +.incontextInsightHoverEffect100 { + opacity: 1 !important; + display: block !important; +} + +.incontextInsightAnchorButton { + box-sizing: border-box; + max-width: fit-content; + border-radius: 0; + border-bottom: dashed 2px #38414D; + white-space: nowrap; + // TODO: this doesn't scale nicely on same elements on view + // border-image: linear-gradient(to left, #38414D 60%, transparent 40%) 20% repeat; + padding: 0 5px; + margin: 0 -2px -5px -5px; + + .incontextInsightAnchorIcon { + :first-child { + opacity: 0; + display: none; + } + } +} + +.incontextInsightAnchorButton:hover { + cursor: pointer; + border-radius: 180px; + border: solid 2px $ouiBorderColor; + margin: -2px -5px -5px -7px; + + .incontextInsightAnchorIcon { + :first-child { + margin-left: 6px; + margin-right: 2px; + padding: 0 2px; + opacity: 1; + transition: opacity 100ms linear; + display: block; + } + } +} + +.incontextInsightAnchorButton:focus { + background: none; +} + +.incontextInsightPopoverTitle { + // TODO: Remove this one paddingSize is fixed + padding: 0 !important; + margin-bottom: 0 !important; + border: none; + + :first-child { + border: none; + } + + .euiBadge__text { + text-transform: none; + font-weight: bold; + } + + .euiBadge__icon { + margin: 2px 0 2px 0; + } + + .euiIcon--small { + height: 100%; + width: 22px; + padding: 2px 0; + } +} + +.incontextInsightPopoverFooter { + // TODO: Remove this one paddingSize is fixed + padding: 4px 12px 8px !important; + border: none; +} + +.incontextInsightPopoverBody { + width: 300px; +} + +.incontextInsightSummary { + border: $euiBorderThin; + border-radius: 4px; +} + +.incontextInsightSuggestionListItem { + margin-top: 0; + border: $euiBorderThin; + border-radius: 4px; + + .euiListGroupItem__button { + padding: 0; + } + + .euiListGroupItem__icon { + margin: 2px; + margin-right: 5px; + } + + .euiListGroupItem__extraAction { + margin-left: 10px; + margin-right: -2px; + } + + .euiListGroupItem__extraAction:hover { + background: none; + } + + .euiListGroupItem__extraAction:focus { + background: none; + } +} diff --git a/public/components/incontext_insight/index.tsx b/public/components/incontext_insight/index.tsx new file mode 100644 index 00000000..736d17df --- /dev/null +++ b/public/components/incontext_insight/index.tsx @@ -0,0 +1,330 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +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'; +// TODO: Replace with getChrome().logos.Chat.url +import chatIcon from '../../assets/chat.svg'; + +export interface IncontextInsightProps { + children?: React.ReactNode; +} + +// TODO: add saved objects / config to store seed suggestions +export const IncontextInsight = ({ children }: IncontextInsightProps) => { + const anchor = useRef(null); + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + // TODO: use animation when not using display: none + if (anchor.current) { + const incontextInsightAnchorButtonClassList = anchor.current.parentElement?.querySelector( + '.incontextInsightAnchorButton' + )?.classList; + const incontextInsightAnchorIconClassList = anchor.current.querySelector( + '.incontextInsightAnchorIcon' + )?.children[0].classList; + + if (!incontextInsightAnchorButtonClassList || !incontextInsightAnchorIconClassList) return; + + incontextInsightAnchorButtonClassList.add('incontextInsightHoverEffectUnderline'); + incontextInsightAnchorIconClassList.add( + 'incontextInsightHoverEffect0', + 'incontextInsightHoverEffect25', + 'incontextInsightHoverEffect50', + 'incontextInsightHoverEffect75', + 'incontextInsightHoverEffect100' + ); + + setTimeout(() => { + let opacityLevel = 100; + const intervalId = setInterval(() => { + incontextInsightAnchorIconClassList.remove(`incontextInsightHoverEffect${opacityLevel}`); + if (opacityLevel === 0) { + incontextInsightAnchorButtonClassList.remove('incontextInsightHoverEffectUnderline'); + clearInterval(intervalId); + } + opacityLevel -= 25; + }, 25); + }, 1250); + } + }, []); + + const registry = getIncontextInsightRegistry(); + const toasts = getNotifications().toasts; + let target: React.ReactNode; + let input: IncontextInsightInput; + + const findIncontextInsight = (node: React.ReactNode): React.ReactNode => { + try { + if (!isValidElement(node)) return; + if (node.key && registry.get(node.key as string)) { + input = registry.get(node.key as string); + target = node; + return; + } + + if (node.props.children) { + Children.forEach(node.props.children, (child) => { + findIncontextInsight(child); + }); + } + if (!input) throw Error('Child key not found in registry.'); + } catch { + return; + } + }; + + const onAnchorClick = () => { + setIsVisible(!isVisible); + if (anchor.current) { + const incontextInsightAnchorButtonClassList = anchor.current.parentElement?.querySelector( + '.incontextInsightAnchorButton' + )?.classList; + incontextInsightAnchorButtonClassList?.add('incontextInsightHoverEffectUnderline'); + } + }; + + const onAnchorKeyPress = (event: React.KeyboardEvent) => { + if (event.key === keys.TAB) { + onAnchorClick(); + } + }; + + const closePopover = () => { + setIsVisible(false); + if (anchor.current) { + const incontextInsightAnchorButtonClassList = anchor.current.parentElement?.querySelector( + '.incontextInsightAnchorButton' + )?.classList; + incontextInsightAnchorButtonClassList?.remove('incontextInsightHoverEffectUnderline'); + } + }; + + const onSubmitClick = (incontextInsight: IncontextInsightInput, suggestion: string) => { + setIsVisible(false); + registry.open(incontextInsight, suggestion); + if (anchor.current) { + const incontextInsightAnchorButtonClassList = anchor.current.parentElement?.querySelector( + '.incontextInsightAnchorButton' + )?.classList; + incontextInsightAnchorButtonClassList?.remove('incontextInsightHoverEffectUnderline'); + } + }; + + 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; + + return ( + + +
{target}
+
+ +
+ +
+
+
+ ); + }; + + const renderPopover = () => { + if (!input || !target || !anchor.current) return; + const popoverBody = () => { + switch (input.type) { + case 'suggestions': + return ; + case 'generate': + return ; + case 'summary': + return ; + case 'summaryWithSuggestions': + return ; + case 'chat': + return ; + case 'chatWithSuggestions': + return ; + default: + return ; + } + }; + + return ( + + + + +
+ + {i18n.translate('assistantDashboards.incontextInsight.assistant', { + defaultMessage: 'OpenSearch Assistant', + })} + +
+
+ +
+ +
+
+
+
+
{popoverBody()}
+
+ ); + }; + + findIncontextInsight(children); + + return ( + <> + <>{renderPopover()} + <>{renderAnchor()} + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { IncontextInsight as default }; diff --git a/public/contexts/core_context.tsx b/public/contexts/core_context.tsx index 4ed06cfb..c2d4ff08 100644 --- a/public/contexts/core_context.tsx +++ b/public/contexts/core_context.tsx @@ -7,13 +7,12 @@ import { OpenSearchDashboardsServices, useOpenSearchDashboards, } from '../../../../src/plugins/opensearch_dashboards_react/public'; -import { AppPluginStartDependencies, SetupDependencies } from '../types'; -import { ConversationLoadService } from '../services/conversation_load_service'; -import { ConversationsService } from '../services/conversations_service'; +import { AssistantPluginStartDependencies, AssistantPluginSetupDependencies } from '../types'; +import { ConversationLoadService, ConversationsService } from '../services'; export interface AssistantServices extends Required { - setupDeps: SetupDependencies; - startDeps: AppPluginStartDependencies; + setupDeps: AssistantPluginSetupDependencies; + startDeps: AssistantPluginStartDependencies; conversationLoad: ConversationLoadService; conversations: ConversationsService; } diff --git a/public/index.ts b/public/index.ts index a16d3c12..e2786e83 100644 --- a/public/index.ts +++ b/public/index.ts @@ -4,9 +4,18 @@ */ import { PluginInitializerContext } from '../../../src/core/public'; -import { AssistantPlugin } from './plugin'; +import { AssistantPlugin, IncontextInsightComponent } from './plugin'; +import { AssistantSetup, AssistantStart, IncontextInsight } from './types'; -export { AssistantPlugin as Plugin }; +export { + AssistantPlugin as Plugin, + AssistantSetup as AssistantPublicPluginSetup, + AssistantStart as AssistantPublicPluginStart, + IncontextInsight, + IncontextInsightComponent, +}; + +export * from './services'; export function plugin(initializerContext: PluginInitializerContext) { return new AssistantPlugin(initializerContext); diff --git a/public/plugin.tsx b/public/plugin.tsx index c6854389..2d15682f 100644 --- a/public/plugin.tsx +++ b/public/plugin.tsx @@ -3,7 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import React, { lazy, Suspense } from 'react'; import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '../../../src/core/public'; import { createOpenSearchDashboardsReactContext, @@ -12,42 +13,62 @@ import { import { createGetterSetter } from '../../../src/plugins/opensearch_dashboards_utils/common'; import { HeaderChatButton } from './chat_header_button'; import { AssistantServices } from './contexts/core_context'; -import { ConversationLoadService } from './services/conversation_load_service'; -import { ConversationsService } from './services/conversations_service'; import { ActionExecutor, - AppPluginStartDependencies, + AssistantPluginStartDependencies, + AssistantPluginSetupDependencies, AssistantActions, AssistantSetup, AssistantStart, MessageRenderer, - SetupDependencies, } from './types'; +import { + IncontextInsightRegistry, + ConversationLoadService, + ConversationsService, + setChrome, + setNotifications, + setIncontextInsightRegistry, +} from './services'; +import { ConfigSchema } from '../common/types/config'; export const [getCoreStart, setCoreStart] = createGetterSetter('CoreStart'); -interface PublicConfig { - chat: { - // whether chat feature is enabled, UI should hide if false - enabled: boolean; - }; -} +// @ts-ignore +const LazyIncontextInsightComponent = lazy(() => import('./components/incontext_insight')); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const IncontextInsightComponent: React.FC<{ props: any }> = (props) => ( + }> + + +); interface UserAccountResponse { data: { roles: string[]; user_name: string; user_requested_tenant?: string }; } export class AssistantPlugin - implements Plugin { - private config: PublicConfig; + implements + Plugin< + AssistantSetup, + AssistantStart, + AssistantPluginSetupDependencies, + AssistantPluginStartDependencies + > { + private config: ConfigSchema; + incontextInsightRegistry: IncontextInsightRegistry | undefined; + constructor(initializerContext: PluginInitializerContext) { - this.config = initializerContext.config.get(); + this.config = initializerContext.config.get(); } public setup( - core: CoreSetup, - setupDeps: SetupDependencies + core: CoreSetup, + setupDeps: AssistantPluginSetupDependencies ): AssistantSetup { + this.incontextInsightRegistry = new IncontextInsightRegistry(); + setIncontextInsightRegistry(this.incontextInsightRegistry); const messageRenderers: Record = {}; const actionExecutors: Record = {}; const assistantActions: AssistantActions = {} as AssistantActions; @@ -75,7 +96,9 @@ export class AssistantPlugin account.data.roles.some((role) => ['all_access', 'assistant_user'].includes(role)); if (this.config.chat.enabled) { - core.getStartServices().then(async ([coreStart, startDeps]) => { + const setupChat = async () => { + const [coreStart, startDeps] = await core.getStartServices(); + const CoreContext = createOpenSearchDashboardsReactContext({ ...coreStart, setupDeps, @@ -86,6 +109,7 @@ 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); coreStart.chrome.navControls.registerRight({ order: 10000, @@ -102,7 +126,8 @@ export class AssistantPlugin ), }); - }); + }; + setupChat(); } return { @@ -119,11 +144,21 @@ export class AssistantPlugin chatEnabled: () => this.config.chat.enabled, userHasAccess: async () => await getAccount().then(checkAccess), assistantActions, + registerIncontextInsight: this.incontextInsightRegistry.register.bind( + this.incontextInsightRegistry + ), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + renderIncontextInsight: (props: any) => { + if (!this.incontextInsightRegistry?.isEnabled()) return
; + return ; + }, }; } - public start(core: CoreStart, startDeps: AppPluginStartDependencies): AssistantStart { + public start(core: CoreStart): AssistantStart { setCoreStart(core); + setChrome(core.chrome); + setNotifications(core.notifications); return {}; } diff --git a/public/services/__tests__/incontext_insight_registry.test.ts b/public/services/__tests__/incontext_insight_registry.test.ts new file mode 100644 index 00000000..b38d32bc --- /dev/null +++ b/public/services/__tests__/incontext_insight_registry.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IncontextInsightRegistry } from '../incontext_insight'; +import { IncontextInsight } from '../../types'; + +describe('IncontextInsightRegistry', () => { + let registry: IncontextInsightRegistry; + let insight: IncontextInsight; + let insight2: IncontextInsight; + + beforeEach(() => { + registry = new IncontextInsightRegistry(); + insight = { + key: 'test', + summary: 'test', + suggestions: [], + }; + insight2 = { + key: 'test2', + summary: 'test', + suggestions: [], + }; + }); + + it('emits "onSuggestion" event when open is called', () => { + const mockFn = jest.fn(); + registry.on('onSuggestion', mockFn); + + registry.open(insight, 'test suggestion'); + + expect(mockFn).toHaveBeenCalledWith({ suggestion: 'test suggestion' }); + }); + + it('adds item to registry when register is called with a single item', () => { + registry.register(insight); + + expect(registry.get(insight.key)).toEqual(insight); + }); + + it('adds items to registry when register is called with an array of items', () => { + registry.register([insight, insight2]); + + expect(registry.get(insight2.key)).toEqual(insight2); + }); + + it('checks if the registry is disabled on default', () => { + expect(registry.isEnabled()).toBe(false); + }); + + it('checks if the registry is enabled after setting', () => { + registry.setIsEnabled(true); + expect(registry.isEnabled()).toBe(true); + }); +}); diff --git a/public/services/incontext_insight/incontext_insight_provider.ts b/public/services/incontext_insight/incontext_insight_provider.ts new file mode 100644 index 00000000..68fc586f --- /dev/null +++ b/public/services/incontext_insight/incontext_insight_provider.ts @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createContext } from 'react'; +import { IncontextInsightRegistry } from './incontext_insight_registry'; + +export const IncontextInsightContext = createContext( + undefined +); + +export const IncontextInsightProvider = IncontextInsightContext.Provider; + +export type IncontextInsightProviderType = ReturnType; diff --git a/public/services/incontext_insight/incontext_insight_registry.ts b/public/services/incontext_insight/incontext_insight_registry.ts new file mode 100644 index 00000000..08ca8f34 --- /dev/null +++ b/public/services/incontext_insight/incontext_insight_registry.ts @@ -0,0 +1,96 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import EventEmitter from 'events'; +import { IncontextInsight, IncontextInsights } from '../../types'; +import { ISuggestedAction, Interaction } from '../../../common/types/chat_saved_object_attributes'; + +export class IncontextInsightRegistry extends EventEmitter { + private registry: IncontextInsights = new Map(); + private enabled: boolean = false; + + private mapper = (incontextInsight: IncontextInsight) => { + return { + key: incontextInsight.key, + type: incontextInsight.type, + summary: incontextInsight.summary, + suggestions: incontextInsight.suggestions, + }; + }; + + public isEnabled() { + return this.enabled; + } + + public setIsEnabled(enabled: boolean) { + this.enabled = enabled; + } + + public open(item: IncontextInsight, suggestion: string) { + // TODO: passing incontextInsight for future usage + this.emit('onSuggestion', { + suggestion, + }); + } + + public register(item: IncontextInsight | IncontextInsight[]): void; + public register(item: unknown) { + if (Array.isArray(item)) { + item.forEach((incontextInsight: IncontextInsight) => + this.registry.set(incontextInsight.key, this.mapper(incontextInsight)) + ); + } else { + const incontextInsight = item as IncontextInsight; + this.registry.set(incontextInsight.key, this.mapper(incontextInsight)); + } + } + + public get(key: string): IncontextInsight { + return this.registry.get(key) as IncontextInsight; + } + + public getAll(): IncontextInsight[] { + return Array.from(this.registry.values()); + } + + public getSummary(key: string) { + return this.get(key).summary; + } + + public getSuggestions(key: string) { + if (!this.get(key) || !this.get(key).suggestions) return []; + return this.get(key).suggestions!; + } + + public setSuggestionsByInteractionId( + interactionId: string | undefined, + suggestedActions: ISuggestedAction[] + ) { + if ( + !interactionId || + suggestedActions.filter(({ actionType }) => actionType === 'send_as_input').length === 0 + ) + return; + const incontextInsight = Array.from(this.registry.values()).find( + (value) => value.interactionId && value.interactionId === interactionId + ); + if (!incontextInsight) return; + this.get(incontextInsight.key).suggestions = suggestedActions + .filter(({ actionType }) => actionType === 'send_as_input') + .map(({ message }) => message); + } + + public setInteractionId(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) + ); + 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/incontext_insight/index.ts b/public/services/incontext_insight/index.ts new file mode 100644 index 00000000..6e7e1db9 --- /dev/null +++ b/public/services/incontext_insight/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + IncontextInsightProvider, + IncontextInsightProviderType, + IncontextInsightContext, +} from './incontext_insight_provider'; +export * from './incontext_insight_registry'; diff --git a/public/services/index.ts b/public/services/index.ts new file mode 100644 index 00000000..72243960 --- /dev/null +++ b/public/services/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createGetterSetter } from '../../../../src/plugins/opensearch_dashboards_utils/public'; +import { ChromeStart, NotificationsStart } from '../../../../src/core/public'; +import { IncontextInsightRegistry } from './incontext_insight'; + +export * from './incontext_insight'; +export { ConversationLoadService } from './conversation_load_service'; +export { ConversationsService } from './conversations_service'; + +export const [getIncontextInsightRegistry, setIncontextInsightRegistry] = createGetterSetter< + IncontextInsightRegistry +>('IncontextInsightRegistry'); + +export const [getChrome, setChrome] = createGetterSetter('Chrome'); + +export const [getNotifications, setNotifications] = createGetterSetter( + 'Notifications' +); diff --git a/public/tabs/chat/chat_page_content.test.tsx b/public/tabs/chat/chat_page_content.test.tsx index 534f2ba9..4d933eb2 100644 --- a/public/tabs/chat/chat_page_content.test.tsx +++ b/public/tabs/chat/chat_page_content.test.tsx @@ -10,6 +10,9 @@ import * as chatContextExports from '../../contexts/chat_context'; import * as chatStateHookExports from '../../hooks/use_chat_state'; import * as chatActionHookExports from '../../hooks/use_chat_actions'; import { IMessage } from '../../../common/types/chat_saved_object_attributes'; +import { getIncontextInsightRegistry } from '../../services'; + +jest.mock('../../services'); jest.mock('./messages/message_bubble', () => { return { @@ -23,6 +26,13 @@ jest.mock('./messages/message_content', () => { return { MessageContent: () =>
}; }); +beforeEach(() => { + (getIncontextInsightRegistry as jest.Mock).mockImplementation(() => ({ + setSuggestionsByInteractionId: jest.fn(), + setInteractionId: jest.fn(), + })); +}); + describe('', () => { const abortActionMock = jest.fn(); const executeActionMock = jest.fn(); diff --git a/public/tabs/chat/chat_page_content.tsx b/public/tabs/chat/chat_page_content.tsx index f4789cb6..f4e11f7f 100644 --- a/public/tabs/chat/chat_page_content.tsx +++ b/public/tabs/chat/chat_page_content.tsx @@ -26,6 +26,7 @@ import { findLastIndex } from '../../utils'; import { MessageBubble } from './messages/message_bubble'; import { MessageContent } from './messages/message_content'; import { SuggestionBubble } from './suggestions/suggestion_bubble'; +import { getIncontextInsightRegistry } from '../../services'; interface ChatPageContentProps { messagesLoading: boolean; @@ -39,6 +40,7 @@ export const ChatPageContent: React.FC = React.memo((props const pageEndRef = useRef(null); const loading = props.messagesLoading || chatState.llmResponding; const chatActions = useChatActions(); + const registry = getIncontextInsightRegistry(); useLayoutEffect(() => { pageEndRef.current?.scrollIntoView(); @@ -119,6 +121,7 @@ export const ChatPageContent: React.FC = React.memo((props interaction = chatState.interactions.find( (item) => item.interaction_id === message.interactionId ); + registry.setInteractionId(interaction); } return ( @@ -200,6 +203,7 @@ interface SuggestionsProps { const Suggestions: React.FC = (props) => { const chatContext = useChatContext(); const { executeAction } = useChatActions(); + const registry = getIncontextInsightRegistry(); if (props.message.type !== 'output') { return null; @@ -220,6 +224,8 @@ const Suggestions: React.FC = (props) => { return null; } + registry.setSuggestionsByInteractionId(interactionId, suggestedActions); + return (
diff --git a/public/tabs/chat/messages/message_bubble.tsx b/public/tabs/chat/messages/message_bubble.tsx index a23b2404..4463afa9 100644 --- a/public/tabs/chat/messages/message_bubble.tsx +++ b/public/tabs/chat/messages/message_bubble.tsx @@ -18,6 +18,7 @@ import { import React, { useCallback } from 'react'; import { IconType } from '@elastic/eui/src/components/icon/icon'; import cx from 'classnames'; +// TODO: Replace with getChrome().logos.Chat.url import chatIcon from '../../../assets/chat.svg'; import { IMessage, diff --git a/public/tabs/chat_window_header.tsx b/public/tabs/chat_window_header.tsx index b29c0e3c..6c28888a 100644 --- a/public/tabs/chat_window_header.tsx +++ b/public/tabs/chat_window_header.tsx @@ -7,6 +7,7 @@ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui' import React from 'react'; import { useChatContext } from '../contexts/chat_context'; import { ChatWindowHeaderTitle } from '../components/chat_window_header_title'; +// TODO: Replace with getChrome().logos.Chat.url import chatIcon from '../assets/chat.svg'; import { TAB_ID } from '../utils/constants'; diff --git a/public/types.ts b/public/types.ts index dccdb609..f4bde66f 100644 --- a/public/types.ts +++ b/public/types.ts @@ -8,12 +8,12 @@ import { EmbeddableSetup, EmbeddableStart } from '../../../src/plugins/embeddabl import { IMessage, ISuggestedAction } from '../common/types/chat_saved_object_attributes'; import { IChatContext } from './contexts/chat_context'; import { MessageContentProps } from './tabs/chat/messages/message_content'; +import { IncontextInsightRegistry } from './services'; export interface RenderProps { props: MessageContentProps; chatContext: IChatContext; } - // TODO should pair with server side registered output parser export type MessageRenderer = (message: IMessage, renderProps: RenderProps) => React.ReactElement; export type ActionExecutor = (params: Record) => void; @@ -26,12 +26,12 @@ export interface AssistantActions { regenerate: (interactionId: string) => Promise; } -export interface AppPluginStartDependencies { +export interface AssistantPluginStartDependencies { embeddable: EmbeddableStart; dashboard: DashboardStart; } -export interface SetupDependencies { +export interface AssistantPluginSetupDependencies { embeddable: EmbeddableSetup; securityDashboards?: {}; } @@ -48,6 +48,8 @@ export interface AssistantSetup { */ userHasAccess: () => Promise; assistantActions: Omit; + registerIncontextInsight: IncontextInsightRegistry['register']; + renderIncontextInsight: (component: React.ReactNode) => React.ReactNode; } // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -62,4 +64,23 @@ export interface ChatConfig { terms_accepted: boolean; } +export type IncontextInsights = Map; + +export interface IncontextInsight { + key: string; + type?: IncontextInsightType; + summary?: string; + suggestions?: string[]; + interactionId?: string; +} + +export type IncontextInsightType = + | 'suggestions' + | 'generate' + | 'summary' + | 'summaryWithSuggestions' + | 'chat' + | 'chatWithSuggestions' + | 'error'; + export type TabId = 'chat' | 'compose' | 'insights' | 'history' | 'trace'; diff --git a/server/index.ts b/server/index.ts index 496fc11c..74696c1a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3,29 +3,18 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { schema, TypeOf } from '@osd/config-schema'; import { PluginConfigDescriptor, PluginInitializerContext } from '../../../src/core/server'; import { AssistantPlugin } from './plugin'; +import { configSchema, ConfigSchema } from '../common/types/config'; -export function plugin(initializerContext: PluginInitializerContext) { - return new AssistantPlugin(initializerContext); -} - -export { AssistantPluginSetup, AssistantPluginStart, MessageParser } from './types'; - -const assistantConfig = { - schema: schema.object({ - chat: schema.object({ - enabled: schema.boolean({ defaultValue: false }), - }), - }), -}; - -export type AssistantConfig = TypeOf; - -export const config: PluginConfigDescriptor = { - schema: assistantConfig.schema, +export const config: PluginConfigDescriptor = { exposeToBrowser: { chat: true, + incontextInsight: true, }, + schema: configSchema, }; + +export const plugin = (initContext: PluginInitializerContext) => new AssistantPlugin(initContext); + +export { AssistantPluginSetup, AssistantPluginStart, MessageParser } from './types'; diff --git a/server/plugin.ts b/server/plugin.ts index c8875b7e..26b86e17 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -4,7 +4,7 @@ */ import { first } from 'rxjs/operators'; -import { AssistantConfig } from '.'; +import { ConfigSchema } from '../common/types/config'; import { CoreSetup, CoreStart, @@ -28,7 +28,7 @@ export class AssistantPlugin implements Plugin { this.logger.debug('Assistant: Setup'); const config = await this.initializerContext.config - .create() + .create() .pipe(first()) .toPromise(); diff --git a/tsconfig.json b/tsconfig.json index 4df21bf9..b9baab8a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,7 @@ "include": [ "test/**/*", "index.ts", + "config.ts", "public/**/*.ts", "public/**/*.tsx", "server/**/*.ts",