mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Obs AI Assistant] Share conversations (#211854)
Closes https://github.com/elastic/kibana/issues/206590 Closes https://github.com/elastic/kibana/issues/211710 Closes https://github.com/elastic/kibana/issues/211604 Closes https://github.com/elastic/obs-ai-assistant-team/issues/215 ## Summary This PR implements conversation sharing for Obs AI Assistant conversations. The features included are as follows: 1. Refactored `ChatActionsMenu` - Removed Copy Conversation and Duplicate options 2. Removed the banner added in https://github.com/elastic/kibana/issues/209382 3. Removed the conversation input box (`PromptEditor`), if the user who is viewing the conversation cannot continue it. 4. Implemented a `ChatBanner` - This will show whether a conversation is shared with the team (The banner content differs based on who is viewing the conversation) 5. Implemented `ChatContextMenu` for conversation specific actions. This includes "Duplicate", "Copy conversation", "Copy URL" and "Delete". "Delete" functionality is only available to the conversation owner. (This menu is only included in the `ChatHeader` at the moment because `Eui` doesn't support passing a node to `EuiListGroupItem` to include this in the `ConversationList`. This will be refactored in https://github.com/elastic/kibana/issues/209386) 6. Implemented `useConversationContextMenu` for "copy" and "delete" functionalities. 7. Implemented `ChatSharingMenu` to mark a conversation as `shared/private`. This is only enabled for the owner of the conversation. For other users, a disabled badge will be shown stating whether the conversation is Private or Shared. 8. Implemented `updateConversationAccess` route. 9. Updated the Chat Item Actions Inspect Prompt Button to `Inspect`. This was `eye` before. 10. Implemented a custom component `ConversationListItemLabel` to show the shared icon in `ConversationList`. 11. Re-named "Copy conversation" to "Copy to clipboard" to avoid ambiguity with "Duplicate". 12. Added success toast on "Copy to clipboard" Note: If a conversation started from contextual insights, and then the user continue the conversation --> The conversation will be stored. However, if the user deletes the continued conversation, they will be reverted to the initial messages from the contextual insights. ### Screen recording https://github.com/user-attachments/assets/50b1fd3c-c2f5-406f-91bc-2b51bb58833e ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
2fd0bea441
commit
9c0e4b0bfb
39 changed files with 1948 additions and 468 deletions
|
@ -22,16 +22,10 @@ import { useKnowledgeBase } from '../hooks';
|
|||
|
||||
export function ChatActionsMenu({
|
||||
connectors,
|
||||
conversationId,
|
||||
disabled,
|
||||
onCopyConversationClick,
|
||||
onDuplicateConversationClick,
|
||||
}: {
|
||||
connectors: UseGenAIConnectorsResult;
|
||||
conversationId?: string;
|
||||
disabled: boolean;
|
||||
onCopyConversationClick: () => void;
|
||||
onDuplicateConversationClick: () => void;
|
||||
}) {
|
||||
const { application, http } = useKibana().services;
|
||||
const knowledgeBase = useKnowledgeBase();
|
||||
|
@ -74,7 +68,7 @@ export function ChatActionsMenu({
|
|||
<EuiButtonIcon
|
||||
data-test-subj="observabilityAiAssistantChatActionsMenuButtonIcon"
|
||||
disabled={disabled}
|
||||
iconType="boxesVertical"
|
||||
iconType="controlsHorizontal"
|
||||
onClick={toggleActionsMenu}
|
||||
aria-label={i18n.translate(
|
||||
'xpack.aiAssistant.chatActionsMenu.euiButtonIcon.menuLabel',
|
||||
|
@ -133,26 +127,6 @@ export function ChatActionsMenu({
|
|||
),
|
||||
panel: 1,
|
||||
},
|
||||
{
|
||||
name: i18n.translate('xpack.aiAssistant.chatHeader.actions.copyConversation', {
|
||||
defaultMessage: 'Copy conversation',
|
||||
}),
|
||||
disabled: !conversationId,
|
||||
onClick: () => {
|
||||
toggleActionsMenu();
|
||||
onCopyConversationClick();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: i18n.translate('xpack.aiAssistant.chatHeader.actions.duplicateConversation', {
|
||||
defaultMessage: 'Duplicate',
|
||||
}),
|
||||
disabled: !conversationId,
|
||||
onClick: () => {
|
||||
toggleActionsMenu();
|
||||
onDuplicateConversationClick();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { EuiText, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiPanel, useEuiTheme } from '@elastic/eui';
|
||||
|
||||
export function ChatBanner({
|
||||
title,
|
||||
description,
|
||||
button = null,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
button?: ReactNode;
|
||||
}) {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
return (
|
||||
<EuiPanel
|
||||
paddingSize="m"
|
||||
hasShadow={false}
|
||||
color="subdued"
|
||||
borderRadius="m"
|
||||
grow={false}
|
||||
className={css`
|
||||
margin: ${euiTheme.size.m};
|
||||
`}
|
||||
>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon size="l" type="users" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow>
|
||||
<EuiText size="xs">
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
{button}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
|
@ -12,17 +12,19 @@ import {
|
|||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiIcon,
|
||||
EuiPanel,
|
||||
euiScrollBarStyles,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
useEuiTheme,
|
||||
UseEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
import { css, keyframes } from '@emotion/css';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { Conversation, Message } from '@kbn/observability-ai-assistant-plugin/common';
|
||||
import type {
|
||||
Conversation,
|
||||
ConversationAccess,
|
||||
Message,
|
||||
} from '@kbn/observability-ai-assistant-plugin/common';
|
||||
import {
|
||||
ChatActionClickType,
|
||||
ChatState,
|
||||
|
@ -50,7 +52,7 @@ import { WelcomeMessage } from './welcome_message';
|
|||
import { useLicense } from '../hooks/use_license';
|
||||
import { PromptEditor } from '../prompt_editor/prompt_editor';
|
||||
import { useKibana } from '../hooks/use_kibana';
|
||||
import { deserializeMessage } from '../utils/deserialize_message';
|
||||
import { ChatBanner } from './chat_banner';
|
||||
|
||||
const fullHeightClassName = css`
|
||||
height: 100%;
|
||||
|
@ -75,6 +77,7 @@ const incorrectLicenseContainer = (euiTheme: UseEuiTheme['euiTheme']) => css`
|
|||
|
||||
const chatBodyContainerClassNameWithError = css`
|
||||
align-self: center;
|
||||
margin: 12px;
|
||||
`;
|
||||
|
||||
const promptEditorContainerClassName = css`
|
||||
|
@ -121,6 +124,9 @@ export function ChatBody({
|
|||
onConversationUpdate,
|
||||
onToggleFlyoutPositionMode,
|
||||
navigateToConversation,
|
||||
setIsUpdatingConversationList,
|
||||
refreshConversations,
|
||||
updateDisplayedConversation,
|
||||
onConversationDuplicate,
|
||||
}: {
|
||||
connectors: ReturnType<typeof useGenAIConnectors>;
|
||||
|
@ -135,6 +141,9 @@ export function ChatBody({
|
|||
onConversationDuplicate: (conversation: Conversation) => void;
|
||||
onToggleFlyoutPositionMode?: (flyoutPositionMode: FlyoutPositionMode) => void;
|
||||
navigateToConversation?: (conversationId?: string) => void;
|
||||
setIsUpdatingConversationList: (isUpdating: boolean) => void;
|
||||
refreshConversations: () => void;
|
||||
updateDisplayedConversation: (id?: string) => void;
|
||||
}) {
|
||||
const license = useLicense();
|
||||
const hasCorrectLicense = license?.hasAtLeast('enterprise');
|
||||
|
@ -164,6 +173,7 @@ export function ChatBody({
|
|||
duplicateConversation,
|
||||
isConversationOwnedByCurrentUser,
|
||||
user: conversationUser,
|
||||
updateConversationAccess,
|
||||
} = useConversation({
|
||||
currentUser,
|
||||
initialConversationId,
|
||||
|
@ -177,8 +187,6 @@ export function ChatBody({
|
|||
|
||||
const timelineContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
let footer: React.ReactNode;
|
||||
|
||||
const isLoading = Boolean(
|
||||
connectors.loading ||
|
||||
knowledgeBase.status.loading ||
|
||||
|
@ -187,7 +195,6 @@ export function ChatBody({
|
|||
);
|
||||
|
||||
let title = conversation.value?.conversation.title || initialTitle;
|
||||
|
||||
if (!title) {
|
||||
if (!connectors.selectedConnector) {
|
||||
title = ASSISTANT_SETUP_TITLE;
|
||||
|
@ -269,18 +276,6 @@ export function ChatBody({
|
|||
}
|
||||
});
|
||||
|
||||
const handleCopyConversation = () => {
|
||||
const deserializedMessages = (conversation.value?.messages ?? messages).map(deserializeMessage);
|
||||
|
||||
const content = JSON.stringify({
|
||||
title: conversation.value?.conversation.title || initialTitle,
|
||||
systemMessage: conversation.value?.systemMessage,
|
||||
messages: deserializedMessages,
|
||||
});
|
||||
|
||||
navigator.clipboard?.writeText(content || '');
|
||||
};
|
||||
|
||||
const handleActionClick = ({
|
||||
message,
|
||||
payload,
|
||||
|
@ -351,6 +346,50 @@ export function ChatBody({
|
|||
}
|
||||
};
|
||||
|
||||
const handleConversationAccessUpdate = async (access: ConversationAccess) => {
|
||||
await updateConversationAccess(access);
|
||||
conversation.refresh();
|
||||
refreshConversations();
|
||||
};
|
||||
|
||||
const isPublic = conversation.value?.public;
|
||||
const showPromptEditor = !isPublic || isConversationOwnedByCurrentUser;
|
||||
const bannerTitle = i18n.translate('xpack.aiAssistant.shareBanner.title', {
|
||||
defaultMessage: 'This conversation is shared with your team.',
|
||||
});
|
||||
|
||||
let sharedBanner = null;
|
||||
|
||||
if (isPublic && !isConversationOwnedByCurrentUser) {
|
||||
sharedBanner = (
|
||||
<ChatBanner
|
||||
title={bannerTitle}
|
||||
description={i18n.translate('xpack.aiAssistant.shareBanner.viewerDescription', {
|
||||
defaultMessage:
|
||||
"You can't edit or continue this conversation, but you can duplicate it into a new private conversation. The original conversation will remain unchanged.",
|
||||
})}
|
||||
button={
|
||||
<EuiButton onClick={duplicateConversation} iconType="copy" size="s">
|
||||
{i18n.translate('xpack.aiAssistant.duplicateButton', {
|
||||
defaultMessage: 'Duplicate',
|
||||
})}
|
||||
</EuiButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else if (isConversationOwnedByCurrentUser && isPublic) {
|
||||
sharedBanner = (
|
||||
<ChatBanner
|
||||
title={bannerTitle}
|
||||
description={i18n.translate('xpack.aiAssistant.shareBanner.ownerDescription', {
|
||||
defaultMessage:
|
||||
'Any further edits you do to this conversation will be shared with the rest of the team.',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let footer: React.ReactNode;
|
||||
if (!hasCorrectLicense && !initialConversationId) {
|
||||
footer = (
|
||||
<>
|
||||
|
@ -409,65 +448,30 @@ export function ChatBody({
|
|||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ChatTimeline
|
||||
conversationId={conversationId}
|
||||
messages={messages}
|
||||
knowledgeBase={knowledgeBase}
|
||||
chatService={chatService}
|
||||
currentUser={conversationUser}
|
||||
isConversationOwnedByCurrentUser={isConversationOwnedByCurrentUser}
|
||||
chatState={state}
|
||||
hasConnector={!!connectors.connectors?.length}
|
||||
onEdit={(editedMessage, newMessage) => {
|
||||
setStickToBottom(true);
|
||||
const indexOf = messages.indexOf(editedMessage);
|
||||
next(messages.slice(0, indexOf).concat(newMessage));
|
||||
}}
|
||||
onFeedback={handleFeedback}
|
||||
onRegenerate={(message) => {
|
||||
next(reverseToLastUserMessage(messages, message));
|
||||
}}
|
||||
onSendTelemetry={(eventWithPayload) =>
|
||||
chatService.sendAnalyticsEvent(eventWithPayload)
|
||||
}
|
||||
onStopGenerating={stop}
|
||||
onActionClick={handleActionClick}
|
||||
/>
|
||||
{conversationId && !isConversationOwnedByCurrentUser ? (
|
||||
<>
|
||||
<EuiPanel paddingSize="m" hasShadow={false} color="subdued">
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon size="l" type="users" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow>
|
||||
<EuiText size="xs">
|
||||
<h3>
|
||||
{i18n.translate('xpack.aiAssistant.sharedBanner.title', {
|
||||
defaultMessage: 'This conversation is shared with your team.',
|
||||
})}
|
||||
</h3>
|
||||
<p>
|
||||
{i18n.translate('xpack.aiAssistant.sharedBanner.description', {
|
||||
defaultMessage: `You can’t edit or continue this conversation, but you can duplicate
|
||||
it into a new private conversation. The original conversation will
|
||||
remain unchanged.`,
|
||||
})}
|
||||
</p>
|
||||
<EuiButton onClick={duplicateConversation} iconType="copy" size="s">
|
||||
{i18n.translate('xpack.aiAssistant.duplicateButton', {
|
||||
defaultMessage: 'Duplicate',
|
||||
})}
|
||||
</EuiButton>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
<ChatTimeline
|
||||
conversationId={conversationId}
|
||||
messages={messages}
|
||||
knowledgeBase={knowledgeBase}
|
||||
chatService={chatService}
|
||||
currentUser={conversationUser}
|
||||
isConversationOwnedByCurrentUser={isConversationOwnedByCurrentUser}
|
||||
chatState={state}
|
||||
hasConnector={!!connectors.connectors?.length}
|
||||
onEdit={(editedMessage, newMessage) => {
|
||||
setStickToBottom(true);
|
||||
const indexOf = messages.indexOf(editedMessage);
|
||||
next(messages.slice(0, indexOf).concat(newMessage));
|
||||
}}
|
||||
onFeedback={handleFeedback}
|
||||
onRegenerate={(message) => {
|
||||
next(reverseToLastUserMessage(messages, message));
|
||||
}}
|
||||
onSendTelemetry={(eventWithPayload) =>
|
||||
chatService.sendAnalyticsEvent(eventWithPayload)
|
||||
}
|
||||
onStopGenerating={stop}
|
||||
onActionClick={handleActionClick}
|
||||
/>
|
||||
)}
|
||||
</EuiPanel>
|
||||
</div>
|
||||
|
@ -479,39 +483,40 @@ export function ChatBody({
|
|||
</EuiFlexItem>
|
||||
) : null}
|
||||
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
className={promptEditorClassname(euiTheme)}
|
||||
style={{ height: promptEditorHeight }}
|
||||
>
|
||||
<EuiHorizontalRule margin="none" />
|
||||
<EuiPanel
|
||||
hasBorder={false}
|
||||
hasShadow={false}
|
||||
paddingSize="m"
|
||||
color="subdued"
|
||||
className={promptEditorContainerClassName}
|
||||
>
|
||||
<PromptEditor
|
||||
disabled={
|
||||
!connectors.selectedConnector ||
|
||||
!hasCorrectLicense ||
|
||||
(!!conversationId && !isConversationOwnedByCurrentUser)
|
||||
}
|
||||
hidden={connectors.loading || connectors.connectors?.length === 0}
|
||||
loading={isLoading}
|
||||
onChangeHeight={handleChangeHeight}
|
||||
onSendTelemetry={(eventWithPayload) =>
|
||||
chatService.sendAnalyticsEvent(eventWithPayload)
|
||||
}
|
||||
onSubmit={(message) => {
|
||||
setStickToBottom(true);
|
||||
return next(messages.concat(message));
|
||||
}}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
<>
|
||||
{conversationId ? sharedBanner : null}
|
||||
{showPromptEditor ? (
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
className={promptEditorClassname(euiTheme)}
|
||||
style={{ height: promptEditorHeight }}
|
||||
>
|
||||
<EuiHorizontalRule margin="none" />
|
||||
<EuiPanel
|
||||
hasBorder={false}
|
||||
hasShadow={false}
|
||||
paddingSize="m"
|
||||
color="subdued"
|
||||
className={promptEditorContainerClassName}
|
||||
>
|
||||
<PromptEditor
|
||||
disabled={!connectors.selectedConnector || !hasCorrectLicense}
|
||||
hidden={connectors.loading || connectors.connectors?.length === 0}
|
||||
loading={isLoading}
|
||||
onChangeHeight={handleChangeHeight}
|
||||
onSendTelemetry={(eventWithPayload) =>
|
||||
chatService.sendAnalyticsEvent(eventWithPayload)
|
||||
}
|
||||
onSubmit={(message) => {
|
||||
setStickToBottom(true);
|
||||
return next(messages.concat(message));
|
||||
}}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -575,11 +580,11 @@ export function ChatBody({
|
|||
<ChatHeader
|
||||
connectors={connectors}
|
||||
conversationId={conversationId}
|
||||
conversation={conversation.value as Conversation}
|
||||
flyoutPositionMode={flyoutPositionMode}
|
||||
licenseInvalid={!hasCorrectLicense && !initialConversationId}
|
||||
loading={isLoading}
|
||||
title={title}
|
||||
onCopyConversation={handleCopyConversation}
|
||||
onDuplicateConversation={duplicateConversation}
|
||||
onSaveTitle={(newTitle) => {
|
||||
saveTitle(newTitle);
|
||||
|
@ -588,6 +593,10 @@ export function ChatBody({
|
|||
navigateToConversation={
|
||||
initialMessages?.length && !initialConversationId ? undefined : navigateToConversation
|
||||
}
|
||||
setIsUpdatingConversationList={setIsUpdatingConversationList}
|
||||
refreshConversations={refreshConversations}
|
||||
updateDisplayedConversation={updateDisplayedConversation}
|
||||
handleConversationAccessUpdate={handleConversationAccessUpdate}
|
||||
isConversationOwnedByCurrentUser={isConversationOwnedByCurrentUser}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { ChatContextMenu } from './chat_context_menu';
|
||||
import { useConfirmModal } from '../hooks';
|
||||
|
||||
jest.mock('../hooks/use_confirm_modal', () => ({
|
||||
useConfirmModal: jest.fn().mockReturnValue({
|
||||
element: <div data-test-subj="confirmModal" />,
|
||||
confirm: jest.fn(() => Promise.resolve(true)),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ChatContextMenu', () => {
|
||||
const onCopyToClipboardClick = jest.fn();
|
||||
const onCopyUrlClick = jest.fn();
|
||||
const onDeleteClick = jest.fn();
|
||||
const onDuplicateConversationClick = jest.fn();
|
||||
|
||||
const renderComponent = (props = {}) =>
|
||||
render(
|
||||
<ChatContextMenu
|
||||
isConversationOwnedByCurrentUser={true}
|
||||
conversationTitle="Test Conversation"
|
||||
onCopyToClipboardClick={onCopyToClipboardClick}
|
||||
onCopyUrlClick={onCopyUrlClick}
|
||||
onDeleteClick={onDeleteClick}
|
||||
onDuplicateConversationClick={onDuplicateConversationClick}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders without errors', () => {
|
||||
renderComponent();
|
||||
expect(
|
||||
screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the popover on button click', () => {
|
||||
renderComponent();
|
||||
const button = screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon');
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByText('Copy to clipboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Duplicate')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onCopyToClipboardClick when Copy to clipboard is clicked', () => {
|
||||
renderComponent();
|
||||
fireEvent.click(screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon'));
|
||||
fireEvent.click(screen.getByText('Copy to clipboard'));
|
||||
expect(onCopyToClipboardClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onCopyUrlClick when Copy URL is clicked', () => {
|
||||
renderComponent();
|
||||
fireEvent.click(screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon'));
|
||||
fireEvent.click(screen.getByText('Copy URL'));
|
||||
expect(onCopyUrlClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onDuplicateConversationClick when Duplicate is clicked', () => {
|
||||
renderComponent();
|
||||
fireEvent.click(screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon'));
|
||||
fireEvent.click(screen.getByText('Duplicate'));
|
||||
expect(onDuplicateConversationClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onDeleteClick when delete is confirmed', async () => {
|
||||
renderComponent();
|
||||
fireEvent.click(screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon'));
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
|
||||
await waitFor(() => expect(onDeleteClick).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('does not call onDeleteClick when delete is canceled', async () => {
|
||||
(useConfirmModal as jest.Mock).mockReturnValue({
|
||||
element: <div data-test-subj="confirmModal" />,
|
||||
confirm: jest.fn(() => Promise.resolve(false)),
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
fireEvent.click(screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon'));
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
|
||||
await waitFor(() => expect(onDeleteClick).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('does not render delete option if isConversationOwnedByCurrentUser is false', () => {
|
||||
renderComponent({ isConversationOwnedByCurrentUser: false });
|
||||
fireEvent.click(screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon'));
|
||||
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables button when disabled prop is true', () => {
|
||||
renderComponent({ disabled: true });
|
||||
expect(screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon')).toBeDisabled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,159 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
EuiButtonIcon,
|
||||
EuiPopover,
|
||||
EuiContextMenuItem,
|
||||
EuiContextMenuPanel,
|
||||
EuiToolTip,
|
||||
EuiHorizontalRule,
|
||||
EuiIcon,
|
||||
useEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useConfirmModal } from '../hooks';
|
||||
|
||||
export function ChatContextMenu({
|
||||
disabled = false,
|
||||
isConversationOwnedByCurrentUser,
|
||||
conversationTitle,
|
||||
onCopyToClipboardClick,
|
||||
onCopyUrlClick,
|
||||
onDeleteClick,
|
||||
onDuplicateConversationClick,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
isConversationOwnedByCurrentUser: boolean;
|
||||
conversationTitle: string;
|
||||
onCopyToClipboardClick: () => void;
|
||||
onCopyUrlClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onDuplicateConversationClick: () => void;
|
||||
}) {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
|
||||
const { element: confirmDeleteElement, confirm: confirmDeleteCallback } = useConfirmModal({
|
||||
title: i18n.translate('xpack.aiAssistant.flyout.confirmDeleteConversationTitle', {
|
||||
defaultMessage: 'Delete conversation',
|
||||
}),
|
||||
children: i18n.translate('xpack.aiAssistant.flyout.confirmDeleteConversationContent', {
|
||||
defaultMessage: 'This action is permanent and cannot be undone.',
|
||||
}),
|
||||
confirmButtonText: i18n.translate('xpack.aiAssistant.flyout.confirmDeleteButtonText', {
|
||||
defaultMessage: 'Delete',
|
||||
}),
|
||||
});
|
||||
|
||||
const menuItems = [
|
||||
<EuiContextMenuItem
|
||||
key="duplicate"
|
||||
icon="copy"
|
||||
onClick={() => {
|
||||
onDuplicateConversationClick();
|
||||
setIsPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
{i18n.translate('xpack.aiAssistant.chatHeader.contextMenu.duplicateConversation', {
|
||||
defaultMessage: 'Duplicate',
|
||||
})}
|
||||
</EuiContextMenuItem>,
|
||||
<EuiContextMenuItem
|
||||
key="copyConversationToClipboard"
|
||||
icon="copyClipboard"
|
||||
onClick={() => {
|
||||
onCopyToClipboardClick();
|
||||
setIsPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
{i18n.translate('xpack.aiAssistant.chatHeader.contextMenu.copyToClipboard', {
|
||||
defaultMessage: 'Copy to clipboard',
|
||||
})}
|
||||
</EuiContextMenuItem>,
|
||||
<EuiContextMenuItem
|
||||
key="copyURL"
|
||||
icon="link"
|
||||
onClick={() => {
|
||||
onCopyUrlClick();
|
||||
setIsPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
{i18n.translate('xpack.aiAssistant.chatHeader.contextMenu.copyUrl', {
|
||||
defaultMessage: 'Copy URL',
|
||||
})}
|
||||
</EuiContextMenuItem>,
|
||||
];
|
||||
|
||||
if (isConversationOwnedByCurrentUser) {
|
||||
menuItems.push(<EuiHorizontalRule key="seperator" margin="none" />);
|
||||
menuItems.push(
|
||||
<EuiContextMenuItem
|
||||
key="delete"
|
||||
css={css`
|
||||
color: ${euiTheme.colors.danger};
|
||||
padding: ${euiTheme.size.s};
|
||||
`}
|
||||
icon={<EuiIcon type="trash" size="m" color="danger" />}
|
||||
onClick={() => {
|
||||
confirmDeleteCallback(
|
||||
i18n.translate('xpack.aiAssistant.flyout.confirmDeleteCheckboxLabel', {
|
||||
defaultMessage: 'Delete "{title}"',
|
||||
values: { title: conversationTitle },
|
||||
})
|
||||
).then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
onDeleteClick();
|
||||
});
|
||||
|
||||
setIsPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
{i18n.translate('xpack.aiAssistant.conversationList.deleteConversationIconLabel', {
|
||||
defaultMessage: 'Delete',
|
||||
})}
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiPopover
|
||||
button={
|
||||
<EuiToolTip
|
||||
content={i18n.translate('xpack.aiAssistant.chatHeader.contextMenu.chatActionsTooltip', {
|
||||
defaultMessage: 'Conversation actions',
|
||||
})}
|
||||
display="block"
|
||||
>
|
||||
<EuiButtonIcon
|
||||
data-test-subj="observabilityAiAssistantChatContextMenuButtonIcon"
|
||||
iconType="boxesVertical"
|
||||
color="text"
|
||||
disabled={disabled}
|
||||
aria-label={i18n.translate('xpack.aiAssistant.chatHeader.contextMenu.iconAreaLabel', {
|
||||
defaultMessage: 'Conversation context menu',
|
||||
})}
|
||||
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={() => setIsPopoverOpen(false)}
|
||||
anchorPosition="downCenter"
|
||||
panelPaddingSize="xs"
|
||||
>
|
||||
<EuiContextMenuPanel size="s" items={menuItems} />
|
||||
</EuiPopover>
|
||||
{confirmDeleteElement}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -19,6 +19,7 @@ import { i18n } from '@kbn/i18n';
|
|||
import { Conversation, Message } from '@kbn/observability-ai-assistant-plugin/common';
|
||||
import React, { useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
|
||||
import { useConversationKey } from '../hooks/use_conversation_key';
|
||||
import { useConversationList } from '../hooks/use_conversation_list';
|
||||
import { useCurrentUser } from '../hooks/use_current_user';
|
||||
|
@ -85,12 +86,18 @@ export function ChatFlyout({
|
|||
observabilityAIAssistant: { ObservabilityAIAssistantMultipaneFlyoutContext },
|
||||
},
|
||||
} = useKibana();
|
||||
const conversationList = useConversationList();
|
||||
|
||||
const {
|
||||
conversations,
|
||||
isLoadingConversationList,
|
||||
setIsUpdatingConversationList,
|
||||
refreshConversations,
|
||||
} = useConversationList();
|
||||
|
||||
const { key: bodyKey, updateConversationIdInPlace } = useConversationKey(conversationId);
|
||||
|
||||
const onConversationDuplicate = (conversation: Conversation) => {
|
||||
conversationList.conversations.refresh();
|
||||
refreshConversations();
|
||||
setInitialMessages([]);
|
||||
setConversationId(conversation.conversation.id);
|
||||
};
|
||||
|
@ -149,6 +156,10 @@ export function ChatFlyout({
|
|||
onFlyoutPositionModeChange?.(newFlyoutPositionMode);
|
||||
};
|
||||
|
||||
const updateDisplayedConversation = (id?: string) => {
|
||||
setConversationId(id || undefined);
|
||||
};
|
||||
|
||||
return isOpen ? (
|
||||
<ObservabilityAIAssistantMultipaneFlyoutContext.Provider
|
||||
value={{
|
||||
|
@ -223,19 +234,16 @@ export function ChatFlyout({
|
|||
|
||||
{conversationsExpanded ? (
|
||||
<ConversationList
|
||||
conversations={conversationList.conversations}
|
||||
isLoading={conversationList.isLoading}
|
||||
conversations={conversations}
|
||||
isLoading={isLoadingConversationList}
|
||||
selectedConversationId={conversationId}
|
||||
onConversationDeleteClick={(deletedConversationId) => {
|
||||
conversationList.deleteConversation(deletedConversationId).then(() => {
|
||||
if (deletedConversationId === conversationId) {
|
||||
setConversationId(undefined);
|
||||
}
|
||||
});
|
||||
}}
|
||||
currentUser={currentUser as AuthenticatedUser}
|
||||
onConversationSelect={(nextConversationId) => {
|
||||
setConversationId(nextConversationId);
|
||||
}}
|
||||
setIsUpdatingConversationList={setIsUpdatingConversationList}
|
||||
refreshConversations={refreshConversations}
|
||||
updateDisplayedConversation={updateDisplayedConversation}
|
||||
/>
|
||||
) : (
|
||||
<EuiPopover
|
||||
|
@ -283,7 +291,7 @@ export function ChatFlyout({
|
|||
updateConversationIdInPlace(conversation.conversation.id);
|
||||
}
|
||||
setConversationId(conversation.conversation.id);
|
||||
conversationList.conversations.refresh();
|
||||
refreshConversations();
|
||||
}}
|
||||
onToggleFlyoutPositionMode={handleToggleFlyoutPositionMode}
|
||||
navigateToConversation={
|
||||
|
@ -294,6 +302,9 @@ export function ChatFlyout({
|
|||
}
|
||||
: undefined
|
||||
}
|
||||
setIsUpdatingConversationList={setIsUpdatingConversationList}
|
||||
refreshConversations={refreshConversations}
|
||||
updateDisplayedConversation={updateDisplayedConversation}
|
||||
onConversationDuplicate={onConversationDuplicate}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
EuiButtonIcon,
|
||||
|
@ -20,15 +21,24 @@ import {
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { css } from '@emotion/css';
|
||||
import { AssistantIcon } from '@kbn/ai-assistant-icon';
|
||||
import { Conversation, ConversationAccess } from '@kbn/observability-ai-assistant-plugin/common';
|
||||
import { ChatActionsMenu } from './chat_actions_menu';
|
||||
import type { UseGenAIConnectorsResult } from '../hooks/use_genai_connectors';
|
||||
import { FlyoutPositionMode } from './chat_flyout';
|
||||
import { ChatSharingMenu } from './chat_sharing_menu';
|
||||
import { ChatContextMenu } from './chat_context_menu';
|
||||
import { useConversationContextMenu } from '../hooks/use_conversation_context_menu';
|
||||
|
||||
// needed to prevent InlineTextEdit component from expanding container
|
||||
const minWidthClassName = css`
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const minMaxWidthClassName = css`
|
||||
min-width: 0;
|
||||
max-width: 80%;
|
||||
`;
|
||||
|
||||
const chatHeaderClassName = css`
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
|
@ -42,29 +52,37 @@ const chatHeaderMobileClassName = css`
|
|||
export function ChatHeader({
|
||||
connectors,
|
||||
conversationId,
|
||||
conversation,
|
||||
flyoutPositionMode,
|
||||
licenseInvalid,
|
||||
loading,
|
||||
title,
|
||||
isConversationOwnedByCurrentUser,
|
||||
onCopyConversation,
|
||||
onDuplicateConversation,
|
||||
onSaveTitle,
|
||||
onToggleFlyoutPositionMode,
|
||||
navigateToConversation,
|
||||
setIsUpdatingConversationList,
|
||||
refreshConversations,
|
||||
updateDisplayedConversation,
|
||||
handleConversationAccessUpdate,
|
||||
}: {
|
||||
connectors: UseGenAIConnectorsResult;
|
||||
conversationId?: string;
|
||||
conversation?: Conversation;
|
||||
flyoutPositionMode?: FlyoutPositionMode;
|
||||
licenseInvalid: boolean;
|
||||
loading: boolean;
|
||||
title: string;
|
||||
isConversationOwnedByCurrentUser: boolean;
|
||||
onCopyConversation: () => void;
|
||||
onDuplicateConversation: () => void;
|
||||
onSaveTitle: (title: string) => void;
|
||||
onToggleFlyoutPositionMode?: (newFlyoutPositionMode: FlyoutPositionMode) => void;
|
||||
navigateToConversation?: (nextConversationId?: string) => void;
|
||||
setIsUpdatingConversationList: (isUpdating: boolean) => void;
|
||||
refreshConversations: () => void;
|
||||
updateDisplayedConversation: (id?: string) => void;
|
||||
handleConversationAccessUpdate: (access: ConversationAccess) => Promise<void>;
|
||||
}) {
|
||||
const theme = useEuiTheme();
|
||||
const breakpoint = useCurrentEuiBreakpoint();
|
||||
|
@ -85,13 +103,18 @@ export function ChatHeader({
|
|||
}
|
||||
};
|
||||
|
||||
const { copyConversationToClipboard, copyUrl, deleteConversation } = useConversationContextMenu({
|
||||
setIsUpdatingConversationList,
|
||||
refreshConversations,
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiPanel
|
||||
borderRadius="none"
|
||||
className={breakpoint === 'xs' ? chatHeaderMobileClassName : chatHeaderClassName}
|
||||
hasBorder={false}
|
||||
hasShadow={false}
|
||||
paddingSize={breakpoint === 'xs' ? 's' : 'm'}
|
||||
paddingSize="s"
|
||||
>
|
||||
<EuiFlexGroup gutterSize="m" responsive={false} alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
|
@ -102,115 +125,147 @@ export function ChatHeader({
|
|||
)}
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow className={minWidthClassName}>
|
||||
<EuiInlineEditTitle
|
||||
heading="h2"
|
||||
size={breakpoint === 'xs' ? 'xs' : 's'}
|
||||
value={newTitle}
|
||||
className={css`
|
||||
color: ${!!title
|
||||
? theme.euiTheme.colors.textParagraph
|
||||
: theme.euiTheme.colors.textSubdued};
|
||||
`}
|
||||
inputAriaLabel={i18n.translate('xpack.aiAssistant.chatHeader.editConversationInput', {
|
||||
defaultMessage: 'Edit conversation',
|
||||
})}
|
||||
isReadOnly={
|
||||
!conversationId ||
|
||||
!connectors.selectedConnector ||
|
||||
licenseInvalid ||
|
||||
!Boolean(onSaveTitle) ||
|
||||
!isConversationOwnedByCurrentUser
|
||||
}
|
||||
onChange={(e) => {
|
||||
setNewTitle(e.currentTarget.nodeValue || '');
|
||||
}}
|
||||
onSave={(e) => {
|
||||
if (onSaveTitle) {
|
||||
onSaveTitle(e);
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
setNewTitle(title);
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexGroup
|
||||
gutterSize="xs"
|
||||
justifyContent="spaceBetween"
|
||||
alignItems="center"
|
||||
className={minWidthClassName}
|
||||
>
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center" className={minMaxWidthClassName}>
|
||||
<EuiFlexItem grow={false} className={minWidthClassName}>
|
||||
<EuiInlineEditTitle
|
||||
heading="h2"
|
||||
size={breakpoint === 'xs' ? 'xs' : 's'}
|
||||
value={newTitle}
|
||||
className={css`
|
||||
color: ${!!title
|
||||
? theme.euiTheme.colors.textParagraph
|
||||
: theme.euiTheme.colors.textSubdued};
|
||||
`}
|
||||
inputAriaLabel={i18n.translate(
|
||||
'xpack.aiAssistant.chatHeader.editConversationInput',
|
||||
{
|
||||
defaultMessage: 'Edit conversation',
|
||||
}
|
||||
)}
|
||||
isReadOnly={
|
||||
!conversationId ||
|
||||
!connectors.selectedConnector ||
|
||||
licenseInvalid ||
|
||||
!Boolean(onSaveTitle) ||
|
||||
!isConversationOwnedByCurrentUser
|
||||
}
|
||||
onChange={(e) => {
|
||||
setNewTitle(e.currentTarget.nodeValue || '');
|
||||
}}
|
||||
onSave={onSaveTitle}
|
||||
onCancel={() => {
|
||||
setNewTitle(title);
|
||||
}}
|
||||
editModeProps={{
|
||||
formRowProps: {
|
||||
fullWidth: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="s" responsive={false}>
|
||||
{flyoutPositionMode && onToggleFlyoutPositionMode ? (
|
||||
{conversationId && conversation ? (
|
||||
<>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiPopover
|
||||
anchorPosition="downLeft"
|
||||
button={
|
||||
<EuiToolTip
|
||||
content={
|
||||
flyoutPositionMode === 'overlay'
|
||||
? i18n.translate(
|
||||
'xpack.aiAssistant.chatHeader.euiToolTip.flyoutModeLabel.dock',
|
||||
{ defaultMessage: 'Dock conversation' }
|
||||
)
|
||||
: i18n.translate(
|
||||
'xpack.aiAssistant.chatHeader.euiToolTip.flyoutModeLabel.undock',
|
||||
{ defaultMessage: 'Undock conversation' }
|
||||
)
|
||||
}
|
||||
display="block"
|
||||
>
|
||||
<EuiButtonIcon
|
||||
aria-label={i18n.translate(
|
||||
'xpack.aiAssistant.chatHeader.euiButtonIcon.toggleFlyoutModeLabel',
|
||||
{ defaultMessage: 'Toggle flyout mode' }
|
||||
)}
|
||||
data-test-subj="observabilityAiAssistantChatHeaderButton"
|
||||
iconType={flyoutPositionMode === 'overlay' ? 'menuRight' : 'menuLeft'}
|
||||
onClick={handleToggleFlyoutPositionMode}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
}
|
||||
<ChatSharingMenu
|
||||
isPublic={conversation.public}
|
||||
onChangeConversationAccess={handleConversationAccessUpdate}
|
||||
disabled={licenseInvalid || !isConversationOwnedByCurrentUser}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{navigateToConversation ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<ChatContextMenu
|
||||
disabled={licenseInvalid}
|
||||
onCopyToClipboardClick={() => copyConversationToClipboard(conversation)}
|
||||
onCopyUrlClick={() => copyUrl(conversationId)}
|
||||
onDeleteClick={() => {
|
||||
deleteConversation(conversationId).then(() => updateDisplayedConversation());
|
||||
}}
|
||||
isConversationOwnedByCurrentUser={isConversationOwnedByCurrentUser}
|
||||
onDuplicateConversationClick={onDuplicateConversation}
|
||||
conversationTitle={conversation.conversation.title}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</>
|
||||
) : null}
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="xs" responsive={false}>
|
||||
{flyoutPositionMode && onToggleFlyoutPositionMode ? (
|
||||
<>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiPopover
|
||||
anchorPosition="downLeft"
|
||||
button={
|
||||
<EuiToolTip
|
||||
content={i18n.translate(
|
||||
'xpack.aiAssistant.chatHeader.euiToolTip.navigateToConversationsLabel',
|
||||
{ defaultMessage: 'Navigate to conversations' }
|
||||
)}
|
||||
content={
|
||||
flyoutPositionMode === 'overlay'
|
||||
? i18n.translate(
|
||||
'xpack.aiAssistant.chatHeader.euiToolTip.flyoutModeLabel.dock',
|
||||
{ defaultMessage: 'Dock conversation' }
|
||||
)
|
||||
: i18n.translate(
|
||||
'xpack.aiAssistant.chatHeader.euiToolTip.flyoutModeLabel.undock',
|
||||
{ defaultMessage: 'Undock conversation' }
|
||||
)
|
||||
}
|
||||
display="block"
|
||||
>
|
||||
<EuiButtonIcon
|
||||
aria-label={i18n.translate(
|
||||
'xpack.aiAssistant.chatHeader.euiButtonIcon.navigateToConversationsLabel',
|
||||
{ defaultMessage: 'Navigate to conversations' }
|
||||
'xpack.aiAssistant.chatHeader.euiButtonIcon.toggleFlyoutModeLabel',
|
||||
{ defaultMessage: 'Toggle flyout mode' }
|
||||
)}
|
||||
data-test-subj="observabilityAiAssistantChatHeaderButton"
|
||||
iconType="discuss"
|
||||
onClick={() => navigateToConversation(conversationId)}
|
||||
iconType={flyoutPositionMode === 'overlay' ? 'menuRight' : 'menuLeft'}
|
||||
onClick={handleToggleFlyoutPositionMode}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{navigateToConversation ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiPopover
|
||||
anchorPosition="downLeft"
|
||||
button={
|
||||
<EuiToolTip
|
||||
content={i18n.translate(
|
||||
'xpack.aiAssistant.chatHeader.euiToolTip.navigateToConversationsLabel',
|
||||
{ defaultMessage: 'Navigate to conversations' }
|
||||
)}
|
||||
display="block"
|
||||
>
|
||||
<EuiButtonIcon
|
||||
aria-label={i18n.translate(
|
||||
'xpack.aiAssistant.chatHeader.euiButtonIcon.navigateToConversationsLabel',
|
||||
{ defaultMessage: 'Navigate to conversations' }
|
||||
)}
|
||||
data-test-subj="observabilityAiAssistantChatHeaderButton"
|
||||
iconType="discuss"
|
||||
onClick={() => navigateToConversation(conversationId)}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<ChatActionsMenu
|
||||
connectors={connectors}
|
||||
conversationId={conversationId}
|
||||
disabled={licenseInvalid}
|
||||
onCopyConversationClick={onCopyConversation}
|
||||
onDuplicateConversationClick={onDuplicateConversation}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<ChatActionsMenu connectors={connectors} disabled={licenseInvalid} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
|
|
|
@ -65,7 +65,7 @@ export function ChatItemActions({
|
|||
color="text"
|
||||
data-test-subj="observabilityAiAssistantChatItemActionsInspectPromptButton"
|
||||
display={expanded ? 'fill' : 'empty'}
|
||||
iconType={expanded ? 'eyeClosed' : 'eye'}
|
||||
iconType="inspect"
|
||||
onClick={onToggleExpand}
|
||||
/>
|
||||
) : null}
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { ChatSharingMenu } from './chat_sharing_menu';
|
||||
import { ConversationAccess } from '@kbn/observability-ai-assistant-plugin/public';
|
||||
|
||||
const mockOnChangeConversationAccess = jest.fn();
|
||||
|
||||
describe('ChatSharingMenu', () => {
|
||||
const renderComponent = (props = {}) =>
|
||||
render(
|
||||
<ChatSharingMenu
|
||||
isPublic={false}
|
||||
disabled={false}
|
||||
onChangeConversationAccess={mockOnChangeConversationAccess}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the component correctly', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText('Private')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays shared label when isPublic is true', () => {
|
||||
renderComponent({ isPublic: true });
|
||||
expect(screen.getByText('Shared')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables interaction when disabled is true', () => {
|
||||
renderComponent({ disabled: true });
|
||||
const badge = screen.getByText('Private');
|
||||
expect(badge).not.toHaveAttribute('onClick');
|
||||
});
|
||||
|
||||
it('opens the popover on badge click', () => {
|
||||
renderComponent();
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(screen.getByText('This conversation is only visible to you.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes conversation access when a new option is selected', async () => {
|
||||
mockOnChangeConversationAccess.mockResolvedValueOnce(undefined);
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
fireEvent.click(screen.getByText('Shared'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockOnChangeConversationAccess).toHaveBeenCalledWith(ConversationAccess.SHARED)
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByText('Shared')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('keeps conversation access unchanged if no new option is selected', async () => {
|
||||
renderComponent();
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
fireEvent.click(screen.getAllByText('Private')[1]);
|
||||
|
||||
await waitFor(() => expect(mockOnChangeConversationAccess).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('shows loading state when changing access', async () => {
|
||||
mockOnChangeConversationAccess.mockImplementation(() => new Promise(() => {}));
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
fireEvent.click(screen.getByText('Shared'));
|
||||
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reverts to previous selection if update fails', async () => {
|
||||
mockOnChangeConversationAccess.mockRejectedValueOnce(new Error('Update failed'));
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
fireEvent.click(screen.getByText('Shared'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockOnChangeConversationAccess).toHaveBeenCalledWith(ConversationAccess.SHARED)
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Private')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('closes the popover when clicked on an option', async () => {
|
||||
mockOnChangeConversationAccess.mockResolvedValueOnce(undefined);
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
fireEvent.click(screen.getByText('Shared'));
|
||||
|
||||
await waitFor(() => expect(mockOnChangeConversationAccess).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText('This conversation is only visible to you.')
|
||||
).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,180 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
EuiPopover,
|
||||
EuiBadge,
|
||||
EuiSelectable,
|
||||
EuiText,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiSelectableOption,
|
||||
EuiIcon,
|
||||
useEuiTheme,
|
||||
EuiLoadingSpinner,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ConversationAccess } from '@kbn/observability-ai-assistant-plugin/public';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
interface OptionData {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const privateLabel = i18n.translate('xpack.aiAssistant.chatHeader.shareOptions.private', {
|
||||
defaultMessage: 'Private',
|
||||
});
|
||||
|
||||
const sharedLabel = i18n.translate('xpack.aiAssistant.chatHeader.shareOptions.shared', {
|
||||
defaultMessage: 'Shared',
|
||||
});
|
||||
|
||||
export function ChatSharingMenu({
|
||||
isPublic,
|
||||
disabled,
|
||||
onChangeConversationAccess,
|
||||
}: {
|
||||
isPublic: boolean;
|
||||
disabled: boolean;
|
||||
onChangeConversationAccess: (access: ConversationAccess) => Promise<void>;
|
||||
}) {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const [selectedValue, setSelectedValue] = useState(
|
||||
isPublic ? ConversationAccess.SHARED : ConversationAccess.PRIVATE
|
||||
);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [previousValue, setPreviousValue] = useState(selectedValue);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const options: Array<EuiSelectableOption<OptionData>> = [
|
||||
{
|
||||
key: ConversationAccess.PRIVATE,
|
||||
label: privateLabel,
|
||||
checked: !selectedValue || selectedValue === ConversationAccess.PRIVATE ? 'on' : undefined,
|
||||
description: i18n.translate('xpack.aiAssistant.chatHeader.shareOptions.privateDescription', {
|
||||
defaultMessage: 'This conversation is only visible to you.',
|
||||
}),
|
||||
'data-test-subj': 'observabilityAiAssistantChatPrivateOption',
|
||||
},
|
||||
{
|
||||
key: ConversationAccess.SHARED,
|
||||
label: sharedLabel,
|
||||
checked: selectedValue === ConversationAccess.SHARED ? 'on' : undefined,
|
||||
description: i18n.translate('xpack.aiAssistant.chatHeader.shareOptions.sharedDescription', {
|
||||
defaultMessage: 'Team members can view this conversation.',
|
||||
}),
|
||||
'data-test-subj': 'observabilityAiAssistantChatSharedOption',
|
||||
},
|
||||
];
|
||||
|
||||
const renderOption = useCallback(
|
||||
(option: EuiSelectableOption<OptionData>) => (
|
||||
<EuiFlexGroup gutterSize="xs" direction="column" style={{ paddingBlock: euiTheme.size.xs }}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="s">
|
||||
<strong>{option.label}</strong>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiText size="xs">{option.description}</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
[euiTheme.size.xs]
|
||||
);
|
||||
|
||||
const handleChange = async (newOptions: EuiSelectableOption[]) => {
|
||||
const selectedOption = newOptions.find((option) => option.checked === 'on');
|
||||
|
||||
if (selectedOption && selectedOption.key !== selectedValue) {
|
||||
setPreviousValue(selectedValue);
|
||||
setSelectedValue(selectedOption.key as ConversationAccess);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await onChangeConversationAccess(selectedOption.key as ConversationAccess);
|
||||
} catch (err) {
|
||||
setSelectedValue(previousValue);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setIsPopoverOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<EuiBadge
|
||||
color="default"
|
||||
data-test-subj="observabilityAiAssistantChatAccessLoadingBadge"
|
||||
className={css`
|
||||
min-width: ${euiTheme.size.xxxxl};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`}
|
||||
>
|
||||
<EuiLoadingSpinner size="s" />
|
||||
</EuiBadge>
|
||||
);
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<EuiBadge
|
||||
iconType={selectedValue === ConversationAccess.SHARED ? 'users' : 'lock'}
|
||||
color="default"
|
||||
data-test-subj="observabilityAiAssistantChatAccessBadge"
|
||||
>
|
||||
{selectedValue === ConversationAccess.SHARED ? sharedLabel : privateLabel}
|
||||
</EuiBadge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
button={
|
||||
<EuiBadge
|
||||
iconType={selectedValue === ConversationAccess.SHARED ? 'users' : 'lock'}
|
||||
color="hollow"
|
||||
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
|
||||
onClickAriaLabel={i18n.translate(
|
||||
'xpack.aiAssistant.chatHeader.shareOptions.toggleOptionsBadge',
|
||||
{
|
||||
defaultMessage: 'Toggle sharing options',
|
||||
}
|
||||
)}
|
||||
data-test-subj="observabilityAiAssistantChatAccessBadge"
|
||||
>
|
||||
{selectedValue === ConversationAccess.SHARED ? sharedLabel : privateLabel}
|
||||
<EuiIcon type="arrowDown" size="m" style={{ paddingLeft: euiTheme.size.xs }} />
|
||||
</EuiBadge>
|
||||
}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={() => setIsPopoverOpen(false)}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downCenter"
|
||||
>
|
||||
<EuiSelectable
|
||||
aria-label={i18n.translate('xpack.aiAssistant.chatHeader.shareOptions.options', {
|
||||
defaultMessage: 'Sharing options',
|
||||
})}
|
||||
options={options}
|
||||
singleSelection="always"
|
||||
renderOption={renderOption}
|
||||
onChange={handleChange}
|
||||
listProps={{
|
||||
isVirtualized: false,
|
||||
onFocusBadge: false,
|
||||
textWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{(list) => <div style={{ width: 250 }}>{list}</div>}
|
||||
</EuiSelectable>
|
||||
</EuiPopover>
|
||||
);
|
||||
}
|
|
@ -7,11 +7,13 @@
|
|||
|
||||
import React from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { DATE_CATEGORY_LABELS } from '../i18n';
|
||||
import { ConversationList } from './conversation_list';
|
||||
import { UseConversationListResult } from '../hooks/use_conversation_list';
|
||||
import { useConversationsByDate } from '../hooks/use_conversations_by_date';
|
||||
import { useConversationsByDate, useConversationContextMenu } from '../hooks';
|
||||
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
|
||||
import { getDisplayedConversation } from '../hooks/use_conversations_by_date.test';
|
||||
|
||||
jest.mock('../hooks/use_conversations_by_date', () => ({
|
||||
useConversationsByDate: jest.fn(),
|
||||
|
@ -24,6 +26,12 @@ jest.mock('../hooks/use_confirm_modal', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/use_conversation_context_menu', () => ({
|
||||
useConversationContextMenu: jest.fn().mockReturnValue({
|
||||
deleteConversation: jest.fn(() => Promise.resolve(true)),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockConversations: UseConversationListResult['conversations'] = {
|
||||
value: {
|
||||
conversations: [
|
||||
|
@ -38,7 +46,11 @@ const mockConversations: UseConversationListResult['conversations'] = {
|
|||
numeric_labels: {},
|
||||
messages: [],
|
||||
namespace: 'namespace-1',
|
||||
public: true,
|
||||
public: false,
|
||||
user: {
|
||||
id: 'user_1',
|
||||
name: 'user_one',
|
||||
},
|
||||
},
|
||||
{
|
||||
conversation: {
|
||||
|
@ -52,6 +64,10 @@ const mockConversations: UseConversationListResult['conversations'] = {
|
|||
messages: [],
|
||||
namespace: 'namespace-2',
|
||||
public: true,
|
||||
user: {
|
||||
id: 'user_2',
|
||||
name: 'user_two',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -61,22 +77,8 @@ const mockConversations: UseConversationListResult['conversations'] = {
|
|||
};
|
||||
|
||||
const mockCategorizedConversations = {
|
||||
TODAY: [
|
||||
{
|
||||
id: '1',
|
||||
label: "Today's Conversation",
|
||||
lastUpdated: '2025-01-21T10:00:00Z',
|
||||
href: '/conversation/1',
|
||||
},
|
||||
],
|
||||
YESTERDAY: [
|
||||
{
|
||||
id: '2',
|
||||
label: "Yesterday's Conversation",
|
||||
lastUpdated: '2025-01-20T10:00:00Z',
|
||||
href: '/conversation/2',
|
||||
},
|
||||
],
|
||||
TODAY: [getDisplayedConversation(mockConversations.value?.conversations[0]!)],
|
||||
YESTERDAY: [getDisplayedConversation(mockConversations.value?.conversations[1]!)],
|
||||
THIS_WEEK: [],
|
||||
LAST_WEEK: [],
|
||||
THIS_MONTH: [],
|
||||
|
@ -85,6 +87,15 @@ const mockCategorizedConversations = {
|
|||
OLDER: [],
|
||||
};
|
||||
|
||||
const mockAuthenticatedUser = {
|
||||
username: 'user_one',
|
||||
profile_uid: 'user_1',
|
||||
authentication_realm: {
|
||||
type: 'my_realm_type',
|
||||
name: 'my_realm_name',
|
||||
},
|
||||
} as AuthenticatedUser;
|
||||
|
||||
const defaultProps = {
|
||||
conversations: mockConversations,
|
||||
isLoading: false,
|
||||
|
@ -93,6 +104,10 @@ const defaultProps = {
|
|||
onConversationDeleteClick: jest.fn(),
|
||||
newConversationHref: '/conversation/new',
|
||||
getConversationHref: (id: string) => `/conversation/${id}`,
|
||||
setIsUpdatingConversationList: jest.fn(),
|
||||
refreshConversations: jest.fn(),
|
||||
updateDisplayedConversation: jest.fn(),
|
||||
currentUser: mockAuthenticatedUser,
|
||||
};
|
||||
|
||||
describe('ConversationList', () => {
|
||||
|
@ -173,11 +188,18 @@ describe('ConversationList', () => {
|
|||
expect(defaultProps.onConversationSelect).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('calls onConversationDeleteClick when delete icon is clicked', async () => {
|
||||
it('calls delete conversation when delete icon is clicked', async () => {
|
||||
const mockDeleteConversation = jest.fn(() => Promise.resolve());
|
||||
|
||||
(useConversationContextMenu as jest.Mock).mockReturnValue({
|
||||
deleteConversation: mockDeleteConversation,
|
||||
});
|
||||
|
||||
render(<ConversationList {...defaultProps} />);
|
||||
const deleteButtons = screen.getAllByLabelText('Delete');
|
||||
await fireEvent.click(deleteButtons[0]);
|
||||
expect(defaultProps.onConversationDeleteClick).toHaveBeenCalledWith('1');
|
||||
fireEvent.click(screen.getAllByLabelText('Delete')[0]);
|
||||
|
||||
await waitFor(() => expect(mockDeleteConversation).toHaveBeenCalledTimes(1));
|
||||
expect(mockDeleteConversation).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('renders a new chat button and triggers onConversationSelect when clicked', () => {
|
||||
|
|
|
@ -21,10 +21,13 @@ import {
|
|||
import { css } from '@emotion/css';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React, { MouseEvent } from 'react';
|
||||
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
|
||||
import type { UseConversationListResult } from '../hooks/use_conversation_list';
|
||||
import { useConfirmModal, useConversationsByDate } from '../hooks';
|
||||
import { useConfirmModal, useConversationsByDate, useConversationContextMenu } from '../hooks';
|
||||
import { DATE_CATEGORY_LABELS } from '../i18n';
|
||||
import { NewChatButton } from '../buttons/new_chat_button';
|
||||
import { ConversationListItemLabel } from './conversation_list_item_label';
|
||||
import { isConversationOwnedByUser } from '../utils/is_conversation_owned_by_current_user';
|
||||
|
||||
const panelClassName = css`
|
||||
max-height: 100%;
|
||||
|
@ -44,18 +47,24 @@ export function ConversationList({
|
|||
conversations,
|
||||
isLoading,
|
||||
selectedConversationId,
|
||||
currentUser,
|
||||
onConversationSelect,
|
||||
onConversationDeleteClick,
|
||||
newConversationHref,
|
||||
getConversationHref,
|
||||
setIsUpdatingConversationList,
|
||||
refreshConversations,
|
||||
updateDisplayedConversation,
|
||||
}: {
|
||||
conversations: UseConversationListResult['conversations'];
|
||||
isLoading: boolean;
|
||||
selectedConversationId?: string;
|
||||
currentUser: Pick<AuthenticatedUser, 'full_name' | 'username' | 'profile_uid'>;
|
||||
onConversationSelect?: (conversationId?: string) => void;
|
||||
onConversationDeleteClick: (conversationId: string) => void;
|
||||
newConversationHref?: string;
|
||||
getConversationHref?: (conversationId: string) => string;
|
||||
setIsUpdatingConversationList: (isUpdating: boolean) => void;
|
||||
refreshConversations: () => void;
|
||||
updateDisplayedConversation: (id?: string) => void;
|
||||
}) {
|
||||
const euiTheme = useEuiTheme();
|
||||
const scrollBarStyles = euiScrollBarStyles(euiTheme);
|
||||
|
@ -99,6 +108,11 @@ export function ConversationList({
|
|||
}
|
||||
};
|
||||
|
||||
const { deleteConversation } = useConversationContextMenu({
|
||||
setIsUpdatingConversationList,
|
||||
refreshConversations,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiPanel paddingSize="none" hasShadow={false} className={panelClassName}>
|
||||
|
@ -150,22 +164,33 @@ export function ConversationList({
|
|||
<EuiListGroupItem
|
||||
data-test-subj="observabilityAiAssistantConversationsLink"
|
||||
key={conversation.id}
|
||||
label={conversation.label}
|
||||
label={
|
||||
<ConversationListItemLabel
|
||||
labelText={conversation.label}
|
||||
isPublic={conversation.public}
|
||||
/>
|
||||
}
|
||||
size="s"
|
||||
isActive={conversation.id === selectedConversationId}
|
||||
isDisabled={isLoading}
|
||||
wrapText
|
||||
showToolTip
|
||||
toolTipText={conversation.label}
|
||||
href={conversation.href}
|
||||
onClick={(event) => onClickConversation(event, conversation.id)}
|
||||
extraAction={{
|
||||
iconType: 'trash',
|
||||
color: 'danger',
|
||||
'aria-label': i18n.translate(
|
||||
'xpack.aiAssistant.conversationList.deleteConversationIconLabel',
|
||||
{
|
||||
defaultMessage: 'Delete',
|
||||
}
|
||||
),
|
||||
disabled: !isConversationOwnedByUser({
|
||||
conversationId: conversation.id,
|
||||
conversationUser: conversation.conversation.user,
|
||||
currentUser,
|
||||
}),
|
||||
onClick: () => {
|
||||
confirmDeleteCallback(
|
||||
i18n.translate(
|
||||
|
@ -179,7 +204,12 @@ export function ConversationList({
|
|||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
onConversationDeleteClick(conversation.id);
|
||||
|
||||
deleteConversation(conversation.id).then(() => {
|
||||
if (conversation.id === selectedConversationId) {
|
||||
updateDisplayedConversation();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
}}
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useEuiTheme, EuiIcon } from '@elastic/eui';
|
||||
|
||||
export function ConversationListItemLabel({
|
||||
labelText,
|
||||
isPublic,
|
||||
}: {
|
||||
labelText: string;
|
||||
isPublic: boolean;
|
||||
}) {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
return (
|
||||
// <span> is used on purpose, using <div> will yield invalid HTML
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: euiTheme.size.s }}>
|
||||
<span style={{ display: 'flex', flexGrow: 1, overflow: 'hidden' }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{labelText}
|
||||
</span>
|
||||
</span>
|
||||
{isPublic ? <EuiIcon type="users" size="m" css={{ flexShrink: 0 }} /> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
|
@ -11,6 +11,7 @@ import ReactDOM from 'react-dom';
|
|||
import type { AssistantScope } from '@kbn/ai-assistant-common';
|
||||
import { isEqual } from 'lodash';
|
||||
import { Conversation } from '@kbn/observability-ai-assistant-plugin/common';
|
||||
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
|
||||
import { useKibana } from '../hooks/use_kibana';
|
||||
import { ConversationList, ChatBody, ChatInlineEditingContent } from '../chat';
|
||||
import { useConversationKey } from '../hooks/use_conversation_key';
|
||||
|
@ -25,7 +26,7 @@ const SECOND_SLOT_CONTAINER_WIDTH = 400;
|
|||
|
||||
interface ConversationViewProps {
|
||||
conversationId?: string;
|
||||
navigateToConversation?: (nextConversationId?: string) => void;
|
||||
navigateToConversation: (nextConversationId?: string) => void;
|
||||
getConversationHref?: (conversationId: string) => string;
|
||||
newConversationHref?: string;
|
||||
scopes?: AssistantScope[];
|
||||
|
@ -72,24 +73,27 @@ export const ConversationView: React.FC<ConversationViewProps> = ({
|
|||
const [secondSlotContainer, setSecondSlotContainer] = useState<HTMLDivElement | null>(null);
|
||||
const [isSecondSlotVisible, setIsSecondSlotVisible] = useState(false);
|
||||
|
||||
const conversationList = useConversationList();
|
||||
|
||||
function handleRefreshConversations() {
|
||||
conversationList.conversations.refresh();
|
||||
}
|
||||
const {
|
||||
conversations,
|
||||
isLoadingConversationList,
|
||||
setIsUpdatingConversationList,
|
||||
refreshConversations,
|
||||
} = useConversationList();
|
||||
|
||||
const handleConversationUpdate = (conversation: { conversation: { id: string } }) => {
|
||||
if (!conversationId) {
|
||||
updateConversationIdInPlace(conversation.conversation.id);
|
||||
if (navigateToConversation) {
|
||||
navigateToConversation(conversation.conversation.id);
|
||||
}
|
||||
navigateToConversation(conversation.conversation.id);
|
||||
}
|
||||
handleRefreshConversations();
|
||||
refreshConversations();
|
||||
};
|
||||
|
||||
const updateDisplayedConversation = (id?: string) => {
|
||||
navigateToConversation(id || undefined);
|
||||
};
|
||||
|
||||
const handleConversationDuplicate = (conversation: Conversation) => {
|
||||
handleRefreshConversations();
|
||||
refreshConversations();
|
||||
navigateToConversation?.(conversation.conversation.id);
|
||||
};
|
||||
|
||||
|
@ -146,20 +150,16 @@ export const ConversationView: React.FC<ConversationViewProps> = ({
|
|||
<EuiFlexItem grow={false} className={conversationListContainerName}>
|
||||
<ConversationList
|
||||
selectedConversationId={conversationId}
|
||||
conversations={conversationList.conversations}
|
||||
isLoading={conversationList.isLoading}
|
||||
onConversationDeleteClick={(deletedConversationId) => {
|
||||
conversationList.deleteConversation(deletedConversationId).then(() => {
|
||||
if (deletedConversationId === conversationId && navigateToConversation) {
|
||||
navigateToConversation(undefined);
|
||||
}
|
||||
});
|
||||
}}
|
||||
conversations={conversations}
|
||||
isLoading={isLoadingConversationList}
|
||||
newConversationHref={newConversationHref}
|
||||
currentUser={currentUser as AuthenticatedUser}
|
||||
onConversationSelect={navigateToConversation}
|
||||
getConversationHref={getConversationHref}
|
||||
setIsUpdatingConversationList={setIsUpdatingConversationList}
|
||||
refreshConversations={refreshConversations}
|
||||
updateDisplayedConversation={updateDisplayedConversation}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
</EuiFlexItem>
|
||||
|
||||
{!chatService.value ? (
|
||||
|
@ -182,6 +182,9 @@ export const ConversationView: React.FC<ConversationViewProps> = ({
|
|||
showLinkToConversationsApp={false}
|
||||
onConversationUpdate={handleConversationUpdate}
|
||||
navigateToConversation={navigateToConversation}
|
||||
setIsUpdatingConversationList={setIsUpdatingConversationList}
|
||||
refreshConversations={refreshConversations}
|
||||
updateDisplayedConversation={updateDisplayedConversation}
|
||||
onConversationDuplicate={handleConversationDuplicate}
|
||||
/>
|
||||
|
||||
|
|
|
@ -12,3 +12,4 @@ export * from './use_scopes';
|
|||
export * from './use_genai_connectors';
|
||||
export * from './use_confirm_modal';
|
||||
export * from './use_conversations_by_date';
|
||||
export * from './use_conversation_context_menu';
|
||||
|
|
|
@ -15,6 +15,7 @@ import type {
|
|||
import type { ObservabilityAIAssistantChatService } from '@kbn/observability-ai-assistant-plugin/public';
|
||||
import type { AbortableAsyncState } from '@kbn/observability-ai-assistant-plugin/public';
|
||||
import type { UseChatResult } from '@kbn/observability-ai-assistant-plugin/public';
|
||||
import { ConversationAccess } from '@kbn/observability-ai-assistant-plugin/public';
|
||||
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
|
||||
import { EMPTY_CONVERSATION_TITLE } from '../i18n';
|
||||
import { useAIAssistantAppService } from './use_ai_assistant_app_service';
|
||||
|
@ -22,6 +23,7 @@ import { useKibana } from './use_kibana';
|
|||
import { useOnce } from './use_once';
|
||||
import { useAbortableAsync } from './use_abortable_async';
|
||||
import { useScopes } from './use_scopes';
|
||||
import { isConversationOwnedByUser } from '../utils/is_conversation_owned_by_current_user';
|
||||
|
||||
function createNewConversation({
|
||||
title = EMPTY_CONVERSATION_TITLE,
|
||||
|
@ -55,6 +57,7 @@ export type UseConversationResult = {
|
|||
user?: Pick<AuthenticatedUser, 'username' | 'profile_uid'>;
|
||||
isConversationOwnedByCurrentUser: boolean;
|
||||
saveTitle: (newTitle: string) => void;
|
||||
updateConversationAccess: (access: ConversationAccess) => Promise<Conversation>;
|
||||
duplicateConversation: () => Promise<Conversation>;
|
||||
} & Omit<UseChatResult, 'setMessages'>;
|
||||
|
||||
|
@ -84,6 +87,8 @@ export function useConversation({
|
|||
const initialMessages = useOnce(initialMessagesFromProps);
|
||||
const initialTitle = useOnce(initialTitleFromProps);
|
||||
|
||||
const [displayedConversationId, setDisplayedConversationId] = useState(initialConversationId);
|
||||
|
||||
if (initialMessages.length && initialConversationId) {
|
||||
throw new Error('Cannot set initialMessages if initialConversationId is set');
|
||||
}
|
||||
|
@ -123,6 +128,7 @@ export function useConversation({
|
|||
if (!displayedConversationId || !conversation.value) {
|
||||
throw new Error('Cannot duplicate the conversation if conversation is not stored');
|
||||
}
|
||||
|
||||
try {
|
||||
const duplicatedConversation = await service.callApi(
|
||||
`POST /internal/observability_ai_assistant/conversation/{conversationId}/duplicate`,
|
||||
|
@ -147,6 +153,46 @@ export function useConversation({
|
|||
}
|
||||
};
|
||||
|
||||
const updateConversationAccess = async (access: ConversationAccess) => {
|
||||
if (!displayedConversationId || !conversation.value) {
|
||||
throw new Error('Cannot share the conversation if conversation is not stored');
|
||||
}
|
||||
|
||||
try {
|
||||
const sharedConversation = await service.callApi(
|
||||
`PATCH /internal/observability_ai_assistant/conversation/{conversationId}`,
|
||||
{
|
||||
signal: null,
|
||||
params: {
|
||||
path: {
|
||||
conversationId: displayedConversationId,
|
||||
},
|
||||
body: {
|
||||
public: access === ConversationAccess.SHARED,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
notifications!.toasts.addSuccess({
|
||||
title: i18n.translate('xpack.aiAssistant.updateConversationAccessSuccessToast', {
|
||||
defaultMessage: 'Conversation access successfully updated to "{access}"',
|
||||
values: { access },
|
||||
}),
|
||||
});
|
||||
|
||||
return sharedConversation;
|
||||
} catch (err) {
|
||||
notifications!.toasts.addError(err, {
|
||||
title: i18n.translate('xpack.aiAssistant.updateConversationAccessErrorToast', {
|
||||
defaultMessage: 'Could not update conversation access to "{access}"',
|
||||
values: { access },
|
||||
}),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const { next, messages, setMessages, state, stop } = useChat({
|
||||
initialMessages,
|
||||
initialConversationId,
|
||||
|
@ -162,8 +208,6 @@ export function useConversation({
|
|||
scopes,
|
||||
});
|
||||
|
||||
const [displayedConversationId, setDisplayedConversationId] = useState(initialConversationId);
|
||||
|
||||
const conversation: AbortableAsyncState<ConversationCreateRequest | Conversation | undefined> =
|
||||
useAbortableAsync(
|
||||
({ signal }) => {
|
||||
|
@ -198,25 +242,20 @@ export function useConversation({
|
|||
}
|
||||
);
|
||||
|
||||
const isConversationOwnedByUser = (conversationUser: Conversation['user']): boolean => {
|
||||
if (!initialConversationId) return true;
|
||||
|
||||
if (!conversationUser || !currentUser) return false;
|
||||
|
||||
return conversationUser.id && currentUser.profile_uid
|
||||
? conversationUser.id === currentUser.profile_uid
|
||||
: conversationUser.name === currentUser.username;
|
||||
};
|
||||
const conversationId =
|
||||
conversation.value?.conversation && 'id' in conversation.value.conversation
|
||||
? conversation.value.conversation.id
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
conversation,
|
||||
conversationId:
|
||||
conversation.value?.conversation && 'id' in conversation.value.conversation
|
||||
? conversation.value.conversation.id
|
||||
: undefined,
|
||||
isConversationOwnedByCurrentUser: isConversationOwnedByUser(
|
||||
conversation.value && 'user' in conversation.value ? conversation.value.user : undefined
|
||||
),
|
||||
conversationId,
|
||||
isConversationOwnedByCurrentUser: isConversationOwnedByUser({
|
||||
conversationId,
|
||||
conversationUser:
|
||||
conversation.value && 'user' in conversation.value ? conversation.value.user : undefined,
|
||||
currentUser,
|
||||
}),
|
||||
user:
|
||||
initialConversationId && conversation.value?.conversation && 'user' in conversation.value
|
||||
? {
|
||||
|
@ -243,6 +282,7 @@ export function useConversation({
|
|||
onConversationUpdate?.(nextConversation);
|
||||
});
|
||||
},
|
||||
updateConversationAccess,
|
||||
duplicateConversation,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,184 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import type { Conversation } from '@kbn/observability-ai-assistant-plugin/common';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { useConversationContextMenu } from './use_conversation_context_menu';
|
||||
|
||||
const setIsUpdatingConversationList = jest.fn();
|
||||
const refreshConversations = jest.fn();
|
||||
|
||||
const mockService: { callApi: jest.Mock } = {
|
||||
callApi: jest.fn(),
|
||||
};
|
||||
|
||||
const mockNotifications = {
|
||||
toasts: {
|
||||
addSuccess: jest.fn(),
|
||||
addError: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockHttp = {
|
||||
basePath: {
|
||||
prepend: jest.fn((path) => `/mock-base${path}`),
|
||||
},
|
||||
};
|
||||
|
||||
const useKibanaMockServices = {
|
||||
uiSettings: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
notifications: mockNotifications,
|
||||
http: mockHttp,
|
||||
observabilityAIAssistant: {
|
||||
service: mockService,
|
||||
},
|
||||
};
|
||||
|
||||
describe('useConversationContextMenu', () => {
|
||||
const originalClipboard = global.window.navigator.clipboard;
|
||||
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<KibanaContextProvider services={useKibanaMockServices}>{children}</KibanaContextProvider>
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: jest.fn(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: originalClipboard,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('deletes a conversation successfully', async () => {
|
||||
mockService.callApi.mockResolvedValueOnce({});
|
||||
const { result } = renderHook(
|
||||
() => useConversationContextMenu({ setIsUpdatingConversationList, refreshConversations }),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteConversation('1');
|
||||
});
|
||||
|
||||
expect(setIsUpdatingConversationList).toHaveBeenCalledWith(true);
|
||||
expect(mockService.callApi).toHaveBeenCalledWith(
|
||||
'DELETE /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
{
|
||||
params: { path: { conversationId: '1' } },
|
||||
signal: null,
|
||||
}
|
||||
);
|
||||
expect(setIsUpdatingConversationList).toHaveBeenCalledWith(false);
|
||||
expect(refreshConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles delete conversation errors', async () => {
|
||||
mockService.callApi.mockRejectedValueOnce(new Error('Delete failed'));
|
||||
const { result } = renderHook(
|
||||
() => useConversationContextMenu({ setIsUpdatingConversationList, refreshConversations }),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteConversation('1');
|
||||
});
|
||||
|
||||
expect(mockNotifications.toasts.addError).toHaveBeenCalledWith(expect.any(Error), {
|
||||
title: 'Could not delete conversation',
|
||||
});
|
||||
});
|
||||
|
||||
it('copies conversation content to clipboard', () => {
|
||||
const mockConversation: Conversation = {
|
||||
systemMessage: 'System message',
|
||||
conversation: {
|
||||
id: '1',
|
||||
title: 'Test Conversation',
|
||||
last_updated: new Date().toISOString(),
|
||||
},
|
||||
'@timestamp': new Date().toISOString(),
|
||||
labels: {},
|
||||
numeric_labels: {},
|
||||
messages: [],
|
||||
namespace: 'namespace-1',
|
||||
public: true,
|
||||
};
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useConversationContextMenu({ setIsUpdatingConversationList, refreshConversations }),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.copyConversationToClipboard(mockConversation);
|
||||
});
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
title: mockConversation.conversation.title,
|
||||
systemMessage: mockConversation.systemMessage,
|
||||
messages: mockConversation.messages,
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockNotifications.toasts.addSuccess).toHaveBeenCalledWith({
|
||||
title: 'Conversation content copied to clipboard in JSON format',
|
||||
});
|
||||
});
|
||||
|
||||
it('copies conversation URL to clipboard', () => {
|
||||
const { result } = renderHook(
|
||||
() => useConversationContextMenu({ setIsUpdatingConversationList, refreshConversations }),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.copyUrl('1');
|
||||
});
|
||||
|
||||
expect(mockHttp.basePath.prepend).toHaveBeenCalledWith(
|
||||
'/app/observabilityAIAssistant/conversations/1'
|
||||
);
|
||||
expect(mockNotifications.toasts.addSuccess).toHaveBeenCalledWith({
|
||||
title: 'Conversation URL copied to clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles copy URL errors', () => {
|
||||
const { result } = renderHook(
|
||||
() => useConversationContextMenu({ setIsUpdatingConversationList, refreshConversations }),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
jest.spyOn(navigator.clipboard, 'writeText').mockImplementation(() => {
|
||||
throw new Error('Copy failed');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.copyUrl('1');
|
||||
});
|
||||
|
||||
expect(mockNotifications.toasts.addError).toHaveBeenCalledWith(expect.any(Error), {
|
||||
title: 'Could not copy conversation URL',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { Message, Conversation } from '@kbn/observability-ai-assistant-plugin/common';
|
||||
import { useAIAssistantAppService } from './use_ai_assistant_app_service';
|
||||
import { useKibana } from './use_kibana';
|
||||
import { deserializeMessage } from '../utils/deserialize_message';
|
||||
|
||||
export interface CopyConversationToClipboardParams {
|
||||
conversation: Conversation;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export interface UseConversationContextMenuResult {
|
||||
deleteConversation: (id: string) => Promise<void>;
|
||||
copyConversationToClipboard: (conversation: Conversation) => void;
|
||||
copyUrl: (id: string) => void;
|
||||
}
|
||||
|
||||
export function useConversationContextMenu({
|
||||
setIsUpdatingConversationList,
|
||||
refreshConversations,
|
||||
}: {
|
||||
setIsUpdatingConversationList: (isUpdating: boolean) => void;
|
||||
refreshConversations: () => void;
|
||||
}): UseConversationContextMenuResult {
|
||||
const service = useAIAssistantAppService();
|
||||
|
||||
const { notifications, http } = useKibana().services;
|
||||
|
||||
const handleDeleteConversation = async (id: string) => {
|
||||
setIsUpdatingConversationList(true);
|
||||
|
||||
try {
|
||||
await service.callApi(
|
||||
'DELETE /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
conversationId: id,
|
||||
},
|
||||
},
|
||||
signal: null,
|
||||
}
|
||||
);
|
||||
|
||||
refreshConversations();
|
||||
} catch (err) {
|
||||
notifications!.toasts.addError(err, {
|
||||
title: i18n.translate('xpack.aiAssistant.flyout.deleteConversationErrorToast', {
|
||||
defaultMessage: 'Could not delete conversation',
|
||||
}),
|
||||
toastMessage: err.body?.message,
|
||||
});
|
||||
}
|
||||
|
||||
setIsUpdatingConversationList(false);
|
||||
};
|
||||
|
||||
const handleCopyConversationToClipboard = (conversation: Conversation) => {
|
||||
try {
|
||||
const deserializedMessages = conversation.messages.map(deserializeMessage);
|
||||
|
||||
const content = JSON.stringify({
|
||||
title: conversation.conversation.title,
|
||||
systemMessage: conversation.systemMessage,
|
||||
messages: deserializedMessages,
|
||||
});
|
||||
|
||||
navigator.clipboard?.writeText(content || '');
|
||||
|
||||
notifications!.toasts.addSuccess({
|
||||
title: i18n.translate('xpack.aiAssistant.copyConversationSuccessToast', {
|
||||
defaultMessage: 'Conversation content copied to clipboard in JSON format',
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
notifications!.toasts.addError(error, {
|
||||
title: i18n.translate('xpack.aiAssistant.copyConversationErrorToast', {
|
||||
defaultMessage: 'Could not copy conversation',
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyUrl = (id: string) => {
|
||||
try {
|
||||
const conversationUrl = http?.basePath.prepend(
|
||||
`/app/observabilityAIAssistant/conversations/${id}`
|
||||
);
|
||||
|
||||
if (!conversationUrl) {
|
||||
throw new Error('Conversation URL does not exist');
|
||||
}
|
||||
|
||||
const urlToCopy = new URL(conversationUrl, window.location.origin).toString();
|
||||
navigator.clipboard?.writeText(urlToCopy);
|
||||
|
||||
notifications!.toasts.addSuccess({
|
||||
title: i18n.translate('xpack.aiAssistant.copyUrlSuccessToast', {
|
||||
defaultMessage: 'Conversation URL copied to clipboard',
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
notifications!.toasts.addError(error, {
|
||||
title: i18n.translate('xpack.aiAssistant.copyUrlErrorToast', {
|
||||
defaultMessage: 'Could not copy conversation URL',
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
deleteConversation: handleDeleteConversation,
|
||||
copyConversationToClipboard: handleCopyConversationToClipboard,
|
||||
copyUrl: handleCopyUrl,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { useConversationList } from './use_conversation_list';
|
||||
|
||||
const mockService: { callApi: jest.Mock } = {
|
||||
callApi: jest.fn(),
|
||||
};
|
||||
|
||||
const useKibanaMockServices = {
|
||||
uiSettings: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
observabilityAIAssistant: {
|
||||
service: mockService,
|
||||
},
|
||||
};
|
||||
|
||||
describe('useConversationList', () => {
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<KibanaContextProvider services={useKibanaMockServices}>{children}</KibanaContextProvider>
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('fetches conversations on mount', async () => {
|
||||
const mockResponse = { conversations: [{ id: '1', title: 'Test Conversation' }] };
|
||||
mockService.callApi.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(useConversationList, { wrapper });
|
||||
|
||||
expect(result.current.isLoadingConversationList).toBe(true);
|
||||
await act(async () => {});
|
||||
expect(result.current.conversations.value).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('refreshes conversations when refreshConversations is called', async () => {
|
||||
const mockResponse = { conversations: [{ id: '1', title: 'Test Conversation' }] };
|
||||
mockService.callApi.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(useConversationList, { wrapper });
|
||||
await act(async () => {});
|
||||
expect(result.current.conversations.value).toEqual(mockResponse);
|
||||
|
||||
const newMockResponse = { conversations: [{ id: '2', title: 'New Conversation' }] };
|
||||
mockService.callApi.mockResolvedValueOnce(newMockResponse);
|
||||
|
||||
await act(async () => {
|
||||
result.current.refreshConversations();
|
||||
});
|
||||
|
||||
expect(result.current.conversations.value).toEqual(newMockResponse);
|
||||
});
|
||||
|
||||
it('sets loading state correctly during API calls', async () => {
|
||||
const mockResponse = { conversations: [] };
|
||||
mockService.callApi.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(useConversationList, { wrapper });
|
||||
expect(result.current.isLoadingConversationList).toBe(true);
|
||||
|
||||
await act(async () => {});
|
||||
expect(result.current.isLoadingConversationList).toBe(false);
|
||||
});
|
||||
|
||||
it('allows manual update of loading state', async () => {
|
||||
const { result } = renderHook(useConversationList, { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.setIsUpdatingConversationList(true);
|
||||
});
|
||||
expect(result.current.isLoadingConversationList).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.setIsUpdatingConversationList(false);
|
||||
});
|
||||
expect(result.current.isLoadingConversationList).toBe(false);
|
||||
});
|
||||
});
|
|
@ -6,18 +6,18 @@
|
|||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
type AbortableAsyncState,
|
||||
type Conversation,
|
||||
useAbortableAsync,
|
||||
} from '@kbn/observability-ai-assistant-plugin/public';
|
||||
import { useAIAssistantAppService } from './use_ai_assistant_app_service';
|
||||
import { useKibana } from './use_kibana';
|
||||
|
||||
export interface UseConversationListResult {
|
||||
isLoading: boolean;
|
||||
conversations: AbortableAsyncState<{ conversations: Conversation[] }>;
|
||||
deleteConversation: (id: string) => Promise<void>;
|
||||
isLoadingConversationList: boolean;
|
||||
setIsUpdatingConversationList: (isUpdating: boolean) => void;
|
||||
refreshConversations: () => void;
|
||||
}
|
||||
|
||||
export function useConversationList(): UseConversationListResult {
|
||||
|
@ -25,10 +25,6 @@ export function useConversationList(): UseConversationListResult {
|
|||
|
||||
const [isUpdatingList, setIsUpdatingList] = useState(false);
|
||||
|
||||
const {
|
||||
services: { notifications },
|
||||
} = useKibana();
|
||||
|
||||
const conversations = useAbortableAsync(
|
||||
({ signal }) => {
|
||||
setIsUpdatingList(true);
|
||||
|
@ -43,40 +39,10 @@ export function useConversationList(): UseConversationListResult {
|
|||
setIsUpdatingList(conversations.loading);
|
||||
}, [conversations.loading]);
|
||||
|
||||
const handleDeleteConversation = async (id: string) => {
|
||||
setIsUpdatingList(true);
|
||||
|
||||
try {
|
||||
await service.callApi(
|
||||
'DELETE /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
conversationId: id,
|
||||
},
|
||||
},
|
||||
signal: null,
|
||||
}
|
||||
);
|
||||
|
||||
conversations.refresh();
|
||||
} catch (err) {
|
||||
notifications!.toasts.addError(err, {
|
||||
title: i18n.translate('xpack.aiAssistant.flyout.failedToDeleteConversation', {
|
||||
defaultMessage: 'Could not delete conversation',
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
deleteConversation: (id: string) => {
|
||||
setIsUpdatingList(true);
|
||||
return handleDeleteConversation(id).finally(() => {
|
||||
setIsUpdatingList(false);
|
||||
});
|
||||
},
|
||||
conversations,
|
||||
isLoading: conversations.loading || isUpdatingList,
|
||||
isLoadingConversationList: conversations.loading || isUpdatingList,
|
||||
setIsUpdatingConversationList: setIsUpdatingList,
|
||||
refreshConversations: conversations.refresh,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -15,12 +15,16 @@ jest.mock('../utils/date', () => ({
|
|||
isValidDateMath: jest.fn(),
|
||||
}));
|
||||
|
||||
const getDisplayedConversation = (conversation: Conversation) => {
|
||||
jest.unmock('./use_conversations_by_date');
|
||||
|
||||
export const getDisplayedConversation = (conversation: Conversation) => {
|
||||
return {
|
||||
id: conversation.conversation.id,
|
||||
label: conversation.conversation.title,
|
||||
lastUpdated: conversation.conversation.last_updated,
|
||||
href: `/conversation/${conversation.conversation.id}`,
|
||||
public: conversation.public,
|
||||
conversation,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -26,7 +26,14 @@ export function useConversationsByDate(
|
|||
|
||||
const categorizedConversations: Record<
|
||||
string,
|
||||
Array<{ id: string; label: string; lastUpdated: string; href?: string }>
|
||||
Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
lastUpdated: string;
|
||||
href?: string;
|
||||
public: boolean;
|
||||
conversation: Conversation;
|
||||
}>
|
||||
> = {
|
||||
TODAY: [],
|
||||
YESTERDAY: [],
|
||||
|
@ -49,6 +56,8 @@ export function useConversationsByDate(
|
|||
label: conversation.conversation.title,
|
||||
lastUpdated: conversation.conversation.last_updated,
|
||||
href: getConversationHref ? getConversationHref(conversation.conversation.id) : undefined,
|
||||
public: conversation.public,
|
||||
conversation,
|
||||
};
|
||||
|
||||
if (lastUpdated >= startOfToday) {
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Conversation } from '@kbn/observability-ai-assistant-plugin/public';
|
||||
import type { AuthenticatedUser } from '@kbn/security-plugin-types-common';
|
||||
|
||||
export const isConversationOwnedByUser = ({
|
||||
conversationId,
|
||||
conversationUser,
|
||||
currentUser,
|
||||
}: {
|
||||
conversationId?: string;
|
||||
conversationUser?: Conversation['user'];
|
||||
currentUser: Pick<AuthenticatedUser, 'full_name' | 'username' | 'profile_uid'> | undefined;
|
||||
}): boolean => {
|
||||
if (!conversationId) return true;
|
||||
|
||||
if (!conversationUser || !currentUser) return false;
|
||||
|
||||
return conversationUser.id && currentUser.profile_uid
|
||||
? conversationUser.id === currentUser.profile_uid
|
||||
: conversationUser.name === currentUser.username;
|
||||
};
|
|
@ -41,5 +41,6 @@
|
|||
"@kbn/storybook",
|
||||
"@kbn/ai-assistant-icon",
|
||||
"@kbn/datemath",
|
||||
"@kbn/security-plugin-types-common",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -9721,7 +9721,6 @@
|
|||
"xpack.aiAssistant.chatFlyout.euiToolTip.expandConversationListLabel": "Développer la liste des conversations",
|
||||
"xpack.aiAssistant.chatFlyout.euiToolTip.newChatLabel": "Nouveau chat",
|
||||
"xpack.aiAssistant.chatHeader.actions.connector": "Connecteur",
|
||||
"xpack.aiAssistant.chatHeader.actions.copyConversation": "Copier la conversation",
|
||||
"xpack.aiAssistant.chatHeader.actions.knowledgeBase": "Gérer la base de connaissances",
|
||||
"xpack.aiAssistant.chatHeader.actions.settings": "Réglages de l'assistant d'IA",
|
||||
"xpack.aiAssistant.chatHeader.actions.title": "Actions",
|
||||
|
@ -9763,7 +9762,6 @@
|
|||
"xpack.aiAssistant.flyout.confirmDeleteButtonText": "Supprimer la conversation",
|
||||
"xpack.aiAssistant.flyout.confirmDeleteConversationContent": "Cette action ne peut pas être annulée.",
|
||||
"xpack.aiAssistant.flyout.confirmDeleteConversationTitle": "Supprimer cette conversation ?",
|
||||
"xpack.aiAssistant.flyout.failedToDeleteConversation": "Impossible de supprimer la conversation",
|
||||
"xpack.aiAssistant.functionListPopover.euiButtonIcon.selectAFunctionLabel": "Sélectionner la fonction",
|
||||
"xpack.aiAssistant.functionListPopover.euiToolTip.clearFunction": "Effacer la fonction",
|
||||
"xpack.aiAssistant.functionListPopover.euiToolTip.selectAFunctionLabel": "Sélectionner une fonction",
|
||||
|
|
|
@ -9597,7 +9597,6 @@
|
|||
"xpack.aiAssistant.chatFlyout.euiToolTip.expandConversationListLabel": "会話リストを展開",
|
||||
"xpack.aiAssistant.chatFlyout.euiToolTip.newChatLabel": "新しいチャット",
|
||||
"xpack.aiAssistant.chatHeader.actions.connector": "コネクター",
|
||||
"xpack.aiAssistant.chatHeader.actions.copyConversation": "会話をコピー",
|
||||
"xpack.aiAssistant.chatHeader.actions.knowledgeBase": "ナレッジベースを管理",
|
||||
"xpack.aiAssistant.chatHeader.actions.settings": "AI Assistant設定",
|
||||
"xpack.aiAssistant.chatHeader.actions.title": "アクション",
|
||||
|
@ -9639,7 +9638,6 @@
|
|||
"xpack.aiAssistant.flyout.confirmDeleteButtonText": "会話を削除",
|
||||
"xpack.aiAssistant.flyout.confirmDeleteConversationContent": "この操作は元に戻すことができません。",
|
||||
"xpack.aiAssistant.flyout.confirmDeleteConversationTitle": "この会話を削除しますか?",
|
||||
"xpack.aiAssistant.flyout.failedToDeleteConversation": "会話を削除できませんでした",
|
||||
"xpack.aiAssistant.functionListPopover.euiButtonIcon.selectAFunctionLabel": "関数を選択",
|
||||
"xpack.aiAssistant.functionListPopover.euiToolTip.clearFunction": "関数を消去",
|
||||
"xpack.aiAssistant.functionListPopover.euiToolTip.selectAFunctionLabel": "関数を選択",
|
||||
|
|
|
@ -9443,7 +9443,6 @@
|
|||
"xpack.aiAssistant.chatFlyout.euiToolTip.expandConversationListLabel": "展开对话列表",
|
||||
"xpack.aiAssistant.chatFlyout.euiToolTip.newChatLabel": "新聊天",
|
||||
"xpack.aiAssistant.chatHeader.actions.connector": "连接器",
|
||||
"xpack.aiAssistant.chatHeader.actions.copyConversation": "复制对话",
|
||||
"xpack.aiAssistant.chatHeader.actions.knowledgeBase": "管理知识库",
|
||||
"xpack.aiAssistant.chatHeader.actions.settings": "AI 助手设置",
|
||||
"xpack.aiAssistant.chatHeader.actions.title": "操作",
|
||||
|
@ -9485,7 +9484,6 @@
|
|||
"xpack.aiAssistant.flyout.confirmDeleteButtonText": "删除对话",
|
||||
"xpack.aiAssistant.flyout.confirmDeleteConversationContent": "此操作无法撤消。",
|
||||
"xpack.aiAssistant.flyout.confirmDeleteConversationTitle": "删除此对话?",
|
||||
"xpack.aiAssistant.flyout.failedToDeleteConversation": "无法删除对话",
|
||||
"xpack.aiAssistant.functionListPopover.euiButtonIcon.selectAFunctionLabel": "选择函数",
|
||||
"xpack.aiAssistant.functionListPopover.euiToolTip.clearFunction": "清除函数",
|
||||
"xpack.aiAssistant.functionListPopover.euiToolTip.selectAFunctionLabel": "选择函数",
|
||||
|
|
|
@ -5,9 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export type { Message, Conversation, KnowledgeBaseEntry } from './types';
|
||||
export type { ConversationCreateRequest } from './types';
|
||||
export { KnowledgeBaseEntryRole, MessageRole } from './types';
|
||||
export type { Message, Conversation, KnowledgeBaseEntry, ConversationCreateRequest } from './types';
|
||||
export { KnowledgeBaseEntryRole, MessageRole, ConversationAccess } from './types';
|
||||
export type { FunctionDefinition, CompatibleJSONSchema } from './functions/types';
|
||||
export { FunctionVisibility } from './functions/function_visibility';
|
||||
export {
|
||||
|
|
|
@ -145,3 +145,8 @@ export interface ObservabilityAIAssistantScreenContext {
|
|||
actions?: Array<ScreenContextActionDefinition<any>>;
|
||||
starterPrompts?: StarterPrompt[];
|
||||
}
|
||||
|
||||
export enum ConversationAccess {
|
||||
SHARED = 'shared',
|
||||
PRIVATE = 'private',
|
||||
}
|
||||
|
|
|
@ -66,7 +66,10 @@ export {
|
|||
KnowledgeBaseEntryRole,
|
||||
concatenateChatCompletionChunks,
|
||||
StreamingChatResponseEventType,
|
||||
ConversationAccess,
|
||||
KnowledgeBaseType,
|
||||
} from '../common';
|
||||
|
||||
export type {
|
||||
CompatibleJSONSchema,
|
||||
Conversation,
|
||||
|
@ -77,8 +80,6 @@ export type {
|
|||
ShortIdTable,
|
||||
} from '../common';
|
||||
|
||||
export { KnowledgeBaseType } from '../common';
|
||||
|
||||
export type { TelemetryEventTypeWithPayload } from './analytics';
|
||||
export { ObservabilityAIAssistantTelemetryEventType } from './analytics/telemetry_event_type';
|
||||
|
||||
|
|
|
@ -158,39 +158,6 @@ const updateConversationRoute = createObservabilityAIAssistantServerRoute({
|
|||
},
|
||||
});
|
||||
|
||||
const updateConversationTitle = createObservabilityAIAssistantServerRoute({
|
||||
endpoint: 'PUT /internal/observability_ai_assistant/conversation/{conversationId}/title',
|
||||
params: t.type({
|
||||
path: t.type({
|
||||
conversationId: t.string,
|
||||
}),
|
||||
body: t.type({
|
||||
title: t.string,
|
||||
}),
|
||||
}),
|
||||
security: {
|
||||
authz: {
|
||||
requiredPrivileges: ['ai_assistant'],
|
||||
},
|
||||
},
|
||||
handler: async (resources): Promise<Conversation> => {
|
||||
const { service, request, params } = resources;
|
||||
|
||||
const client = await service.getClient({ request });
|
||||
|
||||
if (!client) {
|
||||
throw notImplemented();
|
||||
}
|
||||
|
||||
const conversation = await client.setTitle({
|
||||
conversationId: params.path.conversationId,
|
||||
title: params.body.title,
|
||||
});
|
||||
|
||||
return Promise.resolve(conversation);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteConversationRoute = createObservabilityAIAssistantServerRoute({
|
||||
endpoint: 'DELETE /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
params: t.type({
|
||||
|
@ -216,12 +183,43 @@ const deleteConversationRoute = createObservabilityAIAssistantServerRoute({
|
|||
},
|
||||
});
|
||||
|
||||
const patchConversationRoute = createObservabilityAIAssistantServerRoute({
|
||||
endpoint: 'PATCH /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
params: t.type({
|
||||
path: t.type({
|
||||
conversationId: t.string,
|
||||
}),
|
||||
body: t.partial({
|
||||
public: t.boolean,
|
||||
}),
|
||||
}),
|
||||
security: {
|
||||
authz: {
|
||||
requiredPrivileges: ['ai_assistant'],
|
||||
},
|
||||
},
|
||||
handler: async (resources): Promise<Conversation> => {
|
||||
const { service, request, params } = resources;
|
||||
|
||||
const client = await service.getClient({ request });
|
||||
|
||||
if (!client) {
|
||||
throw notImplemented();
|
||||
}
|
||||
|
||||
return client.updatePartial({
|
||||
conversationId: params.path.conversationId,
|
||||
updates: params.body,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const conversationRoutes = {
|
||||
...getConversationRoute,
|
||||
...findConversationsRoute,
|
||||
...createConversationRoute,
|
||||
...updateConversationRoute,
|
||||
...updateConversationTitle,
|
||||
...deleteConversationRoute,
|
||||
...duplicateConversationRoute,
|
||||
...patchConversationRoute,
|
||||
};
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import type { SearchHit } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { notFound } from '@hapi/boom';
|
||||
import { notFound, forbidden } from '@hapi/boom';
|
||||
import type { ActionsClient } from '@kbn/actions-plugin/server';
|
||||
import type { CoreSetup, ElasticsearchClient, IUiSettingsClient } from '@kbn/core/server';
|
||||
import type { Logger } from '@kbn/logging';
|
||||
|
@ -140,7 +140,7 @@ export class ObservabilityAIAssistantClient {
|
|||
return false;
|
||||
}
|
||||
|
||||
return conversation.user.id
|
||||
return conversation.user.id && user.id
|
||||
? conversation.user.id === user.id
|
||||
: conversation.user.name === user.name;
|
||||
};
|
||||
|
@ -161,6 +161,10 @@ export class ObservabilityAIAssistantClient {
|
|||
throw notFound();
|
||||
}
|
||||
|
||||
if (!this.isConversationOwnedByUser(conversation._source!)) {
|
||||
throw forbidden('Deleting a conversation is only allowed for the owner of the conversation.');
|
||||
}
|
||||
|
||||
await this.dependencies.esClient.asInternalUser.delete({
|
||||
id: conversation._id!,
|
||||
index: conversation._index,
|
||||
|
@ -558,7 +562,7 @@ export class ObservabilityAIAssistantClient {
|
|||
}
|
||||
|
||||
if (!this.isConversationOwnedByUser(persistedConversation._source!)) {
|
||||
throw new Error('Cannot update conversation that is not owned by the user');
|
||||
throw forbidden('Updating a conversation is only allowed for the owner of the conversation.');
|
||||
}
|
||||
|
||||
const updatedConversation: Conversation = merge(
|
||||
|
@ -577,35 +581,6 @@ export class ObservabilityAIAssistantClient {
|
|||
return updatedConversation;
|
||||
};
|
||||
|
||||
setTitle = async ({ conversationId, title }: { conversationId: string; title: string }) => {
|
||||
const document = await this.getConversationWithMetaFields(conversationId);
|
||||
if (!document) {
|
||||
throw notFound();
|
||||
}
|
||||
|
||||
const conversation = await this.get(conversationId);
|
||||
|
||||
if (!conversation) {
|
||||
throw notFound();
|
||||
}
|
||||
|
||||
const updatedConversation: Conversation = merge(
|
||||
{},
|
||||
conversation,
|
||||
{ conversation: { title } },
|
||||
this.getConversationUpdateValues(new Date().toISOString())
|
||||
);
|
||||
|
||||
await this.dependencies.esClient.asInternalUser.update({
|
||||
id: document._id!,
|
||||
index: document._index,
|
||||
doc: { conversation: { title } },
|
||||
refresh: true,
|
||||
});
|
||||
|
||||
return updatedConversation;
|
||||
};
|
||||
|
||||
create = async (conversation: ConversationCreateRequest): Promise<Conversation> => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
|
@ -628,6 +603,25 @@ export class ObservabilityAIAssistantClient {
|
|||
return createdConversation;
|
||||
};
|
||||
|
||||
updatePartial = async ({
|
||||
conversationId,
|
||||
updates,
|
||||
}: {
|
||||
conversationId: string;
|
||||
updates: Partial<{ public: boolean }>;
|
||||
}): Promise<Conversation> => {
|
||||
const conversation = await this.get(conversationId);
|
||||
if (!conversation) {
|
||||
throw notFound();
|
||||
}
|
||||
|
||||
const updatedConversation: Conversation = merge({}, conversation, {
|
||||
...(updates.public !== undefined && { public: updates.public }),
|
||||
});
|
||||
|
||||
return this.update(conversationId, updatedConversation);
|
||||
};
|
||||
|
||||
duplicateConversation = async (conversationId: string): Promise<Conversation> => {
|
||||
const conversation = await this.getConversationWithMetaFields(conversationId);
|
||||
|
||||
|
|
|
@ -107,7 +107,9 @@ export class ObservabilityAIAssistantAppPlugin
|
|||
const appService = (this.appService = createAppService({
|
||||
pluginsStart,
|
||||
}));
|
||||
|
||||
const isEnabled = appService.isEnabled();
|
||||
|
||||
if (isEnabled) {
|
||||
coreStart.chrome.navControls.registerRight({
|
||||
mount: (element) => {
|
||||
|
|
|
@ -14,6 +14,7 @@ export function ConversationViewWithProps() {
|
|||
const { path } = useObservabilityAIAssistantParams('/conversations/*');
|
||||
const conversationId = 'conversationId' in path ? path.conversationId : undefined;
|
||||
const observabilityAIAssistantRouter = useObservabilityAIAssistantRouter();
|
||||
|
||||
function navigateToConversation(nextConversationId?: string) {
|
||||
if (nextConversationId) {
|
||||
observabilityAIAssistantRouter.push('/conversations/{conversationId}', {
|
||||
|
@ -26,6 +27,7 @@ export function ConversationViewWithProps() {
|
|||
observabilityAIAssistantRouter.push('/conversations/new', { path: {}, query: {} });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ConversationView
|
||||
conversationId={conversationId}
|
||||
|
|
|
@ -668,5 +668,161 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversation sharing', () => {
|
||||
let createdConversationId: string;
|
||||
const patchConversationRoute =
|
||||
'PATCH /internal/observability_ai_assistant/conversation/{conversationId}';
|
||||
|
||||
before(async () => {
|
||||
const { status, body } = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversation',
|
||||
params: {
|
||||
body: {
|
||||
conversation: conversationCreate,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).to.be(200);
|
||||
createdConversationId = body.conversation.id;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
const { status } = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'DELETE /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
params: {
|
||||
path: { conversationId: createdConversationId },
|
||||
},
|
||||
});
|
||||
expect(status).to.be(200);
|
||||
});
|
||||
|
||||
it('allows the owner to update the access of their conversation', async () => {
|
||||
const updateResponse = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: patchConversationRoute,
|
||||
params: {
|
||||
path: { conversationId: createdConversationId },
|
||||
body: { public: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateResponse.status).to.be(200);
|
||||
expect(updateResponse.body.public).to.be(true);
|
||||
});
|
||||
|
||||
it('does not allow a different user (admin) to update access of a conversation they do not own', async () => {
|
||||
const updateResponse = await observabilityAIAssistantAPIClient.admin({
|
||||
endpoint: patchConversationRoute,
|
||||
params: {
|
||||
path: { conversationId: createdConversationId },
|
||||
body: { public: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateResponse.status).to.be(403);
|
||||
});
|
||||
|
||||
it('returns 404 when updating access for a non-existing conversation', async () => {
|
||||
const updateResponse = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: patchConversationRoute,
|
||||
params: {
|
||||
path: { conversationId: 'non-existing-conversation-id' },
|
||||
body: { public: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateResponse.status).to.be(404);
|
||||
});
|
||||
|
||||
it('returns 400 for invalid access value', async () => {
|
||||
const { status } = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: patchConversationRoute,
|
||||
params: {
|
||||
path: { conversationId: createdConversationId },
|
||||
// @ts-expect-error
|
||||
body: { access: 'invalid_access' }, // Invalid value
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).to.be(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversation deletion', () => {
|
||||
let createdConversationId: string;
|
||||
const deleteConversationRoute =
|
||||
'DELETE /internal/observability_ai_assistant/conversation/{conversationId}';
|
||||
|
||||
before(async () => {
|
||||
// Create a conversation to delete
|
||||
const { status, body } = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversation',
|
||||
params: {
|
||||
body: {
|
||||
conversation: conversationCreate,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).to.be(200);
|
||||
createdConversationId = body.conversation.id;
|
||||
});
|
||||
|
||||
it('allows the owner to delete their conversation', async () => {
|
||||
const deleteResponse = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: deleteConversationRoute,
|
||||
params: {
|
||||
path: { conversationId: createdConversationId },
|
||||
},
|
||||
});
|
||||
|
||||
expect(deleteResponse.status).to.be(200);
|
||||
|
||||
// Ensure the conversation no longer exists
|
||||
const getResponse = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'GET /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
params: {
|
||||
path: { conversationId: createdConversationId },
|
||||
},
|
||||
});
|
||||
|
||||
expect(getResponse.status).to.be(404);
|
||||
});
|
||||
|
||||
it('does not allow a different user (admin) to delete a conversation they do not own', async () => {
|
||||
// Create another conversation (editor)
|
||||
const { body } = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversation',
|
||||
params: {
|
||||
body: {
|
||||
conversation: conversationCreate,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const unauthorizedConversationId = body.conversation.id;
|
||||
|
||||
// try deleting as an admin
|
||||
const deleteResponse = await observabilityAIAssistantAPIClient.admin({
|
||||
endpoint: deleteConversationRoute,
|
||||
params: {
|
||||
path: { conversationId: unauthorizedConversationId },
|
||||
},
|
||||
});
|
||||
|
||||
expect(deleteResponse.status).to.be(404);
|
||||
|
||||
// Ensure the owner can still delete the conversation
|
||||
const ownerDeleteResponse = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: deleteConversationRoute,
|
||||
params: {
|
||||
path: { conversationId: unauthorizedConversationId },
|
||||
},
|
||||
});
|
||||
|
||||
expect(ownerDeleteResponse.status).to.be(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../ftr_provider_context';
|
||||
|
||||
export async function deleteConversations(getService: FtrProviderContext['getService']) {
|
||||
const observabilityAIAssistantAPIClient = getService('observabilityAIAssistantAPIClient');
|
||||
|
||||
const response = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversations',
|
||||
});
|
||||
|
||||
for (const conversation of response.body.conversations) {
|
||||
await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: `DELETE /internal/observability_ai_assistant/conversation/{conversationId}`,
|
||||
params: {
|
||||
path: {
|
||||
conversationId: conversation.conversation.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -33,13 +33,20 @@ const pages = {
|
|||
tableAuthorCell: 'knowledgeBaseTableAuthorCell',
|
||||
},
|
||||
conversations: {
|
||||
setupGenAiConnectorsButtonSelector: `observabilityAiAssistantInitialSetupPanelSetUpGenerativeAiConnectorButton`,
|
||||
setupGenAiConnectorsButtonSelector:
|
||||
'observabilityAiAssistantInitialSetupPanelSetUpGenerativeAiConnectorButton',
|
||||
chatInput: 'observabilityAiAssistantChatPromptEditorTextArea',
|
||||
retryButton: 'observabilityAiAssistantWelcomeMessageSetUpKnowledgeBaseButton',
|
||||
conversationLink: 'observabilityAiAssistantConversationsLink',
|
||||
positiveFeedbackButton: 'observabilityAiAssistantFeedbackButtonsPositiveButton',
|
||||
connectorsErrorMsg: 'observabilityAiAssistantConnectorsError',
|
||||
conversationsPage: 'observabilityAiAssistantConversationsPage',
|
||||
access: {
|
||||
shareButton: 'observabilityAiAssistantChatAccessBadge',
|
||||
sharedOption: 'observabilityAiAssistantChatSharedOption',
|
||||
privateOption: 'observabilityAiAssistantChatPrivateOption',
|
||||
loadingBadge: 'observabilityAiAssistantChatAccessLoadingBadge',
|
||||
},
|
||||
},
|
||||
createConnectorFlyout: {
|
||||
flyout: 'create-connector-flyout',
|
||||
|
|
|
@ -19,6 +19,8 @@ import { interceptRequest } from '../../common/intercept_request';
|
|||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
import { editor } from '../../../observability_ai_assistant_api_integration/common/users/users';
|
||||
import { deleteConnectors } from '../../common/connectors';
|
||||
import { deleteConversations } from '../../common/conversations';
|
||||
|
||||
export default function ApiTest({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const observabilityAIAssistantAPIClient = getService('observabilityAIAssistantAPIClient');
|
||||
|
@ -53,36 +55,6 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte
|
|||
return parseCookie(response.headers['set-cookie'][0])!;
|
||||
}
|
||||
|
||||
async function deleteConversations() {
|
||||
const response = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversations',
|
||||
});
|
||||
|
||||
for (const conversation of response.body.conversations) {
|
||||
await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: `DELETE /internal/observability_ai_assistant/conversation/{conversationId}`,
|
||||
params: {
|
||||
path: {
|
||||
conversationId: conversation.conversation.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteConnectors() {
|
||||
const response = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'GET /internal/observability_ai_assistant/connectors',
|
||||
});
|
||||
|
||||
for (const connector of response.body) {
|
||||
await supertest
|
||||
.delete(`/api/actions/connector/${connector.id}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.expect(204);
|
||||
}
|
||||
}
|
||||
|
||||
async function createOldConversation() {
|
||||
const { password } = kbnTestConfig.getUrlParts();
|
||||
const sessionCookie = await login(editor.username, password);
|
||||
|
@ -154,8 +126,8 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte
|
|||
describe('Conversations', () => {
|
||||
let proxy: LlmProxy;
|
||||
before(async () => {
|
||||
await deleteConnectors();
|
||||
await deleteConversations();
|
||||
await deleteConnectors(supertest);
|
||||
await deleteConversations(getService);
|
||||
|
||||
await createOldConversation();
|
||||
|
||||
|
@ -389,7 +361,6 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte
|
|||
|
||||
describe('and opening an old conversation', () => {
|
||||
before(async () => {
|
||||
log.info('SQREN: Opening the old conversation');
|
||||
const conversations = await testSubjects.findAll(
|
||||
ui.pages.conversations.conversationLink
|
||||
);
|
||||
|
@ -408,7 +379,6 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte
|
|||
'And what are SLIs?'
|
||||
);
|
||||
await testSubjects.pressEnter(ui.pages.conversations.chatInput);
|
||||
log.info('SQREN: Waiting for the message to be displayed');
|
||||
|
||||
await proxy.waitForAllInterceptorsToHaveBeenCalled();
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
|
@ -418,7 +388,6 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte
|
|||
before(async () => {
|
||||
await telemetry.setOptIn(true);
|
||||
|
||||
log.info('SQREN: Clicking on the positive feedback button');
|
||||
const feedbackButtons = await testSubjects.findAll(
|
||||
ui.pages.conversations.positiveFeedbackButton
|
||||
);
|
||||
|
@ -457,8 +426,8 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte
|
|||
});
|
||||
|
||||
after(async () => {
|
||||
await deleteConnectors();
|
||||
await deleteConversations();
|
||||
await deleteConnectors(supertest);
|
||||
await deleteConversations(getService);
|
||||
|
||||
await ui.auth.logout();
|
||||
proxy.close();
|
||||
|
|
|
@ -0,0 +1,141 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import {
|
||||
createLlmProxy,
|
||||
LlmProxy,
|
||||
} from '../../../observability_ai_assistant_api_integration/common/create_llm_proxy';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
import { createConnector, deleteConnectors } from '../../common/connectors';
|
||||
import { deleteConversations } from '../../common/conversations';
|
||||
|
||||
export default function ApiTest({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const observabilityAIAssistantAPIClient = getService('observabilityAIAssistantAPIClient');
|
||||
const ui = getService('observabilityAIAssistantUI');
|
||||
const testSubjects = getService('testSubjects');
|
||||
const supertest = getService('supertest');
|
||||
const log = getService('log');
|
||||
const retry = getService('retry');
|
||||
|
||||
const { header } = getPageObjects(['header', 'security']);
|
||||
const PageObjects = getPageObjects(['common', 'error', 'navigationalSearch', 'security']);
|
||||
|
||||
const expectedTitle = 'My title';
|
||||
const expectedResponse = 'Hello from LLM Proxy';
|
||||
|
||||
async function createConversation(proxy: LlmProxy) {
|
||||
await PageObjects.common.navigateToUrl('obsAIAssistant', '', {
|
||||
ensureCurrentUrl: false,
|
||||
shouldLoginIfPrompted: false,
|
||||
shouldUseHashForSubUrl: false,
|
||||
});
|
||||
|
||||
void proxy.interceptTitle(expectedTitle);
|
||||
void proxy.interceptConversation(expectedResponse);
|
||||
|
||||
await testSubjects.setValue(ui.pages.conversations.chatInput, 'Hello');
|
||||
await testSubjects.pressEnter(ui.pages.conversations.chatInput);
|
||||
|
||||
await proxy.waitForAllInterceptorsToHaveBeenCalled();
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
}
|
||||
|
||||
describe('Conversation Sharing', () => {
|
||||
let proxy: LlmProxy;
|
||||
|
||||
before(async () => {
|
||||
proxy = await createLlmProxy(log);
|
||||
await ui.auth.login('editor');
|
||||
await ui.router.goto('/conversations/new', { path: {}, query: {} });
|
||||
|
||||
// cleanup previous connectors
|
||||
await deleteConnectors(supertest);
|
||||
// cleanup conversations
|
||||
await deleteConversations(getService);
|
||||
|
||||
// create connector
|
||||
await createConnector(proxy, supertest);
|
||||
// Ensure a conversation is created before testing sharing
|
||||
await createConversation(proxy);
|
||||
});
|
||||
|
||||
describe('Conversation Sharing Menu', () => {
|
||||
it('should display the share button (badge)', async () => {
|
||||
await testSubjects.existOrFail(ui.pages.conversations.access.shareButton);
|
||||
});
|
||||
|
||||
it('should open the sharing menu on click', async () => {
|
||||
await testSubjects.click(ui.pages.conversations.access.shareButton);
|
||||
await testSubjects.existOrFail(ui.pages.conversations.access.sharedOption);
|
||||
await testSubjects.existOrFail(ui.pages.conversations.access.privateOption);
|
||||
});
|
||||
|
||||
describe('when changing access to Shared', () => {
|
||||
before(async () => {
|
||||
await testSubjects.click(ui.pages.conversations.access.sharedOption);
|
||||
await testSubjects.existOrFail(ui.pages.conversations.access.loadingBadge);
|
||||
await testSubjects.missingOrFail(ui.pages.conversations.access.loadingBadge);
|
||||
});
|
||||
|
||||
it('should update the badge to "Shared"', async () => {
|
||||
await retry.try(async () => {
|
||||
const badgeText = await testSubjects.getVisibleText(
|
||||
ui.pages.conversations.access.shareButton
|
||||
);
|
||||
expect(badgeText).to.contain('Shared');
|
||||
});
|
||||
});
|
||||
|
||||
it('should persist the change in the backend', async () => {
|
||||
await retry.try(async () => {
|
||||
const response = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversations',
|
||||
});
|
||||
const conversation = response.body.conversations.pop();
|
||||
expect(conversation?.public).to.eql(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changing access to Private', () => {
|
||||
before(async () => {
|
||||
await testSubjects.click(ui.pages.conversations.access.shareButton);
|
||||
await testSubjects.click(ui.pages.conversations.access.privateOption);
|
||||
await testSubjects.existOrFail(ui.pages.conversations.access.loadingBadge);
|
||||
await testSubjects.missingOrFail(ui.pages.conversations.access.loadingBadge);
|
||||
});
|
||||
|
||||
it('should update the badge to "Private"', async () => {
|
||||
await retry.try(async () => {
|
||||
const badgeText = await testSubjects.getVisibleText(
|
||||
ui.pages.conversations.access.shareButton
|
||||
);
|
||||
expect(badgeText).to.contain('Private');
|
||||
});
|
||||
});
|
||||
|
||||
it('should persist the change in the backend', async () => {
|
||||
await retry.try(async () => {
|
||||
const response = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversations',
|
||||
});
|
||||
const conversation = response.body.conversations.pop();
|
||||
expect(conversation?.public).to.eql(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await deleteConnectors(supertest);
|
||||
await deleteConversations(getService);
|
||||
await ui.auth.logout();
|
||||
proxy.close();
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue