[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:
Viduni Wickramarachchi 2025-03-11 10:27:40 -04:00 committed by GitHub
parent 2fd0bea441
commit 9c0e4b0bfb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1948 additions and 468 deletions

View file

@ -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();
},
},
],
},
{

View file

@ -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>
);
}

View file

@ -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 cant 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>

View file

@ -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();
});
});

View file

@ -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}
</>
);
}

View file

@ -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>

View file

@ -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>
);

View file

@ -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}

View file

@ -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()
);
});
});

View file

@ -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>
);
}

View file

@ -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', () => {

View file

@ -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();
}
});
});
},
}}

View file

@ -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>
);
}

View file

@ -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}
/>

View file

@ -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';

View file

@ -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,
};
}

View file

@ -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',
});
});
});

View file

@ -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,
};
}

View file

@ -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);
});
});

View file

@ -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,
};
}

View file

@ -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,
};
};

View file

@ -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) {

View file

@ -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;
};

View file

@ -41,5 +41,6 @@
"@kbn/storybook",
"@kbn/ai-assistant-icon",
"@kbn/datemath",
"@kbn/security-plugin-types-common",
]
}

View file

@ -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",

View file

@ -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": "関数を選択",

View file

@ -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": "选择函数",

View file

@ -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 {

View file

@ -145,3 +145,8 @@ export interface ObservabilityAIAssistantScreenContext {
actions?: Array<ScreenContextActionDefinition<any>>;
starterPrompts?: StarterPrompt[];
}
export enum ConversationAccess {
SHARED = 'shared',
PRIVATE = 'private',
}

View file

@ -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';

View file

@ -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,
};

View file

@ -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);

View file

@ -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) => {

View file

@ -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}

View file

@ -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);
});
});
});
}

View file

@ -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,
},
},
});
}
}

View file

@ -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',

View file

@ -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();

View file

@ -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();
});
});
}