diff --git a/common/constants/saved_objects.ts b/common/constants/saved_objects.ts new file mode 100644 index 00000000..25f846eb --- /dev/null +++ b/common/constants/saved_objects.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const CHAT_CONFIG_SAVED_OBJECT_TYPE = 'chat-config'; diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index 22af8dc2..0de232ce 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -1,6 +1,6 @@ { "id": "assistantDashboards", - "version": "2.9.0.0", + "version": "2.11.0.0", "opensearchDashboardsVersion": "opensearchDashboards", "server": true, "ui": true, diff --git a/package.json b/package.json index d7d5d828..4681ec31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "assistant-dashboards", - "version": "2.9.0.0", + "version": "2.11.0.0", "main": "index.ts", "license": "Apache-2.0", "scripts": { @@ -32,7 +32,7 @@ "@types/jsdom": "^21.1.2", "@types/react-test-renderer": "^16.9.1", "eslint": "^6.8.0", - "husky": "6.0.0", + "husky": "^8.0.0", "jest-dom": "^4.0.0", "lint-staged": "^13.1.0", "ts-jest": "^29.1.0" diff --git a/public/chat_header_button.tsx b/public/chat_header_button.tsx index 638f0a99..011a438a 100644 --- a/public/chat_header_button.tsx +++ b/public/chat_header_button.tsx @@ -15,7 +15,7 @@ import { SetContext } from './contexts/set_context'; import { ChatStateProvider } from './hooks/use_chat_state'; import './index.scss'; import { TabId } from './tabs/chat_tab_bar'; -import { ActionExecutor, AssistantActions, ContentRenderer } from './types'; +import { ActionExecutor, AssistantActions, ContentRenderer, UserAccount } from './types'; interface HeaderChatButtonProps { application: ApplicationStart; @@ -23,6 +23,7 @@ interface HeaderChatButtonProps { contentRenderers: Record; actionExecutors: Record; assistantActions: AssistantActions; + currentAccount: UserAccount; } let flyoutLoaded = false; @@ -61,6 +62,7 @@ export const HeaderChatButton: React.FC = (props) => { chatEnabled: props.chatEnabled, contentRenderers: props.contentRenderers, actionExecutors: props.actionExecutors, + currentAccount: props.currentAccount, }), [ appId, @@ -70,6 +72,7 @@ export const HeaderChatButton: React.FC = (props) => { props.chatEnabled, props.contentRenderers, props.actionExecutors, + props.currentAccount, ] ); diff --git a/public/components/terms_and_conditions.tsx b/public/components/terms_and_conditions.tsx new file mode 100644 index 00000000..297ff653 --- /dev/null +++ b/public/components/terms_and_conditions.tsx @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { useObservable } from 'react-use'; +import { EuiButton, EuiEmptyPrompt, EuiLink, EuiText } from '@elastic/eui'; +import { SavedObjectManager } from '../services/saved_object_manager'; +import { useCore } from '../contexts/core_context'; +import { ChatConfig } from '../types'; +import { CHAT_CONFIG_SAVED_OBJECT_TYPE } from '../../common/constants/saved_objects'; + +interface Props { + username: string; +} + +export const TermsAndConditions = (props: Props) => { + const core = useCore(); + + const chatConfigService = SavedObjectManager.getInstance( + core.services.savedObjects.client, + CHAT_CONFIG_SAVED_OBJECT_TYPE + ); + const config = useObservable(chatConfigService.get$(props.username)); + const loading = useObservable(chatConfigService.getLoadingStatus$(props.username)); + const termsAccepted = Boolean(config?.terms_accepted); + + return ( + +

Welcome {props.username} to the OpenSearch Assistant

+

I can help you analyze data, create visualizations, and get other insights.

+

How can I help?

+ + The OpenSearch Assistant may produce inaccurate information. Verify all information + before using it in any environment or workload. + + + } + actions={[ + !termsAccepted && ( + + chatConfigService.createOrUpdate(props.username, { terms_accepted: true }) + } + > + Accept terms & go + + ), + + + Terms & Conditions + + , + ]} + /> + ); +}; diff --git a/public/contexts/chat_context.tsx b/public/contexts/chat_context.tsx index 8ba7887a..240fe9c4 100644 --- a/public/contexts/chat_context.tsx +++ b/public/contexts/chat_context.tsx @@ -5,7 +5,7 @@ import React, { useContext } from 'react'; import { TabId } from '../tabs/chat_tab_bar'; -import { ActionExecutor, ContentRenderer } from '../types'; +import { ActionExecutor, ContentRenderer, UserAccount } from '../types'; export interface IChatContext { appId?: string; @@ -19,6 +19,7 @@ export interface IChatContext { chatEnabled: boolean; contentRenderers: Record; actionExecutors: Record; + currentAccount: UserAccount; } export const ChatContext = React.createContext(null); diff --git a/public/index.scss b/public/index.scss index c0fcee4b..dc3a8e66 100644 --- a/public/index.scss +++ b/public/index.scss @@ -49,9 +49,6 @@ .euiFlyoutFooter { background: transparent; } - .euiPage { - background: transparent; - } } .llm-chat-flyout-header { @@ -78,7 +75,7 @@ &.llm-chat-bubble-panel { word-break: break-word; border-radius: 8px; - max-width: 80%; + max-width: 95%; } &.llm-chat-greeting-card-panel { width: 357px; @@ -102,13 +99,10 @@ } .llm-chat-bubble-panel.llm-chat-bubble-panel-input { - background: #57c3ff; - border-color: white; + background: #159d8d; margin-left: auto; } .llm-chat-bubble-panel.llm-chat-bubble-panel-output { - background: #e6f0f8; - border-color: white; margin-right: auto; } @@ -126,7 +120,8 @@ } .llm-chat-fullscreen { - .euiFlyoutBody__overflowContent, .euiFlyoutFooter { + .euiFlyoutBody__overflowContent, + .euiFlyoutFooter { width: 70%; margin: auto; } diff --git a/public/plugin.tsx b/public/plugin.tsx index 13dd2ef2..3fda4bf5 100644 --- a/public/plugin.tsx +++ b/public/plugin.tsx @@ -33,15 +33,19 @@ export class AssistantPlugin const contentRenderers: Record = {}; const actionExecutors: Record = {}; const assistantActions: AssistantActions = {} as AssistantActions; + const getAccount = async () => { + return await core.http.get<{ data: { roles: string[]; user_name: string } }>( + '/api/v1/configuration/account' + ); + }; const assistantEnabled = (() => { let enabled: boolean; return async () => { if (enabled === undefined) { - enabled = await core.http - .get<{ data: { roles: string[] } }>('/api/v1/configuration/account') - .then((res) => - res.data.roles.some((role) => ['all_access', 'assistant_user'].includes(role)) - ); + const account = await getAccount(); + enabled = account.data.roles.some((role) => + ['all_access', 'assistant_user'].includes(role) + ); } return enabled; }; @@ -53,16 +57,22 @@ export class AssistantPlugin setupDeps, startDeps, }); + const account = await getAccount(); + const username = account.data.user_name; + coreStart.chrome.navControls.registerRight({ order: 10000, mount: toMountPoint( + ['all_access', 'assistant_user'].includes(role) + )} contentRenderers={contentRenderers} actionExecutors={actionExecutors} assistantActions={assistantActions} + currentAccount={{ username }} /> ), diff --git a/public/services/saved_object_manager.ts b/public/services/saved_object_manager.ts new file mode 100644 index 00000000..35ed00b2 --- /dev/null +++ b/public/services/saved_object_manager.ts @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsClientContract } from '../../../../src/core/public'; +import { SavedObjectService } from './saved_object_service'; + +export class SavedObjectManager { + private static instances: Map> = new Map(); + private constructor() {} + + public static getInstance( + savedObjectsClient: SavedObjectsClientContract, + savedObjectType: string + ) { + if (!SavedObjectManager.instances.has(savedObjectType)) { + SavedObjectManager.instances.set( + savedObjectType, + new SavedObjectService(savedObjectsClient, savedObjectType) + ); + } + return SavedObjectManager.instances.get(savedObjectType) as SavedObjectService; + } +} diff --git a/public/services/saved_object_service.ts b/public/services/saved_object_service.ts new file mode 100644 index 00000000..cfd3defb --- /dev/null +++ b/public/services/saved_object_service.ts @@ -0,0 +1,93 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject } from 'rxjs'; +import { SavedObjectsClientContract } from '../../../../src/core/public'; + +export class SavedObjectService { + private objects: Record | null>> = {}; + private loadingStatus: Record> = {}; + + constructor( + private readonly client: SavedObjectsClientContract, + private readonly savedObjectType: string + ) {} + + private setLoading(id: string, loading: boolean) { + if (!this.loadingStatus[id]) { + this.loadingStatus[id] = new BehaviorSubject(loading); + } else { + this.loadingStatus[id].next(loading); + } + } + + private async load(id: string) { + // set loading to true + this.setLoading(id, true); + + const savedObject = await this.client.get>(this.savedObjectType, id); + + // set loading to false + this.setLoading(id, false); + + if (!savedObject.error) { + this.objects[id].next(savedObject.attributes); + } + return savedObject; + } + + private async create(id: string, attributes: Partial) { + this.setLoading(id, true); + const newObject = await this.client.create>(this.savedObjectType, attributes, { + id, + }); + this.objects[id].next({ ...newObject.attributes }); + this.setLoading(id, false); + return newObject.attributes; + } + + private async update(id: string, attributes: Partial) { + this.setLoading(id, true); + const newObject = await this.client.update>(this.savedObjectType, id, attributes); + this.objects[id].next({ ...newObject.attributes }); + this.setLoading(id, false); + return newObject.attributes; + } + + private async initialize(id: string) { + if (!this.objects[id]) { + this.objects[id] = new BehaviorSubject | null>(null); + await this.load(id); + } + } + + public async get(id: string) { + await this.initialize(id); + return this.objects[id].getValue(); + } + + public get$(id: string) { + this.initialize(id); + return this.objects[id]; + } + + public getLoadingStatus$(id: string) { + return this.loadingStatus[id]; + } + + public async createOrUpdate(id: string, attributes: Partial) { + const currentObject = await this.load(id); + + if (currentObject.error) { + // Object not found, create a new object + if (currentObject.error.statusCode === 404) { + return await this.create(id, attributes); + } + } else { + // object found, update existing object + return await this.update(id, attributes); + } + } +} diff --git a/public/tabs/chat/chat_page.tsx b/public/tabs/chat/chat_page.tsx index c8a74f45..a4c192f8 100644 --- a/public/tabs/chat/chat_page.tsx +++ b/public/tabs/chat/chat_page.tsx @@ -5,21 +5,33 @@ import { EuiFlyoutBody, EuiFlyoutFooter, EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui'; import React, { useEffect, useState } from 'react'; +import { useObservable } from 'react-use'; import { useChatContext } from '../../contexts/chat_context'; import { useChatState } from '../../hooks/use_chat_state'; import { useGetSession } from '../../hooks/use_sessions'; import { ChatPageContent } from './chat_page_content'; import { ChatInputControls } from './controls/chat_input_controls'; +import { SavedObjectManager } from '../../services/saved_object_manager'; +import { useCore } from '../../contexts/core_context'; +import { CHAT_CONFIG_SAVED_OBJECT_TYPE } from '../../../common/constants/saved_objects'; +import { ChatConfig } from '../../types'; interface ChatPageProps { className?: string; } export const ChatPage: React.FC = (props) => { + const core = useCore(); + const chatConfigService = SavedObjectManager.getInstance( + core.services.savedObjects.client, + CHAT_CONFIG_SAVED_OBJECT_TYPE + ); const chatContext = useChatContext(); const { chatState, chatStateDispatch } = useChatState(); - const [showGreetings, setShowGreetings] = useState(true); + const [showGreetings, setShowGreetings] = useState(false); const { data: session, loading: messagesLoading, error: messagesLoadingError } = useGetSession(); + const chatConfig = useObservable(chatConfigService.get$(chatContext.currentAccount.username)); + const termsAccepted = Boolean(chatConfig?.terms_accepted); useEffect(() => { if (session) { @@ -30,7 +42,7 @@ export const ChatPage: React.FC = (props) => { return ( <> - + = (props) => { diff --git a/public/tabs/chat/chat_page_content.tsx b/public/tabs/chat/chat_page_content.tsx index ae595588..1d48822b 100644 --- a/public/tabs/chat/chat_page_content.tsx +++ b/public/tabs/chat/chat_page_content.tsx @@ -6,7 +6,7 @@ import { EuiEmptyPrompt, EuiIcon, EuiSpacer, EuiText } from '@elastic/eui'; import React, { useLayoutEffect, useRef } from 'react'; import { IMessage } from '../../../common/types/chat_saved_object_attributes'; -import { InviteMessage } from '../../components/invite_message'; +import { TermsAndConditions } from '../../components/terms_and_conditions'; import { LoadingButton } from '../../components/loading_button'; import { useChatContext } from '../../contexts/chat_context'; import { useChatState } from '../../hooks/use_chat_state'; @@ -39,17 +39,6 @@ export const ChatPageContent: React.FC = React.memo((props pageEndRef.current?.scrollIntoView(); }, [chatState.messages, loading]); - if (!chatContext.chatEnabled) { - return ( - <> - {props.showGreetings && props.setShowGreetings(false)} />} - - - - - ); - } - if (props.messagesLoadingError) { return ( = React.memo((props return ( <> + + + + , {props.showGreetings && props.setShowGreetings(false)} />} {chatState.messages .flatMap((message, i, array) => [ diff --git a/public/tabs/chat/controls/chat_input_controls.tsx b/public/tabs/chat/controls/chat_input_controls.tsx index 10515d01..7e163963 100644 --- a/public/tabs/chat/controls/chat_input_controls.tsx +++ b/public/tabs/chat/controls/chat_input_controls.tsx @@ -19,6 +19,7 @@ export const ChatInputControls: React.FC = (props) => { const chatContext = useChatContext(); const { send } = useChatActions(); const inputRef = useRef(null); + useEffectOnce(() => { if (inputRef.current) { autosize(inputRef.current); diff --git a/public/types.ts b/public/types.ts index c12118ea..a140952a 100644 --- a/public/types.ts +++ b/public/types.ts @@ -35,3 +35,11 @@ export interface AssistantSetup { // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface AssistantStart {} + +export interface UserAccount { + username: string; +} + +export interface ChatConfig { + terms_accepted: boolean; +} diff --git a/server/plugin.ts b/server/plugin.ts index 316fd765..3b3fc35f 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -19,6 +19,7 @@ import './fetch-polyfill'; import { setupRoutes } from './routes/index'; import { chatSavedObject } from './saved_objects/chat_saved_object'; import { AssistantPluginSetup, AssistantPluginStart } from './types'; +import { chatConfigSavedObject } from './saved_objects/chat_config_saved_object'; export class AssistantPlugin implements Plugin { private readonly logger: Logger; @@ -48,6 +49,8 @@ export class AssistantPlugin implements Plugin ({ observability: { show: true, diff --git a/server/saved_objects/chat_config_saved_object.ts b/server/saved_objects/chat_config_saved_object.ts new file mode 100644 index 00000000..83eea4a2 --- /dev/null +++ b/server/saved_objects/chat_config_saved_object.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsType } from '../../../../src/core/server'; +import { CHAT_CONFIG_SAVED_OBJECT_TYPE } from '../../common/constants/saved_objects'; + +export const chatConfigSavedObject: SavedObjectsType = { + name: CHAT_CONFIG_SAVED_OBJECT_TYPE, + hidden: false, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: { + terms_accepted: { + type: 'boolean', + }, + }, + }, +}; diff --git a/yarn.lock b/yarn.lock index 49f7c583..3f22bbf1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -966,10 +966,10 @@ humanize-ms@^1.2.1: dependencies: ms "^2.0.0" -husky@6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/husky/-/husky-6.0.0.tgz#810f11869adf51604c32ea577edbc377d7f9319e" - integrity sha512-SQS2gDTB7tBN486QSoKPKQItZw97BMOd+Kdb6ghfpBc0yXyzrddI0oDV5MkDAbuB4X2mO3/nj60TRMcYxwzZeQ== +husky@^8.0.0: + version "8.0.3" + resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.3.tgz#4936d7212e46d1dea28fef29bb3a108872cd9184" + integrity sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg== iconv-lite@0.6.3: version "0.6.3"