mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Obs AI Assistant] Archiving conversations (#216012)
Closes https://github.com/elastic/kibana/issues/209386 ## Summary 1. The option to archive conversations are enabled via the conversation contextual menu. 2. Archived conversations can be viewed under the "Archived" section of the conversation list. 3. Only the owner of the conversation can archive and unarchive. 4. Once archived, the conversation cannot be continued until unarchived. 5. If the archived conversation is shared, other users (who are not the owner) can duplicate the conversation, if they wish to continue the conversation. 6. The archived section of the conversation list is collapsed by default. 7. Updating the conversation such as title updates, regenerating, providing chat feedback are disabled for archived conversations ### 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] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
a5f3c0ad03
commit
3aa036d515
26 changed files with 976 additions and 319 deletions
|
@ -13,10 +13,12 @@ export function ChatBanner({
|
|||
title,
|
||||
description,
|
||||
button = null,
|
||||
icon = 'users',
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
button?: ReactNode;
|
||||
icon?: string;
|
||||
}) {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
|
@ -33,7 +35,7 @@ export function ChatBanner({
|
|||
>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon size="l" type="users" />
|
||||
<EuiIcon size="l" type={icon} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow>
|
||||
<EuiText size="xs">
|
||||
|
|
|
@ -54,6 +54,7 @@ import { useLicense } from '../hooks/use_license';
|
|||
import { PromptEditor } from '../prompt_editor/prompt_editor';
|
||||
import { useKibana } from '../hooks/use_kibana';
|
||||
import { ChatBanner } from './chat_banner';
|
||||
import { useConversationContextMenu } from '../hooks';
|
||||
|
||||
const fullHeightClassName = css`
|
||||
height: 100%;
|
||||
|
@ -229,6 +230,7 @@ export function ChatBody({
|
|||
namespace,
|
||||
public: isPublic,
|
||||
'@timestamp': timestamp,
|
||||
archived,
|
||||
} = conversation.value;
|
||||
|
||||
const conversationWithoutMessagesAndTitle: ChatFeedback['conversation'] = {
|
||||
|
@ -238,6 +240,7 @@ export function ChatBody({
|
|||
numeric_labels: numericLabels,
|
||||
namespace,
|
||||
public: isPublic,
|
||||
archived,
|
||||
conversation: { id, last_updated: lastUpdated },
|
||||
};
|
||||
|
||||
|
@ -364,27 +367,42 @@ export function ChatBody({
|
|||
refreshConversations();
|
||||
};
|
||||
|
||||
const { copyConversationToClipboard, copyUrl, deleteConversation, archiveConversation } =
|
||||
useConversationContextMenu({
|
||||
setIsUpdatingConversationList,
|
||||
refreshConversations,
|
||||
});
|
||||
|
||||
const handleArchiveConversation = async (id: string, isArchived: boolean) => {
|
||||
await archiveConversation(id, isArchived);
|
||||
conversation.refresh();
|
||||
};
|
||||
|
||||
const isPublic = conversation.value?.public;
|
||||
const showPromptEditor = !isPublic || isConversationOwnedByCurrentUser;
|
||||
const bannerTitle = i18n.translate('xpack.aiAssistant.shareBanner.title', {
|
||||
const isArchived = !!conversation.value?.archived;
|
||||
const showPromptEditor = !isArchived && (!isPublic || isConversationOwnedByCurrentUser);
|
||||
|
||||
const sharedBannerTitle = i18n.translate('xpack.aiAssistant.shareBanner.title', {
|
||||
defaultMessage: 'This conversation is shared with your team.',
|
||||
});
|
||||
const viewerDescription = i18n.translate('xpack.aiAssistant.banner.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.",
|
||||
});
|
||||
const duplicateButton = i18n.translate('xpack.aiAssistant.duplicateButton', {
|
||||
defaultMessage: 'Duplicate',
|
||||
});
|
||||
|
||||
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.",
|
||||
})}
|
||||
title={sharedBannerTitle}
|
||||
description={viewerDescription}
|
||||
button={
|
||||
<EuiButton onClick={duplicateConversation} iconType="copy" size="s">
|
||||
{i18n.translate('xpack.aiAssistant.duplicateButton', {
|
||||
defaultMessage: 'Duplicate',
|
||||
})}
|
||||
{duplicateButton}
|
||||
</EuiButton>
|
||||
}
|
||||
/>
|
||||
|
@ -392,7 +410,7 @@ export function ChatBody({
|
|||
} else if (isConversationOwnedByCurrentUser && isPublic) {
|
||||
sharedBanner = (
|
||||
<ChatBanner
|
||||
title={bannerTitle}
|
||||
title={sharedBannerTitle}
|
||||
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.',
|
||||
|
@ -401,6 +419,48 @@ export function ChatBody({
|
|||
);
|
||||
}
|
||||
|
||||
let archivedBanner = null;
|
||||
const archivedBannerTitle = i18n.translate('xpack.aiAssistant.archivedBanner.title', {
|
||||
defaultMessage: 'This conversation has been archived.',
|
||||
});
|
||||
|
||||
if (isConversationOwnedByCurrentUser) {
|
||||
archivedBanner = (
|
||||
<ChatBanner
|
||||
title={archivedBannerTitle}
|
||||
icon="folderOpen"
|
||||
description={i18n.translate('xpack.aiAssistant.archivedBanner.ownerDescription', {
|
||||
defaultMessage:
|
||||
"You can't edit or continue this conversation as it's been archived, but you can unarchive it.",
|
||||
})}
|
||||
button={
|
||||
<EuiButton
|
||||
onClick={() => handleArchiveConversation(conversationId!, !isArchived)}
|
||||
iconType="folderOpen"
|
||||
size="s"
|
||||
>
|
||||
{i18n.translate('xpack.aiAssistant.unarchiveButton', {
|
||||
defaultMessage: 'Unarchive',
|
||||
})}
|
||||
</EuiButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
archivedBanner = (
|
||||
<ChatBanner
|
||||
title={archivedBannerTitle}
|
||||
icon="folderOpen"
|
||||
description={viewerDescription}
|
||||
button={
|
||||
<EuiButton onClick={duplicateConversation} iconType="copy" size="s">
|
||||
{duplicateButton}
|
||||
</EuiButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let footer: React.ReactNode;
|
||||
if (!hasCorrectLicense && !initialConversationId) {
|
||||
footer = (
|
||||
|
@ -483,6 +543,7 @@ export function ChatBody({
|
|||
}
|
||||
onStopGenerating={stop}
|
||||
onActionClick={handleActionClick}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
)}
|
||||
</EuiPanel>
|
||||
|
@ -496,12 +557,13 @@ export function ChatBody({
|
|||
) : null}
|
||||
|
||||
<>
|
||||
{conversationId ? sharedBanner : null}
|
||||
{conversationId && !isArchived ? sharedBanner : null}
|
||||
{conversationId && isArchived ? archivedBanner : null}
|
||||
{showPromptEditor ? (
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
className={promptEditorClassname(euiTheme)}
|
||||
style={{ height: promptEditorHeight }}
|
||||
css={{ height: promptEditorHeight }}
|
||||
>
|
||||
<EuiHorizontalRule margin="none" />
|
||||
<EuiPanel
|
||||
|
@ -605,11 +667,13 @@ export function ChatBody({
|
|||
navigateToConversation={
|
||||
initialMessages?.length && !initialConversationId ? undefined : navigateToConversation
|
||||
}
|
||||
setIsUpdatingConversationList={setIsUpdatingConversationList}
|
||||
refreshConversations={refreshConversations}
|
||||
updateDisplayedConversation={updateDisplayedConversation}
|
||||
handleConversationAccessUpdate={handleConversationAccessUpdate}
|
||||
isConversationOwnedByCurrentUser={isConversationOwnedByCurrentUser}
|
||||
copyConversationToClipboard={copyConversationToClipboard}
|
||||
copyUrl={copyUrl}
|
||||
deleteConversation={deleteConversation}
|
||||
handleArchiveConversation={handleArchiveConversation}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
|
|
|
@ -22,16 +22,19 @@ describe('ChatContextMenu', () => {
|
|||
const onCopyUrlClick = jest.fn();
|
||||
const onDeleteClick = jest.fn();
|
||||
const onDuplicateConversationClick = jest.fn();
|
||||
const onArchiveConversation = jest.fn();
|
||||
|
||||
const renderComponent = (props = {}) =>
|
||||
render(
|
||||
<ChatContextMenu
|
||||
isConversationOwnedByCurrentUser={true}
|
||||
conversationTitle="Test Conversation"
|
||||
isArchived={false}
|
||||
onCopyToClipboardClick={onCopyToClipboardClick}
|
||||
onCopyUrlClick={onCopyUrlClick}
|
||||
onDeleteClick={onDeleteClick}
|
||||
onDuplicateConversationClick={onDuplicateConversationClick}
|
||||
onArchiveConversation={onArchiveConversation}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
@ -49,8 +52,7 @@ describe('ChatContextMenu', () => {
|
|||
|
||||
it('opens the popover on button click', () => {
|
||||
renderComponent();
|
||||
const button = screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon');
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon'));
|
||||
expect(screen.getByText('Copy to clipboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Duplicate')).toBeInTheDocument();
|
||||
});
|
||||
|
@ -76,6 +78,20 @@ describe('ChatContextMenu', () => {
|
|||
expect(onDuplicateConversationClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onArchiveConversation when Archive is clicked (not archived)', () => {
|
||||
renderComponent({ isArchived: false });
|
||||
fireEvent.click(screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon'));
|
||||
fireEvent.click(screen.getByText('Archive'));
|
||||
expect(onArchiveConversation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onArchiveConversation when Unarchive is clicked (already archived)', () => {
|
||||
renderComponent({ isArchived: true });
|
||||
fireEvent.click(screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon'));
|
||||
fireEvent.click(screen.getByText('Unarchive'));
|
||||
expect(onArchiveConversation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onDeleteClick when delete is confirmed', async () => {
|
||||
renderComponent();
|
||||
fireEvent.click(screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon'));
|
||||
|
@ -97,10 +113,12 @@ describe('ChatContextMenu', () => {
|
|||
await waitFor(() => expect(onDeleteClick).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('does not render delete option if isConversationOwnedByCurrentUser is false', () => {
|
||||
it('does not render delete or archive options if isConversationOwnedByCurrentUser is false', () => {
|
||||
renderComponent({ isConversationOwnedByCurrentUser: false });
|
||||
fireEvent.click(screen.getByTestId('observabilityAiAssistantChatContextMenuButtonIcon'));
|
||||
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Archive')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Unarchive')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables button when disabled prop is true', () => {
|
||||
|
|
|
@ -24,18 +24,22 @@ export function ChatContextMenu({
|
|||
disabled = false,
|
||||
isConversationOwnedByCurrentUser,
|
||||
conversationTitle,
|
||||
isArchived,
|
||||
onCopyToClipboardClick,
|
||||
onCopyUrlClick,
|
||||
onDeleteClick,
|
||||
onDuplicateConversationClick,
|
||||
onArchiveConversation,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
isConversationOwnedByCurrentUser: boolean;
|
||||
conversationTitle: string;
|
||||
isArchived: boolean;
|
||||
onCopyToClipboardClick: () => void;
|
||||
onCopyUrlClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onDuplicateConversationClick: () => void;
|
||||
onArchiveConversation: () => void;
|
||||
}) {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
|
@ -92,6 +96,29 @@ export function ChatContextMenu({
|
|||
];
|
||||
|
||||
if (isConversationOwnedByCurrentUser) {
|
||||
menuItems.push(
|
||||
<EuiContextMenuItem
|
||||
key="archive"
|
||||
icon="folderOpen"
|
||||
onClick={() => {
|
||||
onArchiveConversation();
|
||||
setIsPopoverOpen(false);
|
||||
}}
|
||||
data-test-subj={
|
||||
!isArchived
|
||||
? 'observabilityAiAssistantContextMenuArchive'
|
||||
: 'observabilityAiAssistantContextMenuUnarchive'
|
||||
}
|
||||
>
|
||||
{!isArchived
|
||||
? i18n.translate('xpack.aiAssistant.chatHeader.contextMenu.archiveConversation', {
|
||||
defaultMessage: 'Archive',
|
||||
})
|
||||
: i18n.translate('xpack.aiAssistant.chatHeader.contextMenu.unarchiveConversation', {
|
||||
defaultMessage: 'Unarchive',
|
||||
})}
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
menuItems.push(<EuiHorizontalRule key="seperator" margin="none" />);
|
||||
menuItems.push(
|
||||
<EuiContextMenuItem
|
||||
|
|
|
@ -310,7 +310,7 @@ export function ChatFlyout({
|
|||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem
|
||||
style={{
|
||||
css={{
|
||||
maxWidth: isSecondSlotVisible ? SIDEBAR_WIDTH : 0,
|
||||
paddingTop: '56px',
|
||||
}}
|
||||
|
|
|
@ -27,7 +27,6 @@ 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`
|
||||
|
@ -62,10 +61,12 @@ export function ChatHeader({
|
|||
onSaveTitle,
|
||||
onToggleFlyoutPositionMode,
|
||||
navigateToConversation,
|
||||
setIsUpdatingConversationList,
|
||||
refreshConversations,
|
||||
updateDisplayedConversation,
|
||||
handleConversationAccessUpdate,
|
||||
copyConversationToClipboard,
|
||||
copyUrl,
|
||||
deleteConversation,
|
||||
handleArchiveConversation,
|
||||
}: {
|
||||
connectors: UseGenAIConnectorsResult;
|
||||
conversationId?: string;
|
||||
|
@ -79,10 +80,12 @@ export function ChatHeader({
|
|||
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>;
|
||||
deleteConversation: (id: string) => Promise<void>;
|
||||
copyConversationToClipboard: (conversation: Conversation) => void;
|
||||
copyUrl: (id: string) => void;
|
||||
handleArchiveConversation: (id: string, isArchived: boolean) => Promise<void>;
|
||||
}) {
|
||||
const theme = useEuiTheme();
|
||||
const breakpoint = useCurrentEuiBreakpoint();
|
||||
|
@ -103,11 +106,6 @@ export function ChatHeader({
|
|||
}
|
||||
};
|
||||
|
||||
const { copyConversationToClipboard, copyUrl, deleteConversation } = useConversationContextMenu({
|
||||
setIsUpdatingConversationList,
|
||||
refreshConversations,
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiPanel
|
||||
borderRadius="none"
|
||||
|
@ -138,9 +136,11 @@ export function ChatHeader({
|
|||
size={breakpoint === 'xs' ? 'xs' : 's'}
|
||||
value={newTitle}
|
||||
className={css`
|
||||
color: ${!!title
|
||||
? theme.euiTheme.colors.textParagraph
|
||||
: theme.euiTheme.colors.textSubdued};
|
||||
.euiTitle {
|
||||
color: ${!conversation?.archived
|
||||
? theme.euiTheme.colors.textParagraph
|
||||
: theme.euiTheme.colors.textSubdued};
|
||||
}
|
||||
`}
|
||||
inputAriaLabel={i18n.translate(
|
||||
'xpack.aiAssistant.chatHeader.editConversationInput',
|
||||
|
@ -153,7 +153,8 @@ export function ChatHeader({
|
|||
!connectors.selectedConnector ||
|
||||
licenseInvalid ||
|
||||
!Boolean(onSaveTitle) ||
|
||||
!isConversationOwnedByCurrentUser
|
||||
!isConversationOwnedByCurrentUser ||
|
||||
conversation?.archived
|
||||
}
|
||||
onChange={(e) => {
|
||||
setNewTitle(e.currentTarget.nodeValue || '');
|
||||
|
@ -175,6 +176,7 @@ export function ChatHeader({
|
|||
<EuiFlexItem grow={false}>
|
||||
<ChatSharingMenu
|
||||
isPublic={conversation.public}
|
||||
isArchived={!!conversation.archived}
|
||||
onChangeConversationAccess={handleConversationAccessUpdate}
|
||||
disabled={licenseInvalid || !isConversationOwnedByCurrentUser}
|
||||
/>
|
||||
|
@ -190,6 +192,10 @@ export function ChatHeader({
|
|||
isConversationOwnedByCurrentUser={isConversationOwnedByCurrentUser}
|
||||
onDuplicateConversationClick={onDuplicateConversation}
|
||||
conversationTitle={conversation.conversation.title}
|
||||
onArchiveConversation={() =>
|
||||
handleArchiveConversation(conversationId, !conversation.archived)
|
||||
}
|
||||
isArchived={!!conversation.archived}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</>
|
||||
|
|
|
@ -17,6 +17,7 @@ describe('ChatSharingMenu', () => {
|
|||
render(
|
||||
<ChatSharingMenu
|
||||
isPublic={false}
|
||||
isArchived={false}
|
||||
disabled={false}
|
||||
onChangeConversationAccess={mockOnChangeConversationAccess}
|
||||
{...props}
|
||||
|
@ -108,4 +109,23 @@ describe('ChatSharingMenu', () => {
|
|||
).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('renders archived state with correct badges', () => {
|
||||
renderComponent({ isArchived: true });
|
||||
|
||||
const accessBadge = screen.getByTestId('observabilityAiAssistantChatAccessBadge');
|
||||
const archivedBadge = screen.getByTestId('observabilityAiAssistantArchivedBadge');
|
||||
|
||||
expect(accessBadge).toBeInTheDocument();
|
||||
expect(archivedBadge).toBeInTheDocument();
|
||||
expect(archivedBadge).toHaveTextContent('Archived');
|
||||
});
|
||||
|
||||
it('does not open the popover when archived', () => {
|
||||
renderComponent({ isArchived: true });
|
||||
|
||||
fireEvent.click(screen.getByTestId('observabilityAiAssistantChatAccessBadge'));
|
||||
|
||||
expect(screen.queryByText('This conversation is only visible to you.')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -22,6 +22,16 @@ import { i18n } from '@kbn/i18n';
|
|||
import { ConversationAccess } from '@kbn/observability-ai-assistant-plugin/public';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
const iconOnlyBadgeStyle = css`
|
||||
.euiBadge__icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
padding: 0px 6px;
|
||||
line-height: 1;
|
||||
`;
|
||||
|
||||
interface OptionData {
|
||||
description?: string;
|
||||
}
|
||||
|
@ -36,10 +46,12 @@ const sharedLabel = i18n.translate('xpack.aiAssistant.chatHeader.shareOptions.sh
|
|||
|
||||
export function ChatSharingMenu({
|
||||
isPublic,
|
||||
isArchived,
|
||||
disabled,
|
||||
onChangeConversationAccess,
|
||||
}: {
|
||||
isPublic: boolean;
|
||||
isArchived: boolean;
|
||||
disabled: boolean;
|
||||
onChangeConversationAccess: (access: ConversationAccess) => Promise<void>;
|
||||
}) {
|
||||
|
@ -123,6 +135,33 @@ export function ChatSharingMenu({
|
|||
);
|
||||
}
|
||||
|
||||
if (isArchived) {
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="xs" responsive={false} alignItems="center" wrap={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiBadge
|
||||
iconType={selectedValue === ConversationAccess.SHARED ? 'users' : 'lock'}
|
||||
color="hollow"
|
||||
className={iconOnlyBadgeStyle}
|
||||
data-test-subj="observabilityAiAssistantChatAccessBadge"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiBadge
|
||||
iconType="folderOpen"
|
||||
color="default"
|
||||
data-test-subj="observabilityAiAssistantArchivedBadge"
|
||||
>
|
||||
{i18n.translate('xpack.aiAssistant.chatHeader.archivedBadge', {
|
||||
defaultMessage: 'Archived',
|
||||
})}
|
||||
</EuiBadge>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<EuiBadge
|
||||
|
|
|
@ -53,6 +53,7 @@ export interface ChatTimelineProps {
|
|||
hasConnector: boolean;
|
||||
chatState: ChatState;
|
||||
isConversationOwnedByCurrentUser: boolean;
|
||||
isArchived: boolean;
|
||||
currentUser?: Pick<AuthenticatedUser, 'full_name' | 'username'>;
|
||||
onEdit: (message: Message, messageAfterEdit: Message) => void;
|
||||
onFeedback: (feedback: Feedback) => void;
|
||||
|
@ -75,6 +76,7 @@ export function ChatTimeline({
|
|||
hasConnector,
|
||||
currentUser,
|
||||
isConversationOwnedByCurrentUser,
|
||||
isArchived,
|
||||
onEdit,
|
||||
onFeedback,
|
||||
onRegenerate,
|
||||
|
@ -93,6 +95,7 @@ export function ChatTimeline({
|
|||
isConversationOwnedByCurrentUser,
|
||||
chatState,
|
||||
onActionClick,
|
||||
isArchived,
|
||||
});
|
||||
|
||||
const consolidatedChatItems: Array<ChatTimelineItem | ChatTimelineItem[]> = [];
|
||||
|
@ -123,6 +126,7 @@ export function ChatTimeline({
|
|||
currentUser,
|
||||
chatState,
|
||||
isConversationOwnedByCurrentUser,
|
||||
isArchived,
|
||||
onActionClick,
|
||||
]);
|
||||
|
||||
|
|
|
@ -116,50 +116,83 @@ describe('ConversationList', () => {
|
|||
(useConversationsByDate as jest.Mock).mockReturnValue(mockCategorizedConversations);
|
||||
});
|
||||
|
||||
it('renders the component without errors', () => {
|
||||
it('renders conversations and archived sections properly', () => {
|
||||
render(<ConversationList {...defaultProps} />);
|
||||
|
||||
const todayCategoryLabel = screen.getByText(/today/i, {
|
||||
selector: 'div.euiText',
|
||||
});
|
||||
expect(todayCategoryLabel).toBeInTheDocument();
|
||||
|
||||
const yesterdayCategoryLabel = screen.getByText(/yesterday/i, {
|
||||
selector: 'div.euiText',
|
||||
});
|
||||
expect(yesterdayCategoryLabel).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
i18n.translate('xpack.aiAssistant.conversationList.errorMessage', {
|
||||
defaultMessage: 'Failed to load',
|
||||
screen.getByText(
|
||||
i18n.translate('xpack.aiAssistant.conversationList.conversationsTitle', {
|
||||
defaultMessage: 'Conversations',
|
||||
})
|
||||
)
|
||||
).not.toBeInTheDocument();
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByText(
|
||||
i18n.translate('xpack.aiAssistant.conversationList.noConversations', {
|
||||
defaultMessage: 'No conversations',
|
||||
screen.getByText(
|
||||
i18n.translate('xpack.aiAssistant.conversationList.archivedTitle', {
|
||||
defaultMessage: 'Archived',
|
||||
})
|
||||
)
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId('observabilityAiAssistantNewChatButton')).toBeInTheDocument();
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays loading state', () => {
|
||||
render(<ConversationList {...defaultProps} isLoading={true} />);
|
||||
it('toggles conversations and archived sections correctly', async () => {
|
||||
render(<ConversationList {...defaultProps} />);
|
||||
const conversationsHeader = screen.getByText(/Conversations/i);
|
||||
const archivedHeader = screen.getByText(/Archived/i);
|
||||
|
||||
fireEvent.click(archivedHeader);
|
||||
expect(
|
||||
screen.getByText(
|
||||
i18n.translate('xpack.aiAssistant.conversationList.archivedTitle', {
|
||||
defaultMessage: 'Archived',
|
||||
})
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(conversationsHeader);
|
||||
expect(
|
||||
screen.getByText(
|
||||
i18n.translate('xpack.aiAssistant.conversationList.conversationsTitle', {
|
||||
defaultMessage: 'Conversations',
|
||||
})
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders categorized conversations inside collapsible sections', () => {
|
||||
render(<ConversationList {...defaultProps} />);
|
||||
const todayLabels = screen.getAllByText(DATE_CATEGORY_LABELS.TODAY);
|
||||
expect(todayLabels.length).toBeGreaterThan(0);
|
||||
|
||||
const conversationLinks = screen.getAllByText("Today's Conversation");
|
||||
expect(conversationLinks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calls onConversationSelect when a conversation is clicked', () => {
|
||||
render(<ConversationList {...defaultProps} />);
|
||||
const conversationLinks = screen.getAllByText("Today's Conversation");
|
||||
fireEvent.click(conversationLinks[0]);
|
||||
expect(defaultProps.onConversationSelect).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('displays loading state when isLoading is true and no conversations', () => {
|
||||
render(
|
||||
<ConversationList
|
||||
{...defaultProps}
|
||||
isLoading={true}
|
||||
conversations={{ ...mockConversations, value: { conversations: [] } }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays error state', () => {
|
||||
const errorProps = {
|
||||
...defaultProps,
|
||||
conversations: { ...mockConversations, error: new Error('An error occurred') },
|
||||
};
|
||||
render(<ConversationList {...errorProps} />);
|
||||
it('displays error state when error is set', () => {
|
||||
render(
|
||||
<ConversationList
|
||||
{...defaultProps}
|
||||
conversations={{ ...mockConversations, error: new Error('An error occurred') }}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
i18n.translate('xpack.aiAssistant.conversationList.errorMessage', {
|
||||
|
@ -169,28 +202,24 @@ describe('ConversationList', () => {
|
|||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders categorized conversations', () => {
|
||||
render(<ConversationList {...defaultProps} />);
|
||||
Object.entries(mockCategorizedConversations).forEach(([category, conversationList]) => {
|
||||
if (conversationList.length > 0) {
|
||||
expect(screen.getByText(DATE_CATEGORY_LABELS[category])).toBeInTheDocument();
|
||||
conversationList.forEach((conversation) => {
|
||||
expect(screen.getByText(conversation.label)).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
});
|
||||
it('renders "no conversations" message when conversation list is empty', () => {
|
||||
render(
|
||||
<ConversationList
|
||||
{...defaultProps}
|
||||
conversations={{ ...mockConversations, value: { conversations: [] } }}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
i18n.translate('xpack.aiAssistant.conversationList.noConversations', {
|
||||
defaultMessage: 'No conversations',
|
||||
})
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onConversationSelect when a conversation is clicked', () => {
|
||||
render(<ConversationList {...defaultProps} />);
|
||||
const todayConversation = screen.getByText("Today's Conversation");
|
||||
fireEvent.click(todayConversation);
|
||||
expect(defaultProps.onConversationSelect).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('calls delete conversation when delete icon is clicked', async () => {
|
||||
it('triggers delete flow when delete icon is clicked and confirmed', async () => {
|
||||
const mockDeleteConversation = jest.fn(() => Promise.resolve());
|
||||
|
||||
(useConversationContextMenu as jest.Mock).mockReturnValue({
|
||||
deleteConversation: mockDeleteConversation,
|
||||
});
|
||||
|
@ -198,27 +227,39 @@ describe('ConversationList', () => {
|
|||
render(<ConversationList {...defaultProps} />);
|
||||
fireEvent.click(screen.getAllByLabelText('Delete')[0]);
|
||||
|
||||
await waitFor(() => expect(mockDeleteConversation).toHaveBeenCalledTimes(1));
|
||||
expect(mockDeleteConversation).toHaveBeenCalledWith('1');
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteConversation).toHaveBeenCalledWith('1');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a new chat button and triggers onConversationSelect when clicked', () => {
|
||||
it('renders new chat button and triggers onConversationSelect', () => {
|
||||
render(<ConversationList {...defaultProps} />);
|
||||
const newChatButton = screen.getByTestId('observabilityAiAssistantNewChatButton');
|
||||
fireEvent.click(newChatButton);
|
||||
const newChatBtn = screen.getByTestId('observabilityAiAssistantNewChatButton');
|
||||
fireEvent.click(newChatBtn);
|
||||
expect(defaultProps.onConversationSelect).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('renders "no conversations" message when there are no conversations', () => {
|
||||
const emptyProps = {
|
||||
...defaultProps,
|
||||
conversations: { ...mockConversations, value: { conversations: [] } },
|
||||
it('defaults to archived section open if selected conversation is archived', () => {
|
||||
const archivedConversation = {
|
||||
...mockConversations.value!.conversations[0],
|
||||
archived: true,
|
||||
};
|
||||
render(<ConversationList {...emptyProps} />);
|
||||
|
||||
render(
|
||||
<ConversationList
|
||||
{...defaultProps}
|
||||
selectedConversationId="1"
|
||||
conversations={{
|
||||
...mockConversations,
|
||||
value: { conversations: [archivedConversation] },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
i18n.translate('xpack.aiAssistant.conversationList.noConversations', {
|
||||
defaultMessage: 'No conversations',
|
||||
i18n.translate('xpack.aiAssistant.conversationList.archivedTitle', {
|
||||
defaultMessage: 'Archived',
|
||||
})
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
EuiCollapsibleNavGroup,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiIcon,
|
||||
|
@ -16,11 +17,12 @@ import {
|
|||
euiScrollBarStyles,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
UseEuiTheme,
|
||||
useEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
import { css } from '@emotion/css';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React, { MouseEvent } from 'react';
|
||||
import React, { MouseEvent, useEffect, useMemo, useState } from 'react';
|
||||
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
|
||||
import type { UseConversationListResult } from '../hooks/use_conversation_list';
|
||||
import { useConfirmModal, useConversationsByDate, useConversationContextMenu } from '../hooks';
|
||||
|
@ -29,13 +31,21 @@ import { NewChatButton } from '../buttons/new_chat_button';
|
|||
import { ConversationListItemLabel } from './conversation_list_item_label';
|
||||
import { isConversationOwnedByUser } from '../utils/is_conversation_owned_by_current_user';
|
||||
|
||||
enum ListSections {
|
||||
CONVERSATIONS = 'conversations',
|
||||
ARCHIVED = 'archived',
|
||||
}
|
||||
|
||||
const panelClassName = css`
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 56px;
|
||||
`;
|
||||
|
||||
const overflowScrollClassName = (scrollBarStyles: string) => css`
|
||||
const scrollSectionClass = (scrollBarStyles: string) => css`
|
||||
overflow-y: auto;
|
||||
max-height: ${Math.floor(window.innerHeight * 0.7)}px;
|
||||
${scrollBarStyles}
|
||||
`;
|
||||
|
||||
|
@ -43,6 +53,16 @@ const newChatButtonWrapperClassName = css`
|
|||
padding-bottom: 5px;
|
||||
`;
|
||||
|
||||
const titleClassName = css`
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
`;
|
||||
|
||||
const containerClassName = (theme: UseEuiTheme) => css`
|
||||
height: 100%;
|
||||
border-top: solid 1px ${theme.euiTheme.border.color};
|
||||
`;
|
||||
|
||||
export function ConversationList({
|
||||
conversations,
|
||||
isLoading,
|
||||
|
@ -69,16 +89,32 @@ export function ConversationList({
|
|||
const euiTheme = useEuiTheme();
|
||||
const scrollBarStyles = euiScrollBarStyles(euiTheme);
|
||||
|
||||
const containerClassName = css`
|
||||
height: 100%;
|
||||
border-top: solid 1px ${euiTheme.euiTheme.border.color};
|
||||
padding: ${euiTheme.euiTheme.size.s};
|
||||
`;
|
||||
const [allConversations, activeConversations, archivedConversations] = useMemo(() => {
|
||||
const conversationList = conversations.value?.conversations ?? [];
|
||||
|
||||
const titleClassName = css`
|
||||
text-transform: uppercase;
|
||||
font-weight: ${euiTheme.euiTheme.font.weight.bold};
|
||||
`;
|
||||
return [
|
||||
conversationList,
|
||||
conversationList.filter((c) => !c.archived),
|
||||
conversationList.filter((c) => c.archived),
|
||||
];
|
||||
}, [conversations.value?.conversations]);
|
||||
|
||||
const selectedConversation = useMemo(
|
||||
() => allConversations.find((c) => c.conversation.id === selectedConversationId),
|
||||
[allConversations, selectedConversationId]
|
||||
);
|
||||
|
||||
const categorizedActiveConversations = useConversationsByDate(
|
||||
activeConversations,
|
||||
getConversationHref
|
||||
);
|
||||
|
||||
const categorizedArchivedConversations = useConversationsByDate(
|
||||
archivedConversations,
|
||||
getConversationHref
|
||||
);
|
||||
|
||||
const [openSection, setOpenSection] = useState<ListSections>(ListSections.CONVERSATIONS);
|
||||
|
||||
const { element: confirmDeleteElement, confirm: confirmDeleteCallback } = useConfirmModal({
|
||||
title: i18n.translate('xpack.aiAssistant.flyout.confirmDeleteConversationTitle', {
|
||||
|
@ -92,12 +128,6 @@ export function ConversationList({
|
|||
}),
|
||||
});
|
||||
|
||||
// Categorize conversations by date
|
||||
const conversationsCategorizedByDate = useConversationsByDate(
|
||||
conversations.value?.conversations,
|
||||
getConversationHref
|
||||
);
|
||||
|
||||
const onClickConversation = (
|
||||
e: MouseEvent<HTMLButtonElement> | MouseEvent<HTMLAnchorElement>,
|
||||
conversationId?: string
|
||||
|
@ -113,125 +143,192 @@ export function ConversationList({
|
|||
refreshConversations,
|
||||
});
|
||||
|
||||
const renderCategorizedList = (
|
||||
categorizedConversations: ReturnType<typeof useConversationsByDate>
|
||||
) => {
|
||||
return Object.entries(categorizedConversations).map(([category, list]) =>
|
||||
list.length ? (
|
||||
<EuiFlexItem grow={false} key={category}>
|
||||
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
|
||||
<EuiText className={titleClassName} size="s">
|
||||
{DATE_CATEGORY_LABELS[category]}
|
||||
</EuiText>
|
||||
</EuiPanel>
|
||||
<EuiListGroup flush={false} gutterSize="none">
|
||||
{list.map((conversation) => (
|
||||
<EuiListGroupItem
|
||||
data-test-subj="observabilityAiAssistantConversationsLink"
|
||||
key={conversation.id}
|
||||
label={
|
||||
<ConversationListItemLabel
|
||||
labelText={conversation.label}
|
||||
isPublic={conversation.public}
|
||||
/>
|
||||
}
|
||||
size="s"
|
||||
isActive={conversation.id === selectedConversationId}
|
||||
isDisabled={isLoading}
|
||||
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('xpack.aiAssistant.flyout.confirmDeleteCheckboxLabel', {
|
||||
defaultMessage: 'Delete "{title}"',
|
||||
values: { title: conversation.label },
|
||||
})
|
||||
).then((confirmed) => {
|
||||
if (!confirmed) return;
|
||||
deleteConversation(conversation.id).then(() => {
|
||||
if (conversation.id === selectedConversationId) {
|
||||
updateDisplayedConversation();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</EuiListGroup>
|
||||
<EuiSpacer size="s" />
|
||||
</EuiFlexItem>
|
||||
) : null
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedConversation?.archived) {
|
||||
setOpenSection(ListSections.ARCHIVED);
|
||||
} else {
|
||||
setOpenSection(ListSections.CONVERSATIONS);
|
||||
}
|
||||
}, [selectedConversation]);
|
||||
|
||||
const loader = (
|
||||
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
|
||||
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLoadingSpinner size="s" />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiPanel paddingSize="none" hasShadow={false} className={panelClassName}>
|
||||
<EuiFlexGroup direction="column" gutterSize="none" className={containerClassName}>
|
||||
<EuiFlexItem grow className={overflowScrollClassName(scrollBarStyles)}>
|
||||
<EuiFlexGroup direction="column" gutterSize="xs">
|
||||
{isLoading ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
|
||||
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLoadingSpinner size="s" />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
|
||||
{conversations.error ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
|
||||
<EuiFlexGroup direction="row" alignItems="center" gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="warning" color="danger" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="s" color="danger">
|
||||
{i18n.translate('xpack.aiAssistant.conversationList.errorMessage', {
|
||||
defaultMessage: 'Failed to load',
|
||||
})}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
|
||||
{/* Render conversations categorized by date */}
|
||||
{Object.entries(conversationsCategorizedByDate).map(([category, conversationList]) =>
|
||||
conversationList.length ? (
|
||||
<EuiFlexItem grow={false} key={category}>
|
||||
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
|
||||
<EuiText className={titleClassName} size="s">
|
||||
{DATE_CATEGORY_LABELS[category]}
|
||||
</EuiText>
|
||||
</EuiPanel>
|
||||
<EuiListGroup flush={false} gutterSize="none">
|
||||
{conversationList.map((conversation) => (
|
||||
<EuiListGroupItem
|
||||
data-test-subj="observabilityAiAssistantConversationsLink"
|
||||
key={conversation.id}
|
||||
label={
|
||||
<ConversationListItemLabel
|
||||
labelText={conversation.label}
|
||||
isPublic={conversation.public}
|
||||
/>
|
||||
}
|
||||
size="s"
|
||||
isActive={conversation.id === selectedConversationId}
|
||||
isDisabled={isLoading}
|
||||
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(
|
||||
'xpack.aiAssistant.flyout.confirmDeleteCheckboxLabel',
|
||||
{
|
||||
defaultMessage: 'Delete "{title}"',
|
||||
values: { title: conversation.label },
|
||||
}
|
||||
)
|
||||
).then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteConversation(conversation.id).then(() => {
|
||||
if (conversation.id === selectedConversationId) {
|
||||
updateDisplayedConversation();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</EuiListGroup>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiFlexGroup direction="column" gutterSize="none" className={containerClassName(euiTheme)}>
|
||||
{isLoading && !allConversations.length ? (
|
||||
<EuiFlexItem grow={true}>
|
||||
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="m">
|
||||
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLoadingSpinner size="s" />
|
||||
</EuiFlexItem>
|
||||
) : null
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !conversations.error && !conversations.value?.conversations?.length ? (
|
||||
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
|
||||
<EuiText color="subdued" size="s">
|
||||
{i18n.translate('xpack.aiAssistant.conversationList.noConversations', {
|
||||
defaultMessage: 'No conversations',
|
||||
})}
|
||||
</EuiText>
|
||||
</EuiPanel>
|
||||
) : null}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
{conversations.error ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="m">
|
||||
<EuiFlexGroup direction="row" alignItems="center" gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="warning" color="danger" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="s" color="danger">
|
||||
{i18n.translate('xpack.aiAssistant.conversationList.errorMessage', {
|
||||
defaultMessage: 'Failed to load',
|
||||
})}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !conversations.error && !allConversations?.length ? (
|
||||
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="m">
|
||||
<EuiText color="subdued" size="s">
|
||||
{i18n.translate('xpack.aiAssistant.conversationList.noConversations', {
|
||||
defaultMessage: 'No conversations',
|
||||
})}
|
||||
</EuiText>
|
||||
</EuiPanel>
|
||||
) : null}
|
||||
|
||||
{!conversations.error && allConversations?.length ? (
|
||||
<>
|
||||
<EuiFlexItem grow>
|
||||
<EuiFlexGroup direction="column" gutterSize="none">
|
||||
<EuiFlexItem grow={openSection === ListSections.CONVERSATIONS}>
|
||||
<EuiCollapsibleNavGroup
|
||||
isCollapsible
|
||||
title={i18n.translate(
|
||||
'xpack.aiAssistant.conversationList.conversationsTitle',
|
||||
{
|
||||
defaultMessage: 'Conversations',
|
||||
}
|
||||
)}
|
||||
titleSize="xs"
|
||||
iconType="list"
|
||||
iconSize="m"
|
||||
onToggle={(isOpen) =>
|
||||
setOpenSection(isOpen ? ListSections.CONVERSATIONS : ListSections.ARCHIVED)
|
||||
}
|
||||
forceState={openSection === ListSections.CONVERSATIONS ? 'open' : 'closed'}
|
||||
>
|
||||
<div className={scrollSectionClass(scrollBarStyles)}>
|
||||
{isLoading ? loader : null}
|
||||
<EuiFlexGroup direction="column" gutterSize="xs">
|
||||
{renderCategorizedList(categorizedActiveConversations)}
|
||||
</EuiFlexGroup>
|
||||
</div>
|
||||
</EuiCollapsibleNavGroup>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={openSection === ListSections.ARCHIVED}>
|
||||
<EuiCollapsibleNavGroup
|
||||
isCollapsible
|
||||
title={i18n.translate('xpack.aiAssistant.conversationList.archivedTitle', {
|
||||
defaultMessage: 'Archived',
|
||||
})}
|
||||
titleSize="xs"
|
||||
iconType="folderOpen"
|
||||
iconSize="m"
|
||||
onToggle={(isOpen) =>
|
||||
setOpenSection(isOpen ? ListSections.ARCHIVED : ListSections.CONVERSATIONS)
|
||||
}
|
||||
forceState={openSection === ListSections.ARCHIVED ? 'open' : 'closed'}
|
||||
borders="horizontal"
|
||||
>
|
||||
<div className={scrollSectionClass(scrollBarStyles)}>
|
||||
{isLoading ? loader : null}
|
||||
<EuiFlexGroup direction="column" gutterSize="xs">
|
||||
{renderCategorizedList(categorizedArchivedConversations)}
|
||||
</EuiFlexGroup>
|
||||
</div>
|
||||
</EuiCollapsibleNavGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiPanel paddingSize="s" hasBorder={false} hasShadow={false}>
|
||||
|
|
|
@ -32,14 +32,10 @@ const mockHttp = {
|
|||
};
|
||||
|
||||
const useKibanaMockServices = {
|
||||
uiSettings: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
uiSettings: { get: jest.fn() },
|
||||
notifications: mockNotifications,
|
||||
http: mockHttp,
|
||||
observabilityAIAssistant: {
|
||||
service: mockService,
|
||||
},
|
||||
observabilityAIAssistant: { service: mockService },
|
||||
};
|
||||
|
||||
describe('useConversationContextMenu', () => {
|
||||
|
@ -104,6 +100,7 @@ describe('useConversationContextMenu', () => {
|
|||
|
||||
expect(mockNotifications.toasts.addError).toHaveBeenCalledWith(expect.any(Error), {
|
||||
title: 'Could not delete conversation',
|
||||
toastMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -181,4 +178,72 @@ describe('useConversationContextMenu', () => {
|
|||
title: 'Could not copy conversation URL',
|
||||
});
|
||||
});
|
||||
|
||||
it('archives a conversation successfully', async () => {
|
||||
const archivedConversation = { conversation: { id: '1' }, archived: true };
|
||||
mockService.callApi.mockResolvedValueOnce(archivedConversation);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useConversationContextMenu({ setIsUpdatingConversationList, refreshConversations }),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const resultValue = await result.current.archiveConversation('1', true);
|
||||
expect(resultValue).toEqual(archivedConversation);
|
||||
});
|
||||
|
||||
expect(setIsUpdatingConversationList).toHaveBeenCalledWith(true);
|
||||
expect(mockService.callApi).toHaveBeenCalledWith(
|
||||
'PATCH /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
{
|
||||
signal: null,
|
||||
params: {
|
||||
path: { conversationId: '1' },
|
||||
body: { archived: true },
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(mockNotifications.toasts.addSuccess).toHaveBeenCalledWith({
|
||||
title: 'Conversation archived successfully',
|
||||
});
|
||||
expect(setIsUpdatingConversationList).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('handles errors when archiving a conversation', async () => {
|
||||
mockService.callApi.mockRejectedValueOnce(new Error('Archive failed'));
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useConversationContextMenu({ setIsUpdatingConversationList, refreshConversations }),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.archiveConversation('1', true)).rejects.toThrow('Archive failed');
|
||||
});
|
||||
|
||||
expect(mockNotifications.toasts.addError).toHaveBeenCalledWith(expect.any(Error), {
|
||||
title: 'Could not archive conversation',
|
||||
});
|
||||
expect(setIsUpdatingConversationList).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('unarchives a conversation successfully', async () => {
|
||||
const unarchivedConversation = { conversation: { id: '1' }, archived: false };
|
||||
mockService.callApi.mockResolvedValueOnce(unarchivedConversation);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useConversationContextMenu({ setIsUpdatingConversationList, refreshConversations }),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const resultValue = await result.current.archiveConversation('1', false);
|
||||
expect(resultValue).toEqual(unarchivedConversation);
|
||||
});
|
||||
|
||||
expect(mockNotifications.toasts.addSuccess).toHaveBeenCalledWith({
|
||||
title: 'Conversation unarchived successfully',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -19,6 +19,7 @@ export interface UseConversationContextMenuResult {
|
|||
deleteConversation: (id: string) => Promise<void>;
|
||||
copyConversationToClipboard: (conversation: Conversation) => void;
|
||||
copyUrl: (id: string) => void;
|
||||
archiveConversation: (id: string, isArchived: boolean) => Promise<Conversation>;
|
||||
}
|
||||
|
||||
export function useConversationContextMenu({
|
||||
|
@ -114,9 +115,60 @@ export function useConversationContextMenu({
|
|||
}
|
||||
};
|
||||
|
||||
const handleArchiveConversation = async (id: string, isArchived: boolean) => {
|
||||
setIsUpdatingConversationList(true);
|
||||
|
||||
try {
|
||||
const archivedConversation = await service.callApi(
|
||||
`PATCH /internal/observability_ai_assistant/conversation/{conversationId}`,
|
||||
{
|
||||
signal: null,
|
||||
params: {
|
||||
path: {
|
||||
conversationId: id,
|
||||
},
|
||||
body: {
|
||||
archived: isArchived,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
refreshConversations();
|
||||
setIsUpdatingConversationList(false);
|
||||
|
||||
notifications!.toasts.addSuccess({
|
||||
title: isArchived
|
||||
? i18n.translate('xpack.aiAssistant.archiveConversationSuccessToast', {
|
||||
defaultMessage: 'Conversation archived successfully',
|
||||
})
|
||||
: i18n.translate('xpack.aiAssistant.unarchiveConversationSuccessToast', {
|
||||
defaultMessage: 'Conversation unarchived successfully',
|
||||
}),
|
||||
});
|
||||
|
||||
return archivedConversation;
|
||||
} catch (err) {
|
||||
notifications!.toasts.addError(err, {
|
||||
title: isArchived
|
||||
? i18n.translate('xpack.aiAssistant.archiveConversationErrorToast', {
|
||||
defaultMessage: 'Could not archive conversation',
|
||||
})
|
||||
: i18n.translate('xpack.aiAssistant.unarchiveConversationErrorToast', {
|
||||
defaultMessage: 'Could not unarchive conversation',
|
||||
}),
|
||||
});
|
||||
|
||||
setIsUpdatingConversationList(false);
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
deleteConversation: handleDeleteConversation,
|
||||
copyConversationToClipboard: handleCopyConversationToClipboard,
|
||||
copyUrl: handleCopyUrl,
|
||||
archiveConversation: handleArchiveConversation,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -43,6 +43,7 @@ describe('getTimelineItemsFromConversation', () => {
|
|||
messages: [],
|
||||
chatState: ChatState.Ready,
|
||||
onActionClick: jest.fn(),
|
||||
isArchived: false,
|
||||
});
|
||||
|
||||
expect(items.length).toBe(1);
|
||||
|
@ -70,8 +71,10 @@ describe('getTimelineItemsFromConversation', () => {
|
|||
},
|
||||
],
|
||||
onActionClick: jest.fn(),
|
||||
isArchived: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes the opening message and the user message', () => {
|
||||
expect(items.length).toBe(2);
|
||||
expect(items[0].title).toBe('started a conversation');
|
||||
|
@ -137,6 +140,7 @@ describe('getTimelineItemsFromConversation', () => {
|
|||
},
|
||||
],
|
||||
onActionClick: jest.fn(),
|
||||
isArchived: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -222,6 +226,7 @@ describe('getTimelineItemsFromConversation', () => {
|
|||
},
|
||||
],
|
||||
onActionClick: jest.fn(),
|
||||
isArchived: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -298,6 +303,7 @@ describe('getTimelineItemsFromConversation', () => {
|
|||
},
|
||||
],
|
||||
onActionClick: jest.fn(),
|
||||
isArchived: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -364,6 +370,7 @@ describe('getTimelineItemsFromConversation', () => {
|
|||
},
|
||||
],
|
||||
onActionClick: jest.fn(),
|
||||
isArchived: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -415,6 +422,7 @@ describe('getTimelineItemsFromConversation', () => {
|
|||
},
|
||||
],
|
||||
onActionClick: jest.fn(),
|
||||
isArchived: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -467,6 +475,7 @@ describe('getTimelineItemsFromConversation', () => {
|
|||
...extraMessages,
|
||||
],
|
||||
onActionClick: jest.fn(),
|
||||
isArchived: false,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -68,6 +68,7 @@ export function getTimelineItemsfromConversation({
|
|||
chatState,
|
||||
isConversationOwnedByCurrentUser,
|
||||
onActionClick,
|
||||
isArchived,
|
||||
}: {
|
||||
conversationId?: string;
|
||||
chatService: ObservabilityAIAssistantChatService;
|
||||
|
@ -83,6 +84,7 @@ export function getTimelineItemsfromConversation({
|
|||
message: Message;
|
||||
payload: ChatActionClickPayload;
|
||||
}) => void;
|
||||
isArchived: boolean;
|
||||
}): ChatTimelineItem[] {
|
||||
const items: ChatTimelineItem[] = [
|
||||
{
|
||||
|
@ -198,14 +200,14 @@ export function getTimelineItemsfromConversation({
|
|||
|
||||
content = convertMessageToMarkdownCodeBlock(message.message);
|
||||
|
||||
actions.canEdit = hasConnector && isConversationOwnedByCurrentUser;
|
||||
actions.canEdit = hasConnector && isConversationOwnedByCurrentUser && !isArchived;
|
||||
display.collapsed = true;
|
||||
} else {
|
||||
// is a prompt by the user
|
||||
title = '';
|
||||
content = message.message.content;
|
||||
|
||||
actions.canEdit = hasConnector && isConversationOwnedByCurrentUser;
|
||||
actions.canEdit = hasConnector && isConversationOwnedByCurrentUser && !isArchived;
|
||||
display.collapsed = false;
|
||||
}
|
||||
|
||||
|
@ -218,8 +220,8 @@ export function getTimelineItemsfromConversation({
|
|||
break;
|
||||
|
||||
case MessageRole.Assistant:
|
||||
actions.canRegenerate = hasConnector && isConversationOwnedByCurrentUser;
|
||||
actions.canGiveFeedback = isConversationOwnedByCurrentUser;
|
||||
actions.canRegenerate = hasConnector && isConversationOwnedByCurrentUser && !isArchived;
|
||||
actions.canGiveFeedback = isConversationOwnedByCurrentUser && !isArchived;
|
||||
display.hide = false;
|
||||
|
||||
// is a function suggestion by the assistant
|
||||
|
@ -246,7 +248,7 @@ export function getTimelineItemsfromConversation({
|
|||
display.collapsed = true;
|
||||
}
|
||||
|
||||
actions.canEdit = isConversationOwnedByCurrentUser;
|
||||
actions.canEdit = isConversationOwnedByCurrentUser && !isArchived;
|
||||
} else {
|
||||
// is an assistant response
|
||||
title = '';
|
||||
|
|
|
@ -62,6 +62,7 @@ export interface Conversation {
|
|||
numeric_labels: Record<string, number>;
|
||||
namespace: string;
|
||||
public: boolean;
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
type ConversationRequestBase = Omit<Conversation, 'user' | 'conversation' | 'namespace'> & {
|
||||
|
|
|
@ -91,6 +91,13 @@ export const chatFeedbackEventSchema: EventTypeOpts<ChatFeedback> = {
|
|||
description: 'Whether the conversation is public or not.',
|
||||
},
|
||||
},
|
||||
archived: {
|
||||
type: 'boolean',
|
||||
_meta: {
|
||||
description: 'Whether the conversation is archived or not.',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -191,6 +191,7 @@ const patchConversationRoute = createObservabilityAIAssistantServerRoute({
|
|||
}),
|
||||
body: t.partial({
|
||||
public: t.boolean,
|
||||
archived: t.boolean,
|
||||
}),
|
||||
}),
|
||||
security: {
|
||||
|
|
|
@ -68,6 +68,7 @@ export const conversationCreateRt: t.Type<ConversationCreateRequest> = t.interse
|
|||
}),
|
||||
t.partial({
|
||||
systemMessage: t.string,
|
||||
archived: toBooleanRt,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
|
|
@ -456,6 +456,7 @@ describe('Observability AI Assistant client', () => {
|
|||
labels: {},
|
||||
numeric_labels: {},
|
||||
public: false,
|
||||
archived: false,
|
||||
namespace: 'default',
|
||||
user: {
|
||||
name: 'johndoe',
|
||||
|
|
|
@ -388,6 +388,7 @@ export class ObservabilityAIAssistantClient {
|
|||
numeric_labels: {},
|
||||
systemMessage,
|
||||
messages: initialMessagesWithAddedMessages,
|
||||
archived: false,
|
||||
})
|
||||
).pipe(
|
||||
map((conversationCreated): ConversationCreateEvent => {
|
||||
|
@ -611,7 +612,7 @@ export class ObservabilityAIAssistantClient {
|
|||
updates,
|
||||
}: {
|
||||
conversationId: string;
|
||||
updates: Partial<{ public: boolean }>;
|
||||
updates: Partial<{ public: boolean; archived: boolean }>;
|
||||
}): Promise<Conversation> => {
|
||||
const conversation = await this.get(conversationId);
|
||||
if (!conversation) {
|
||||
|
@ -620,6 +621,7 @@ export class ObservabilityAIAssistantClient {
|
|||
|
||||
const updatedConversation: Conversation = merge({}, conversation, {
|
||||
...(updates.public !== undefined && { public: updates.public }),
|
||||
...(updates.archived !== undefined && { archived: updates.archived }),
|
||||
});
|
||||
|
||||
return this.update(conversationId, updatedConversation);
|
||||
|
@ -639,6 +641,7 @@ export class ObservabilityAIAssistantClient {
|
|||
id: v4(),
|
||||
},
|
||||
public: false,
|
||||
archived: false,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -8,36 +8,16 @@
|
|||
import expect from '@kbn/expect';
|
||||
import { merge, omit } from 'lodash';
|
||||
import {
|
||||
type ConversationCreateRequest,
|
||||
type ConversationUpdateRequest,
|
||||
MessageRole,
|
||||
Conversation,
|
||||
} from '@kbn/observability-ai-assistant-plugin/common/types';
|
||||
import type { SupertestReturnType } from '../../../../services/observability_ai_assistant_api';
|
||||
import type { DeploymentAgnosticFtrProviderContext } from '../../../../ftr_provider_context';
|
||||
import { clearConversations, conversationCreate, createConversation } from '../utils/conversation';
|
||||
|
||||
export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderContext) {
|
||||
const observabilityAIAssistantAPIClient = getService('observabilityAIAssistantApi');
|
||||
|
||||
const conversationCreate: ConversationCreateRequest = {
|
||||
'@timestamp': new Date().toISOString(),
|
||||
conversation: {
|
||||
title: 'My title',
|
||||
},
|
||||
labels: {},
|
||||
numeric_labels: {},
|
||||
systemMessage: 'this is a system message',
|
||||
messages: [
|
||||
{
|
||||
'@timestamp': new Date().toISOString(),
|
||||
message: {
|
||||
role: MessageRole.User,
|
||||
content: 'My message',
|
||||
},
|
||||
},
|
||||
],
|
||||
public: false,
|
||||
};
|
||||
const es = getService('es');
|
||||
|
||||
const conversationUpdate: ConversationUpdateRequest = merge({}, conversationCreate, {
|
||||
conversation: {
|
||||
|
@ -90,15 +70,9 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
|
|||
let createResponse: Awaited<
|
||||
SupertestReturnType<'POST /internal/observability_ai_assistant/conversation'>
|
||||
>;
|
||||
|
||||
before(async () => {
|
||||
createResponse = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversation',
|
||||
params: {
|
||||
body: {
|
||||
conversation: conversationCreate,
|
||||
},
|
||||
},
|
||||
});
|
||||
createResponse = await createConversation({ observabilityAIAssistantAPIClient });
|
||||
expect(createResponse.status).to.be(200);
|
||||
});
|
||||
|
||||
|
@ -141,6 +115,7 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
|
|||
messages: conversationCreate.messages,
|
||||
namespace: 'default',
|
||||
public: conversationCreate.public,
|
||||
archived: conversationCreate.archived,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -582,15 +557,9 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
|
|||
let createResponse: Awaited<
|
||||
SupertestReturnType<'POST /internal/observability_ai_assistant/conversation'>
|
||||
>;
|
||||
|
||||
before(async () => {
|
||||
createResponse = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversation',
|
||||
params: {
|
||||
body: {
|
||||
conversation: conversationCreate,
|
||||
},
|
||||
},
|
||||
});
|
||||
createResponse = await createConversation({ observabilityAIAssistantAPIClient });
|
||||
expect(createResponse.status).to.be(200);
|
||||
});
|
||||
|
||||
|
@ -607,13 +576,9 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
|
|||
});
|
||||
|
||||
it('POST /internal/observability_ai_assistant/conversation', async () => {
|
||||
const { status } = await observabilityAIAssistantAPIClient.viewer({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversation',
|
||||
params: {
|
||||
body: {
|
||||
conversation: conversationCreate,
|
||||
},
|
||||
},
|
||||
const { status } = await createConversation({
|
||||
observabilityAIAssistantAPIClient,
|
||||
user: 'viewer',
|
||||
});
|
||||
|
||||
expect(status).to.be(403);
|
||||
|
@ -675,27 +640,14 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
|
|||
'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,
|
||||
},
|
||||
},
|
||||
});
|
||||
const { status, body } = await createConversation({ observabilityAIAssistantAPIClient });
|
||||
|
||||
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);
|
||||
await clearConversations(es);
|
||||
});
|
||||
|
||||
it('allows the owner to update the access of their conversation', async () => {
|
||||
|
@ -756,14 +708,7 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
|
|||
|
||||
before(async () => {
|
||||
// Create a conversation to delete
|
||||
const { status, body } = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversation',
|
||||
params: {
|
||||
body: {
|
||||
conversation: conversationCreate,
|
||||
},
|
||||
},
|
||||
});
|
||||
const { status, body } = await createConversation({ observabilityAIAssistantAPIClient });
|
||||
|
||||
expect(status).to.be(200);
|
||||
createdConversationId = body.conversation.id;
|
||||
|
@ -792,15 +737,7 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
|
|||
|
||||
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 { body } = await createConversation({ observabilityAIAssistantAPIClient });
|
||||
const unauthorizedConversationId = body.conversation.id;
|
||||
|
||||
// try deleting as an admin
|
||||
|
@ -824,5 +761,73 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
|
|||
expect(ownerDeleteResponse.status).to.be(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversation archiving', () => {
|
||||
let createdConversationId: string;
|
||||
|
||||
before(async () => {
|
||||
const { status, body } = await createConversation({
|
||||
observabilityAIAssistantAPIClient,
|
||||
isPublic: true,
|
||||
});
|
||||
|
||||
expect(status).to.be(200);
|
||||
createdConversationId = body.conversation.id;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await clearConversations(es);
|
||||
});
|
||||
|
||||
it('allows the owner to archive a conversation', async () => {
|
||||
const archiveResponse = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'PATCH /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
params: {
|
||||
path: { conversationId: createdConversationId },
|
||||
body: { archived: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(archiveResponse.status).to.be(200);
|
||||
expect(archiveResponse.body.archived).to.be(true);
|
||||
});
|
||||
|
||||
it('allows the owner to unarchive a conversation', async () => {
|
||||
const unarchiveResponse = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'PATCH /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
params: {
|
||||
path: { conversationId: createdConversationId },
|
||||
body: { archived: false },
|
||||
},
|
||||
});
|
||||
|
||||
expect(unarchiveResponse.status).to.be(200);
|
||||
expect(unarchiveResponse.body.archived).to.be(false);
|
||||
});
|
||||
|
||||
it('does not allow a different user (admin) to archive a conversation they do not own', async () => {
|
||||
const updateResponse = await observabilityAIAssistantAPIClient.admin({
|
||||
endpoint: 'PATCH /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
params: {
|
||||
path: { conversationId: createdConversationId },
|
||||
body: { archived: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateResponse.status).to.be(403);
|
||||
});
|
||||
|
||||
it('returns 404 when trying to archive a non-existing conversation', async () => {
|
||||
const response = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'PATCH /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
params: {
|
||||
path: { conversationId: 'non-existing-conversation-id' },
|
||||
body: { archived: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).to.be(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
MessageRole,
|
||||
StreamingChatResponseEvent,
|
||||
StreamingChatResponseEventType,
|
||||
type ConversationCreateRequest,
|
||||
} from '@kbn/observability-ai-assistant-plugin/common';
|
||||
import { Readable } from 'stream';
|
||||
import type { AssistantScope } from '@kbn/ai-assistant-common';
|
||||
|
@ -180,3 +181,45 @@ export function getConversationUpdatedEvent(body: Readable | string) {
|
|||
|
||||
return conversationUpdatedEvent;
|
||||
}
|
||||
|
||||
export const conversationCreate: ConversationCreateRequest = {
|
||||
'@timestamp': new Date().toISOString(),
|
||||
conversation: {
|
||||
title: 'My title',
|
||||
},
|
||||
labels: {},
|
||||
numeric_labels: {},
|
||||
systemMessage: 'this is a system message',
|
||||
messages: [
|
||||
{
|
||||
'@timestamp': new Date().toISOString(),
|
||||
message: {
|
||||
role: MessageRole.User,
|
||||
content: 'My message',
|
||||
},
|
||||
},
|
||||
],
|
||||
public: false,
|
||||
archived: false,
|
||||
};
|
||||
|
||||
export async function createConversation({
|
||||
observabilityAIAssistantAPIClient,
|
||||
user = 'editor',
|
||||
isPublic = false,
|
||||
}: {
|
||||
observabilityAIAssistantAPIClient: ObservabilityAIAssistantApiClient;
|
||||
user?: 'admin' | 'editor' | 'viewer';
|
||||
isPublic?: boolean;
|
||||
}) {
|
||||
const response = await observabilityAIAssistantAPIClient[user]({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversation',
|
||||
params: {
|
||||
body: {
|
||||
conversation: { ...conversationCreate, public: isPublic },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
|
|
@ -60,7 +60,7 @@ async function getTestConfig({
|
|||
services: {
|
||||
observabilityAIAssistantUI: (context: InheritedFtrProviderContext) =>
|
||||
ObservabilityAIAssistantUIProvider(context),
|
||||
observabilityAIAssistantAPIClient: async (context: InheritedFtrProviderContext) => {
|
||||
observabilityAIAssistantAPIClient: async () => {
|
||||
return {
|
||||
admin: getScopedApiClient(kibanaServer, 'elastic'),
|
||||
viewer: getScopedApiClient(kibanaServer, viewer.username),
|
||||
|
|
|
@ -47,6 +47,12 @@ const pages = {
|
|||
privateOption: 'observabilityAiAssistantChatPrivateOption',
|
||||
loadingBadge: 'observabilityAiAssistantChatAccessLoadingBadge',
|
||||
},
|
||||
contextMenu: {
|
||||
button: 'observabilityAiAssistantChatContextMenuButtonIcon',
|
||||
archiveOption: 'observabilityAiAssistantContextMenuArchive',
|
||||
unarchiveOption: 'observabilityAiAssistantContextMenuUnarchive',
|
||||
},
|
||||
archivedBadge: 'observabilityAiAssistantArchivedBadge',
|
||||
},
|
||||
createConnectorFlyout: {
|
||||
flyout: 'create-connector-flyout',
|
||||
|
@ -81,7 +87,6 @@ const pages = {
|
|||
|
||||
export async function ObservabilityAIAssistantUIProvider({
|
||||
getPageObjects,
|
||||
getService,
|
||||
}: InheritedFtrProviderContext): Promise<ObservabilityAIAssistantUIService> {
|
||||
const pageObjects = getPageObjects(['common', 'security']);
|
||||
|
||||
|
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* 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';
|
||||
import { interceptRequest } from '../../common/intercept_request';
|
||||
|
||||
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 driver = getService('__webdriver__');
|
||||
|
||||
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 Archiving', () => {
|
||||
let proxy: LlmProxy;
|
||||
|
||||
before(async () => {
|
||||
proxy = await createLlmProxy(log);
|
||||
await ui.auth.login('editor');
|
||||
await ui.router.goto('/conversations/new', { path: {}, query: {} });
|
||||
|
||||
await deleteConnectors(supertest);
|
||||
await deleteConversations(getService);
|
||||
|
||||
await createConnector(proxy, supertest);
|
||||
await createConversation(proxy);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await deleteConnectors(supertest);
|
||||
await deleteConversations(getService);
|
||||
await ui.auth.logout();
|
||||
proxy.close();
|
||||
});
|
||||
|
||||
it('should display the context menu button', async () => {
|
||||
await testSubjects.existOrFail(ui.pages.conversations.contextMenu.button);
|
||||
});
|
||||
|
||||
it('should open the context menu on click', async () => {
|
||||
await testSubjects.click(ui.pages.conversations.contextMenu.button);
|
||||
await testSubjects.existOrFail(ui.pages.conversations.contextMenu.archiveOption);
|
||||
await testSubjects.click(ui.pages.conversations.contextMenu.button);
|
||||
});
|
||||
|
||||
describe('when archiving a conversation', () => {
|
||||
before(async () => {
|
||||
await interceptRequest(
|
||||
driver.driver,
|
||||
'*observability_ai_assistant\\/conversation\\/*',
|
||||
(responseFactory) => responseFactory.continue(),
|
||||
async () => {
|
||||
await testSubjects.click(ui.pages.conversations.contextMenu.button);
|
||||
await testSubjects.click(ui.pages.conversations.contextMenu.archiveOption);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should display the "Archived" badge', async () => {
|
||||
await retry.try(async () => {
|
||||
const badgeText = await testSubjects.getVisibleText(ui.pages.conversations.archivedBadge);
|
||||
expect(badgeText).to.contain('Archived');
|
||||
});
|
||||
});
|
||||
|
||||
it('should persist the archived state 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?.archived).to.eql(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when unarchiving a conversation', () => {
|
||||
before(async () => {
|
||||
await interceptRequest(
|
||||
driver.driver,
|
||||
'*observability_ai_assistant\\/conversation\\/*',
|
||||
(responseFactory) => responseFactory.continue(),
|
||||
async () => {
|
||||
await testSubjects.click(ui.pages.conversations.contextMenu.button);
|
||||
await testSubjects.click(ui.pages.conversations.contextMenu.unarchiveOption);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should hide the "Archived" badge', async () => {
|
||||
await retry.try(async () => {
|
||||
const exists = await testSubjects.exists(ui.pages.conversations.archivedBadge);
|
||||
expect(exists).to.eql(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should persist the unarchived state 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?.archived).to.eql(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue