mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[Observability AI Assistant] duplicate conversations (#208044)
Closes #209382 ### Summary: #### Duplicate Conversation - **Readonly** → Public conversations can only be modified by the owner. - Duplicated conversations are **owned** by the user who duplicates them. - Duplicated conversations are **private** by default `public: false`. https://github.com/user-attachments/assets/9a2d1727-aa0d-4d8f-a886-727c0ce1578c UPDATE: https://github.com/user-attachments/assets/ee3282e8-5ae8-445d-9368-928dd59cfb75 ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [ ] 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) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [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 - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] 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)
This commit is contained in:
parent
df59c26083
commit
b331fa1c53
18 changed files with 699 additions and 124 deletions
|
@ -25,11 +25,13 @@ export function ChatActionsMenu({
|
|||
conversationId,
|
||||
disabled,
|
||||
onCopyConversationClick,
|
||||
onDuplicateConversationClick,
|
||||
}: {
|
||||
connectors: UseGenAIConnectorsResult;
|
||||
conversationId?: string;
|
||||
disabled: boolean;
|
||||
onCopyConversationClick: () => void;
|
||||
onDuplicateConversationClick: () => void;
|
||||
}) {
|
||||
const { application, http } = useKibana().services;
|
||||
const knowledgeBase = useKnowledgeBase();
|
||||
|
@ -141,6 +143,16 @@ export function ChatActionsMenu({
|
|||
onCopyConversationClick();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: i18n.translate('xpack.aiAssistant.chatHeader.actions.duplicateConversation', {
|
||||
defaultMessage: 'Duplicate',
|
||||
}),
|
||||
disabled: !conversationId,
|
||||
onClick: () => {
|
||||
toggleActionsMenu();
|
||||
onDuplicateConversationClick();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -6,14 +6,17 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
EuiButton,
|
||||
EuiCallOut,
|
||||
euiCanAnimate,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiIcon,
|
||||
EuiPanel,
|
||||
euiScrollBarStyles,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
useEuiTheme,
|
||||
UseEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
|
@ -46,8 +49,8 @@ import { SimulatedFunctionCallingCallout } from './simulated_function_calling_ca
|
|||
import { WelcomeMessage } from './welcome_message';
|
||||
import { useLicense } from '../hooks/use_license';
|
||||
import { PromptEditor } from '../prompt_editor/prompt_editor';
|
||||
import { deserializeMessage } from '../utils/deserialize_message';
|
||||
import { useKibana } from '../hooks/use_kibana';
|
||||
import { deserializeMessage } from '../utils/deserialize_message';
|
||||
|
||||
const fullHeightClassName = css`
|
||||
height: 100%;
|
||||
|
@ -118,9 +121,10 @@ export function ChatBody({
|
|||
onConversationUpdate,
|
||||
onToggleFlyoutPositionMode,
|
||||
navigateToConversation,
|
||||
onConversationDuplicate,
|
||||
}: {
|
||||
connectors: ReturnType<typeof useGenAIConnectors>;
|
||||
currentUser?: Pick<AuthenticatedUser, 'full_name' | 'username'>;
|
||||
currentUser?: Pick<AuthenticatedUser, 'full_name' | 'username' | 'profile_uid'>;
|
||||
flyoutPositionMode?: FlyoutPositionMode;
|
||||
initialTitle?: string;
|
||||
initialMessages?: Message[];
|
||||
|
@ -128,6 +132,7 @@ export function ChatBody({
|
|||
knowledgeBase: UseKnowledgeBaseResult;
|
||||
showLinkToConversationsApp: boolean;
|
||||
onConversationUpdate: (conversation: { conversation: Conversation['conversation'] }) => void;
|
||||
onConversationDuplicate: (conversation: Conversation) => void;
|
||||
onToggleFlyoutPositionMode?: (flyoutPositionMode: FlyoutPositionMode) => void;
|
||||
navigateToConversation?: (conversationId?: string) => void;
|
||||
}) {
|
||||
|
@ -148,13 +153,26 @@ export function ChatBody({
|
|||
false
|
||||
);
|
||||
|
||||
const { conversation, messages, next, state, stop, saveTitle } = useConversation({
|
||||
const {
|
||||
conversation,
|
||||
conversationId,
|
||||
messages,
|
||||
next,
|
||||
state,
|
||||
stop,
|
||||
saveTitle,
|
||||
duplicateConversation,
|
||||
isConversationOwnedByCurrentUser,
|
||||
user: conversationUser,
|
||||
} = useConversation({
|
||||
currentUser,
|
||||
initialConversationId,
|
||||
initialMessages,
|
||||
initialTitle,
|
||||
chatService,
|
||||
connectorId: connectors.selectedConnector,
|
||||
onConversationUpdate,
|
||||
onConversationDuplicate,
|
||||
});
|
||||
|
||||
const timelineContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
@ -391,28 +409,65 @@ export function ChatBody({
|
|||
}
|
||||
/>
|
||||
) : (
|
||||
<ChatTimeline
|
||||
messages={messages}
|
||||
knowledgeBase={knowledgeBase}
|
||||
chatService={chatService}
|
||||
currentUser={currentUser}
|
||||
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}
|
||||
/>
|
||||
<>
|
||||
<ChatTimeline
|
||||
conversationId={conversationId}
|
||||
messages={messages}
|
||||
knowledgeBase={knowledgeBase}
|
||||
chatService={chatService}
|
||||
currentUser={conversationUser}
|
||||
isConversationOwnedByCurrentUser={isConversationOwnedByCurrentUser}
|
||||
chatState={state}
|
||||
hasConnector={!!connectors.connectors?.length}
|
||||
onEdit={(editedMessage, newMessage) => {
|
||||
setStickToBottom(true);
|
||||
const indexOf = messages.indexOf(editedMessage);
|
||||
next(messages.slice(0, indexOf).concat(newMessage));
|
||||
}}
|
||||
onFeedback={handleFeedback}
|
||||
onRegenerate={(message) => {
|
||||
next(reverseToLastUserMessage(messages, message));
|
||||
}}
|
||||
onSendTelemetry={(eventWithPayload) =>
|
||||
chatService.sendAnalyticsEvent(eventWithPayload)
|
||||
}
|
||||
onStopGenerating={stop}
|
||||
onActionClick={handleActionClick}
|
||||
/>
|
||||
{conversationId && !isConversationOwnedByCurrentUser ? (
|
||||
<>
|
||||
<EuiPanel paddingSize="m" hasShadow={false} color="subdued">
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon size="l" type="users" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow>
|
||||
<EuiText size="xs">
|
||||
<h3>
|
||||
{i18n.translate('xpack.aiAssistant.sharedBanner.title', {
|
||||
defaultMessage: 'This conversation is shared with your team.',
|
||||
})}
|
||||
</h3>
|
||||
<p>
|
||||
{i18n.translate('xpack.aiAssistant.sharedBanner.description', {
|
||||
defaultMessage: `You can’t edit or continue this conversation, but you can duplicate
|
||||
it into a new private conversation. The original conversation will
|
||||
remain unchanged.`,
|
||||
})}
|
||||
</p>
|
||||
<EuiButton onClick={duplicateConversation} iconType="copy" size="s">
|
||||
{i18n.translate('xpack.aiAssistant.duplicateButton', {
|
||||
defaultMessage: 'Duplicate',
|
||||
})}
|
||||
</EuiButton>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</EuiPanel>
|
||||
</div>
|
||||
|
@ -438,7 +493,11 @@ export function ChatBody({
|
|||
className={promptEditorContainerClassName}
|
||||
>
|
||||
<PromptEditor
|
||||
disabled={!connectors.selectedConnector || !hasCorrectLicense}
|
||||
disabled={
|
||||
!connectors.selectedConnector ||
|
||||
!hasCorrectLicense ||
|
||||
(!!conversationId && !isConversationOwnedByCurrentUser)
|
||||
}
|
||||
hidden={connectors.loading || connectors.connectors?.length === 0}
|
||||
loading={isLoading}
|
||||
onChangeHeight={handleChangeHeight}
|
||||
|
@ -515,16 +574,13 @@ export function ChatBody({
|
|||
<EuiFlexItem grow={false} className={headerContainerClassName}>
|
||||
<ChatHeader
|
||||
connectors={connectors}
|
||||
conversationId={
|
||||
conversation.value?.conversation && 'id' in conversation.value.conversation
|
||||
? conversation.value.conversation.id
|
||||
: undefined
|
||||
}
|
||||
conversationId={conversationId}
|
||||
flyoutPositionMode={flyoutPositionMode}
|
||||
licenseInvalid={!hasCorrectLicense && !initialConversationId}
|
||||
loading={isLoading}
|
||||
title={title}
|
||||
onCopyConversation={handleCopyConversation}
|
||||
onDuplicateConversation={duplicateConversation}
|
||||
onSaveTitle={(newTitle) => {
|
||||
saveTitle(newTitle);
|
||||
}}
|
||||
|
@ -532,6 +588,7 @@ export function ChatBody({
|
|||
navigateToConversation={
|
||||
initialMessages?.length && !initialConversationId ? undefined : navigateToConversation
|
||||
}
|
||||
isConversationOwnedByCurrentUser={isConversationOwnedByCurrentUser}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
|
|
|
@ -49,6 +49,7 @@ const noPanelStyle = css`
|
|||
|
||||
export function ChatConsolidatedItems({
|
||||
consolidatedItem,
|
||||
isConversationOwnedByCurrentUser,
|
||||
onActionClick,
|
||||
onEditSubmit,
|
||||
onFeedback,
|
||||
|
@ -57,6 +58,7 @@ export function ChatConsolidatedItems({
|
|||
onStopGenerating,
|
||||
}: {
|
||||
consolidatedItem: ChatTimelineItem[];
|
||||
isConversationOwnedByCurrentUser: ChatTimelineProps['isConversationOwnedByCurrentUser'];
|
||||
onActionClick: ChatTimelineProps['onActionClick'];
|
||||
onEditSubmit: ChatTimelineProps['onEdit'];
|
||||
onFeedback: ChatTimelineProps['onFeedback'];
|
||||
|
@ -134,6 +136,7 @@ export function ChatConsolidatedItems({
|
|||
}}
|
||||
onSendTelemetry={onSendTelemetry}
|
||||
onStopGeneratingClick={onStopGenerating}
|
||||
isConversationOwnedByCurrentUser={isConversationOwnedByCurrentUser}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
|
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import { css } from '@emotion/css';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { Message } from '@kbn/observability-ai-assistant-plugin/common';
|
||||
import { Conversation, Message } from '@kbn/observability-ai-assistant-plugin/common';
|
||||
import React, { useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useConversationKey } from '../hooks/use_conversation_key';
|
||||
|
@ -42,7 +42,7 @@ export enum FlyoutPositionMode {
|
|||
|
||||
export function ChatFlyout({
|
||||
initialTitle,
|
||||
initialMessages,
|
||||
initialMessages: initialMessagesFromProps,
|
||||
initialFlyoutPositionMode,
|
||||
onFlyoutPositionModeChange,
|
||||
isOpen,
|
||||
|
@ -69,6 +69,7 @@ export function ChatFlyout({
|
|||
const knowledgeBase = useKnowledgeBase();
|
||||
|
||||
const [conversationId, setConversationId] = useState<string | undefined>(undefined);
|
||||
const [initialMessages, setInitialMessages] = useState(initialMessagesFromProps);
|
||||
|
||||
const [flyoutPositionMode, setFlyoutPositionMode] = useState<FlyoutPositionMode>(
|
||||
initialFlyoutPositionMode || FlyoutPositionMode.OVERLAY
|
||||
|
@ -88,6 +89,12 @@ export function ChatFlyout({
|
|||
|
||||
const { key: bodyKey, updateConversationIdInPlace } = useConversationKey(conversationId);
|
||||
|
||||
const onConversationDuplicate = (conversation: Conversation) => {
|
||||
conversationList.conversations.refresh();
|
||||
setInitialMessages([]);
|
||||
setConversationId(conversation.conversation.id);
|
||||
};
|
||||
|
||||
const flyoutClassName = css`
|
||||
max-inline-size: 100% !important;
|
||||
`;
|
||||
|
@ -287,6 +294,7 @@ export function ChatFlyout({
|
|||
}
|
||||
: undefined
|
||||
}
|
||||
onConversationDuplicate={onConversationDuplicate}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
|
|
|
@ -46,7 +46,9 @@ export function ChatHeader({
|
|||
licenseInvalid,
|
||||
loading,
|
||||
title,
|
||||
isConversationOwnedByCurrentUser,
|
||||
onCopyConversation,
|
||||
onDuplicateConversation,
|
||||
onSaveTitle,
|
||||
onToggleFlyoutPositionMode,
|
||||
navigateToConversation,
|
||||
|
@ -57,7 +59,9 @@ export function ChatHeader({
|
|||
licenseInvalid: boolean;
|
||||
loading: boolean;
|
||||
title: string;
|
||||
isConversationOwnedByCurrentUser: boolean;
|
||||
onCopyConversation: () => void;
|
||||
onDuplicateConversation: () => void;
|
||||
onSaveTitle: (title: string) => void;
|
||||
onToggleFlyoutPositionMode?: (newFlyoutPositionMode: FlyoutPositionMode) => void;
|
||||
navigateToConversation?: (nextConversationId?: string) => void;
|
||||
|
@ -115,7 +119,8 @@ export function ChatHeader({
|
|||
!conversationId ||
|
||||
!connectors.selectedConnector ||
|
||||
licenseInvalid ||
|
||||
!Boolean(onSaveTitle)
|
||||
!Boolean(onSaveTitle) ||
|
||||
!isConversationOwnedByCurrentUser
|
||||
}
|
||||
onChange={(e) => {
|
||||
setNewTitle(e.currentTarget.nodeValue || '');
|
||||
|
@ -201,6 +206,7 @@ export function ChatHeader({
|
|||
conversationId={conversationId}
|
||||
disabled={licenseInvalid}
|
||||
onCopyConversationClick={onCopyConversation}
|
||||
onDuplicateConversationClick={onDuplicateConversation}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
|
|
@ -35,6 +35,7 @@ export interface ChatItemProps extends Omit<ChatTimelineItem, 'message'> {
|
|||
onRegenerateClick: () => void;
|
||||
onSendTelemetry: (eventWithPayload: TelemetryEventTypeWithPayload) => void;
|
||||
onStopGeneratingClick: () => void;
|
||||
isConversationOwnedByCurrentUser: boolean;
|
||||
}
|
||||
|
||||
const moreCompactHeaderClassName = css`
|
||||
|
@ -87,6 +88,7 @@ export function ChatItem({
|
|||
error,
|
||||
loading,
|
||||
title,
|
||||
isConversationOwnedByCurrentUser,
|
||||
onActionClick,
|
||||
onEditSubmit,
|
||||
onFeedbackClick,
|
||||
|
@ -167,7 +169,11 @@ export function ChatItem({
|
|||
return (
|
||||
<EuiComment
|
||||
timelineAvatar={<ChatItemAvatar loading={loading} currentUser={currentUser} role={role} />}
|
||||
username={getRoleTranslation(role)}
|
||||
username={getRoleTranslation({
|
||||
role,
|
||||
isCurrentUser: isConversationOwnedByCurrentUser,
|
||||
username: currentUser?.username,
|
||||
})}
|
||||
event={title}
|
||||
actions={
|
||||
<ChatItemActions
|
||||
|
|
|
@ -46,11 +46,13 @@ export interface ChatTimelineItem
|
|||
}
|
||||
|
||||
export interface ChatTimelineProps {
|
||||
conversationId?: string;
|
||||
messages: Message[];
|
||||
knowledgeBase: UseKnowledgeBaseResult;
|
||||
chatService: ObservabilityAIAssistantChatService;
|
||||
hasConnector: boolean;
|
||||
chatState: ChatState;
|
||||
isConversationOwnedByCurrentUser: boolean;
|
||||
currentUser?: Pick<AuthenticatedUser, 'full_name' | 'username'>;
|
||||
onEdit: (message: Message, messageAfterEdit: Message) => void;
|
||||
onFeedback: (feedback: Feedback) => void;
|
||||
|
@ -67,10 +69,12 @@ export interface ChatTimelineProps {
|
|||
}
|
||||
|
||||
export function ChatTimeline({
|
||||
conversationId,
|
||||
messages,
|
||||
chatService,
|
||||
hasConnector,
|
||||
currentUser,
|
||||
isConversationOwnedByCurrentUser,
|
||||
onEdit,
|
||||
onFeedback,
|
||||
onRegenerate,
|
||||
|
@ -81,10 +85,12 @@ export function ChatTimeline({
|
|||
}: ChatTimelineProps) {
|
||||
const items = useMemo(() => {
|
||||
const timelineItems = getTimelineItemsfromConversation({
|
||||
conversationId,
|
||||
chatService,
|
||||
hasConnector,
|
||||
messages,
|
||||
currentUser,
|
||||
isConversationOwnedByCurrentUser,
|
||||
chatState,
|
||||
onActionClick,
|
||||
});
|
||||
|
@ -109,7 +115,16 @@ export function ChatTimeline({
|
|||
}
|
||||
|
||||
return consolidatedChatItems;
|
||||
}, [chatService, hasConnector, messages, currentUser, chatState, onActionClick]);
|
||||
}, [
|
||||
conversationId,
|
||||
chatService,
|
||||
hasConnector,
|
||||
messages,
|
||||
currentUser,
|
||||
chatState,
|
||||
isConversationOwnedByCurrentUser,
|
||||
onActionClick,
|
||||
]);
|
||||
|
||||
return (
|
||||
<EuiCommentList
|
||||
|
@ -128,6 +143,7 @@ export function ChatTimeline({
|
|||
onRegenerate={onRegenerate}
|
||||
onSendTelemetry={onSendTelemetry}
|
||||
onStopGenerating={onStopGenerating}
|
||||
isConversationOwnedByCurrentUser={isConversationOwnedByCurrentUser}
|
||||
/>
|
||||
) : (
|
||||
<ChatItem
|
||||
|
@ -146,6 +162,7 @@ export function ChatTimeline({
|
|||
}}
|
||||
onSendTelemetry={onSendTelemetry}
|
||||
onStopGeneratingClick={onStopGenerating}
|
||||
isConversationOwnedByCurrentUser={isConversationOwnedByCurrentUser}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -10,6 +10,7 @@ import React, { useEffect, useState } from 'react';
|
|||
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 { useKibana } from '../hooks/use_kibana';
|
||||
import { ConversationList, ChatBody, ChatInlineEditingContent } from '../chat';
|
||||
import { useConversationKey } from '../hooks/use_conversation_key';
|
||||
|
@ -87,6 +88,11 @@ export const ConversationView: React.FC<ConversationViewProps> = ({
|
|||
handleRefreshConversations();
|
||||
};
|
||||
|
||||
const handleConversationDuplicate = (conversation: Conversation) => {
|
||||
handleRefreshConversations();
|
||||
navigateToConversation?.(conversation.conversation.id);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setIsSecondSlotVisible(false);
|
||||
|
@ -176,6 +182,7 @@ export const ConversationView: React.FC<ConversationViewProps> = ({
|
|||
showLinkToConversationsApp={false}
|
||||
onConversationUpdate={handleConversationUpdate}
|
||||
navigateToConversation={navigateToConversation}
|
||||
onConversationDuplicate={handleConversationDuplicate}
|
||||
/>
|
||||
|
||||
<div className={sidebarContainerClass}>
|
||||
|
|
|
@ -98,6 +98,7 @@ describe('useConversation', () => {
|
|||
},
|
||||
],
|
||||
initialConversationId: 'foo',
|
||||
onConversationDuplicate: jest.fn(),
|
||||
},
|
||||
wrapper,
|
||||
})
|
||||
|
@ -111,6 +112,7 @@ describe('useConversation', () => {
|
|||
initialProps: {
|
||||
chatService: mockChatService,
|
||||
connectorId: 'my-connector',
|
||||
onConversationDuplicate: jest.fn(),
|
||||
},
|
||||
wrapper,
|
||||
});
|
||||
|
@ -144,6 +146,7 @@ describe('useConversation', () => {
|
|||
},
|
||||
},
|
||||
],
|
||||
onConversationDuplicate: jest.fn(),
|
||||
},
|
||||
wrapper,
|
||||
});
|
||||
|
@ -185,6 +188,7 @@ describe('useConversation', () => {
|
|||
chatService: mockChatService,
|
||||
connectorId: 'my-connector',
|
||||
initialConversationId: 'my-conversation-id',
|
||||
onConversationDuplicate: jest.fn(),
|
||||
},
|
||||
wrapper,
|
||||
});
|
||||
|
@ -232,6 +236,7 @@ describe('useConversation', () => {
|
|||
chatService: mockChatService,
|
||||
connectorId: 'my-connector',
|
||||
initialConversationId: 'my-conversation-id',
|
||||
onConversationDuplicate: jest.fn(),
|
||||
},
|
||||
wrapper,
|
||||
});
|
||||
|
@ -318,6 +323,7 @@ describe('useConversation', () => {
|
|||
},
|
||||
],
|
||||
onConversationUpdate,
|
||||
onConversationDuplicate: jest.fn(),
|
||||
},
|
||||
wrapper,
|
||||
});
|
||||
|
@ -396,6 +402,7 @@ describe('useConversation', () => {
|
|||
},
|
||||
],
|
||||
initialConversationId: 'foo',
|
||||
onConversationDuplicate: jest.fn(),
|
||||
},
|
||||
wrapper,
|
||||
});
|
||||
|
@ -439,6 +446,7 @@ describe('useConversation', () => {
|
|||
chatService: mockChatService,
|
||||
connectorId: 'my-connector',
|
||||
initialConversationId: 'my-conversation-id',
|
||||
onConversationDuplicate: jest.fn(),
|
||||
},
|
||||
wrapper,
|
||||
});
|
||||
|
|
|
@ -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 type { AuthenticatedUser } from '@kbn/security-plugin/common';
|
||||
import { EMPTY_CONVERSATION_TITLE } from '../i18n';
|
||||
import { useAIAssistantAppService } from './use_ai_assistant_app_service';
|
||||
import { useKibana } from './use_kibana';
|
||||
|
@ -38,28 +39,36 @@ function createNewConversation({
|
|||
}
|
||||
|
||||
export interface UseConversationProps {
|
||||
currentUser?: Pick<AuthenticatedUser, 'full_name' | 'username' | 'profile_uid'>;
|
||||
initialConversationId?: string;
|
||||
initialMessages?: Message[];
|
||||
initialTitle?: string;
|
||||
chatService: ObservabilityAIAssistantChatService;
|
||||
connectorId: string | undefined;
|
||||
onConversationUpdate?: (conversation: { conversation: Conversation['conversation'] }) => void;
|
||||
onConversationDuplicate: (conversation: Conversation) => void;
|
||||
}
|
||||
|
||||
export type UseConversationResult = {
|
||||
conversation: AbortableAsyncState<ConversationCreateRequest | Conversation | undefined>;
|
||||
conversationId?: string;
|
||||
user?: Pick<AuthenticatedUser, 'username' | 'profile_uid'>;
|
||||
isConversationOwnedByCurrentUser: boolean;
|
||||
saveTitle: (newTitle: string) => void;
|
||||
duplicateConversation: () => Promise<Conversation>;
|
||||
} & Omit<UseChatResult, 'setMessages'>;
|
||||
|
||||
const DEFAULT_INITIAL_MESSAGES: Message[] = [];
|
||||
|
||||
export function useConversation({
|
||||
currentUser,
|
||||
initialConversationId: initialConversationIdFromProps,
|
||||
initialMessages: initialMessagesFromProps = DEFAULT_INITIAL_MESSAGES,
|
||||
initialTitle: initialTitleFromProps,
|
||||
chatService,
|
||||
connectorId,
|
||||
onConversationUpdate,
|
||||
onConversationDuplicate,
|
||||
}: UseConversationProps): UseConversationResult {
|
||||
const service = useAIAssistantAppService();
|
||||
const scopes = useScopes();
|
||||
|
@ -110,6 +119,34 @@ export function useConversation({
|
|||
});
|
||||
};
|
||||
|
||||
const duplicateConversation = async () => {
|
||||
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`,
|
||||
{
|
||||
signal: null,
|
||||
params: {
|
||||
path: {
|
||||
conversationId: displayedConversationId,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
onConversationDuplicate(duplicatedConversation);
|
||||
return duplicatedConversation;
|
||||
} catch (err) {
|
||||
notifications!.toasts.addError(err, {
|
||||
title: i18n.translate('xpack.aiAssistant.errorDuplicatingConversation', {
|
||||
defaultMessage: 'Could not duplicate conversation',
|
||||
}),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const { next, messages, setMessages, state, stop } = useChat({
|
||||
initialMessages,
|
||||
initialConversationId,
|
||||
|
@ -161,10 +198,34 @@ 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;
|
||||
};
|
||||
|
||||
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
|
||||
),
|
||||
user:
|
||||
initialConversationId && conversation.value?.conversation && 'user' in conversation.value
|
||||
? {
|
||||
profile_uid: conversation.value.user?.id,
|
||||
username: conversation.value.user?.name || '',
|
||||
}
|
||||
: currentUser,
|
||||
state,
|
||||
next,
|
||||
next: (_messages: Message[]) => next(_messages, () => conversation.refresh()),
|
||||
stop,
|
||||
messages,
|
||||
saveTitle: (title: string) => {
|
||||
|
@ -182,5 +243,6 @@ export function useConversation({
|
|||
onConversationUpdate?.(nextConversation);
|
||||
});
|
||||
},
|
||||
duplicateConversation,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -8,11 +8,21 @@
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { MessageRole } from '@kbn/observability-ai-assistant-plugin/public';
|
||||
|
||||
export function getRoleTranslation(role: MessageRole) {
|
||||
export function getRoleTranslation({
|
||||
role,
|
||||
isCurrentUser,
|
||||
username,
|
||||
}: {
|
||||
role: MessageRole;
|
||||
isCurrentUser: boolean;
|
||||
username?: string;
|
||||
}) {
|
||||
if (role === MessageRole.User) {
|
||||
return i18n.translate('xpack.aiAssistant.chatTimeline.messages.user.label', {
|
||||
defaultMessage: 'You',
|
||||
});
|
||||
return isCurrentUser
|
||||
? i18n.translate('xpack.aiAssistant.chatTimeline.messages.user.label', {
|
||||
defaultMessage: 'You',
|
||||
})
|
||||
: username;
|
||||
}
|
||||
|
||||
return i18n.translate('xpack.aiAssistant.chatTimeline.messages.elasticAssistant.label', {
|
||||
|
|
|
@ -37,6 +37,7 @@ function Providers({ children }: { children: React.ReactNode }) {
|
|||
describe('getTimelineItemsFromConversation', () => {
|
||||
describe('returns an opening message only', () => {
|
||||
items = getTimelineItemsfromConversation({
|
||||
isConversationOwnedByCurrentUser: true,
|
||||
chatService: mockChatService,
|
||||
hasConnector: true,
|
||||
messages: [],
|
||||
|
@ -51,6 +52,7 @@ describe('getTimelineItemsFromConversation', () => {
|
|||
describe('with a start of a conversation', () => {
|
||||
beforeEach(() => {
|
||||
items = getTimelineItemsfromConversation({
|
||||
isConversationOwnedByCurrentUser: true,
|
||||
chatService: mockChatService,
|
||||
hasConnector: true,
|
||||
currentUser: {
|
||||
|
@ -102,6 +104,7 @@ describe('getTimelineItemsFromConversation', () => {
|
|||
beforeEach(() => {
|
||||
mockChatService.hasRenderFunction.mockImplementation(() => false);
|
||||
items = getTimelineItemsfromConversation({
|
||||
isConversationOwnedByCurrentUser: true,
|
||||
chatService: mockChatService,
|
||||
hasConnector: true,
|
||||
chatState: ChatState.Ready,
|
||||
|
@ -186,6 +189,7 @@ describe('getTimelineItemsFromConversation', () => {
|
|||
mockChatService.hasRenderFunction.mockImplementation(() => true);
|
||||
mockChatService.renderFunction.mockImplementation(() => 'Rendered');
|
||||
items = getTimelineItemsfromConversation({
|
||||
isConversationOwnedByCurrentUser: true,
|
||||
chatService: mockChatService,
|
||||
hasConnector: true,
|
||||
chatState: ChatState.Ready,
|
||||
|
@ -257,6 +261,7 @@ describe('getTimelineItemsFromConversation', () => {
|
|||
describe('with a function that errors out', () => {
|
||||
beforeEach(() => {
|
||||
items = getTimelineItemsfromConversation({
|
||||
isConversationOwnedByCurrentUser: true,
|
||||
chatService: mockChatService,
|
||||
hasConnector: true,
|
||||
chatState: ChatState.Ready,
|
||||
|
@ -328,6 +333,7 @@ describe('getTimelineItemsFromConversation', () => {
|
|||
describe('with an invalid JSON response', () => {
|
||||
beforeEach(() => {
|
||||
items = getTimelineItemsfromConversation({
|
||||
isConversationOwnedByCurrentUser: true,
|
||||
chatService: mockChatService,
|
||||
hasConnector: true,
|
||||
currentUser: {
|
||||
|
@ -376,6 +382,7 @@ describe('getTimelineItemsFromConversation', () => {
|
|||
beforeEach(() => {
|
||||
mockChatService.hasRenderFunction.mockImplementation(() => false);
|
||||
items = getTimelineItemsfromConversation({
|
||||
isConversationOwnedByCurrentUser: true,
|
||||
chatService: mockChatService,
|
||||
hasConnector: true,
|
||||
chatState: ChatState.Ready,
|
||||
|
@ -445,6 +452,7 @@ describe('getTimelineItemsFromConversation', () => {
|
|||
describe('while the chat is loading', () => {
|
||||
const renderWithLoading = (extraMessages: Message[]) => {
|
||||
items = getTimelineItemsfromConversation({
|
||||
isConversationOwnedByCurrentUser: true,
|
||||
chatService: mockChatService,
|
||||
hasConnector: true,
|
||||
chatState: ChatState.Loading,
|
||||
|
|
|
@ -60,18 +60,22 @@ function FunctionName({ name: functionName }: { name: string }) {
|
|||
}
|
||||
|
||||
export function getTimelineItemsfromConversation({
|
||||
conversationId,
|
||||
chatService,
|
||||
currentUser,
|
||||
hasConnector,
|
||||
messages,
|
||||
chatState,
|
||||
isConversationOwnedByCurrentUser,
|
||||
onActionClick,
|
||||
}: {
|
||||
conversationId?: string;
|
||||
chatService: ObservabilityAIAssistantChatService;
|
||||
currentUser?: Pick<AuthenticatedUser, 'username' | 'full_name'>;
|
||||
hasConnector: boolean;
|
||||
messages: Message[];
|
||||
chatState: ChatState;
|
||||
isConversationOwnedByCurrentUser: boolean;
|
||||
onActionClick: ({
|
||||
message,
|
||||
payload,
|
||||
|
@ -89,12 +93,14 @@ export function getTimelineItemsfromConversation({
|
|||
loading: false,
|
||||
message: {
|
||||
'@timestamp': new Date().toISOString(),
|
||||
message: { role: MessageRole.User },
|
||||
message: {
|
||||
role: !!conversationId && !currentUser ? MessageRole.System : MessageRole.User,
|
||||
},
|
||||
},
|
||||
title: i18n.translate('xpack.aiAssistant.conversationStartTitle', {
|
||||
defaultMessage: 'started a conversation',
|
||||
}),
|
||||
role: MessageRole.User,
|
||||
role: !!conversationId && !currentUser ? MessageRole.System : MessageRole.User,
|
||||
},
|
||||
...messages.map((message, index) => {
|
||||
const id = v4();
|
||||
|
@ -192,14 +198,14 @@ export function getTimelineItemsfromConversation({
|
|||
|
||||
content = convertMessageToMarkdownCodeBlock(message.message);
|
||||
|
||||
actions.canEdit = hasConnector;
|
||||
actions.canEdit = hasConnector && isConversationOwnedByCurrentUser;
|
||||
display.collapsed = true;
|
||||
} else {
|
||||
// is a prompt by the user
|
||||
title = '';
|
||||
content = message.message.content;
|
||||
|
||||
actions.canEdit = hasConnector;
|
||||
actions.canEdit = hasConnector && isConversationOwnedByCurrentUser;
|
||||
display.collapsed = false;
|
||||
}
|
||||
|
||||
|
@ -212,8 +218,8 @@ export function getTimelineItemsfromConversation({
|
|||
break;
|
||||
|
||||
case MessageRole.Assistant:
|
||||
actions.canRegenerate = hasConnector;
|
||||
actions.canGiveFeedback = true;
|
||||
actions.canRegenerate = hasConnector && isConversationOwnedByCurrentUser;
|
||||
actions.canGiveFeedback = isConversationOwnedByCurrentUser;
|
||||
display.hide = false;
|
||||
|
||||
// is a function suggestion by the assistant
|
||||
|
@ -240,7 +246,7 @@ export function getTimelineItemsfromConversation({
|
|||
display.collapsed = true;
|
||||
}
|
||||
|
||||
actions.canEdit = true;
|
||||
actions.canEdit = isConversationOwnedByCurrentUser;
|
||||
} else {
|
||||
// is an assistant response
|
||||
title = '';
|
||||
|
|
|
@ -34,7 +34,7 @@ export interface UseChatResult {
|
|||
messages: Message[];
|
||||
setMessages: (messages: Message[]) => void;
|
||||
state: ChatState;
|
||||
next: (messages: Message[]) => void;
|
||||
next: (messages: Message[], onError?: (error: any) => void) => void;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
|
@ -131,7 +131,7 @@ function useChatWithoutContext({
|
|||
);
|
||||
|
||||
const next = useCallback(
|
||||
async (nextMessages: Message[]) => {
|
||||
async (nextMessages: Message[], onError?: (error: any) => void) => {
|
||||
// make sure we ignore any aborts for the previous signal
|
||||
abortControllerRef.current.signal.removeEventListener('abort', handleSignalAbort);
|
||||
|
||||
|
@ -239,6 +239,7 @@ function useChatWithoutContext({
|
|||
error: (error) => {
|
||||
setPendingMessages([]);
|
||||
setMessages(nextMessages.concat(getPendingMessages()));
|
||||
onError?.(error);
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -105,6 +105,31 @@ const createConversationRoute = createObservabilityAIAssistantServerRoute({
|
|||
},
|
||||
});
|
||||
|
||||
const duplicateConversationRoute = createObservabilityAIAssistantServerRoute({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversation/{conversationId}/duplicate',
|
||||
params: t.type({
|
||||
path: t.type({
|
||||
conversationId: 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();
|
||||
}
|
||||
|
||||
return client.duplicateConversation(params.path.conversationId);
|
||||
},
|
||||
});
|
||||
|
||||
const updateConversationRoute = createObservabilityAIAssistantServerRoute({
|
||||
endpoint: 'PUT /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
params: t.type({
|
||||
|
@ -198,4 +223,5 @@ export const conversationRoutes = {
|
|||
...updateConversationRoute,
|
||||
...updateConversationTitle,
|
||||
...deleteConversationRoute,
|
||||
...duplicateConversationRoute,
|
||||
};
|
||||
|
|
|
@ -158,6 +158,12 @@ describe('Observability AI Assistant client', () => {
|
|||
functionClientMock.hasAction.mockReturnValue(false);
|
||||
functionClientMock.getActions.mockReturnValue([]);
|
||||
|
||||
internalUserEsClientMock.search.mockResolvedValue({
|
||||
hits: {
|
||||
hits: [],
|
||||
},
|
||||
} as any);
|
||||
|
||||
currentUserEsClientMock.search.mockResolvedValue({
|
||||
hits: {
|
||||
hits: [],
|
||||
|
@ -510,6 +516,9 @@ describe('Observability AI Assistant client', () => {
|
|||
labels: {},
|
||||
numeric_labels: {},
|
||||
public: false,
|
||||
user: {
|
||||
name: 'johndoe',
|
||||
},
|
||||
messages: [user('How many alerts do I have?')],
|
||||
},
|
||||
},
|
||||
|
|
|
@ -134,6 +134,17 @@ export class ObservabilityAIAssistantClient {
|
|||
};
|
||||
};
|
||||
|
||||
private isConversationOwnedByUser = (conversation: Conversation): boolean => {
|
||||
const user = this.dependencies.user;
|
||||
if (!conversation.user || !user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return conversation.user.id
|
||||
? conversation.user.id === user.id
|
||||
: conversation.user.name === user.name;
|
||||
};
|
||||
|
||||
get = async (conversationId: string): Promise<Conversation> => {
|
||||
const conversation = await this.getConversationWithMetaFields(conversationId);
|
||||
|
||||
|
@ -288,96 +299,108 @@ export class ObservabilityAIAssistantClient {
|
|||
shareReplay()
|
||||
);
|
||||
|
||||
const output$ = mergeOperator(
|
||||
// get all the events from continuing the conversation
|
||||
nextEvents$,
|
||||
// wait until all dependencies have completed
|
||||
forkJoin([
|
||||
// get just the new messages
|
||||
nextEvents$.pipe(extractMessages()),
|
||||
// get just the title, and drop the token count events
|
||||
title$.pipe(filter((value): value is string => typeof value === 'string')),
|
||||
systemMessage$,
|
||||
]).pipe(
|
||||
switchMap(([addedMessages, title, systemMessage]) => {
|
||||
const initialMessagesWithAddedMessages = initialMessages.concat(addedMessages);
|
||||
const conversationWithMetaFields$ = from(
|
||||
this.getConversationWithMetaFields(conversationId)
|
||||
).pipe(
|
||||
switchMap((conversation) => {
|
||||
if (isConversationUpdate && !conversation) {
|
||||
return throwError(() => createConversationNotFoundError());
|
||||
}
|
||||
|
||||
const lastMessage = last(initialMessagesWithAddedMessages);
|
||||
if (conversation?._source && !this.isConversationOwnedByUser(conversation._source)) {
|
||||
return throwError(
|
||||
() => new Error('Cannot update conversation that is not owned by the user')
|
||||
);
|
||||
}
|
||||
|
||||
// if a function request is at the very end, close the stream to consumer
|
||||
// without persisting or updating the conversation. we need to wait
|
||||
// on the function response to have a valid conversation
|
||||
const isFunctionRequest = !!lastMessage?.message.function_call?.name;
|
||||
return of(conversation);
|
||||
})
|
||||
);
|
||||
|
||||
if (!persist || isFunctionRequest) {
|
||||
return of();
|
||||
}
|
||||
const output$ = conversationWithMetaFields$.pipe(
|
||||
switchMap((conversation) => {
|
||||
return mergeOperator(
|
||||
// get all the events from continuing the conversation
|
||||
nextEvents$,
|
||||
// wait until all dependencies have completed
|
||||
forkJoin([
|
||||
// get just the new messages
|
||||
nextEvents$.pipe(extractMessages()),
|
||||
// get just the title, and drop the token count events
|
||||
title$.pipe(filter((value): value is string => typeof value === 'string')),
|
||||
systemMessage$,
|
||||
]).pipe(
|
||||
switchMap(([addedMessages, title, systemMessage]) => {
|
||||
const initialMessagesWithAddedMessages = initialMessages.concat(addedMessages);
|
||||
|
||||
if (isConversationUpdate) {
|
||||
return from(this.getConversationWithMetaFields(conversationId))
|
||||
.pipe(
|
||||
switchMap((conversation) => {
|
||||
if (!conversation) {
|
||||
return throwError(() => createConversationNotFoundError());
|
||||
}
|
||||
const lastMessage = last(initialMessagesWithAddedMessages);
|
||||
|
||||
return from(
|
||||
this.update(
|
||||
conversationId,
|
||||
// if a function request is at the very end, close the stream to consumer
|
||||
// without persisting or updating the conversation. we need to wait
|
||||
// on the function response to have a valid conversation
|
||||
const isFunctionRequest = !!lastMessage?.message.function_call?.name;
|
||||
|
||||
merge(
|
||||
{},
|
||||
if (!persist || isFunctionRequest) {
|
||||
return of();
|
||||
}
|
||||
|
||||
// base conversation without messages
|
||||
omit(conversation._source, 'messages'),
|
||||
if (isConversationUpdate && conversation) {
|
||||
return from(
|
||||
this.update(
|
||||
conversationId,
|
||||
|
||||
// update messages and system message
|
||||
{ messages: initialMessagesWithAddedMessages, systemMessage },
|
||||
merge(
|
||||
{},
|
||||
|
||||
// update title
|
||||
{
|
||||
conversation: {
|
||||
title: title || conversation._source?.conversation.title,
|
||||
},
|
||||
}
|
||||
)
|
||||
// base conversation without messages
|
||||
omit(conversation._source, 'messages'),
|
||||
|
||||
// update messages and system message
|
||||
{ messages: initialMessagesWithAddedMessages, systemMessage },
|
||||
|
||||
// update title
|
||||
{
|
||||
conversation: {
|
||||
title: title || conversation._source?.conversation.title,
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
)
|
||||
).pipe(
|
||||
map((conversationUpdated): ConversationUpdateEvent => {
|
||||
return {
|
||||
conversation: conversationUpdated.conversation,
|
||||
type: StreamingChatResponseEventType.ConversationUpdate,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return from(
|
||||
this.create({
|
||||
'@timestamp': new Date().toISOString(),
|
||||
conversation: {
|
||||
title,
|
||||
id: conversationId,
|
||||
},
|
||||
public: !!isPublic,
|
||||
labels: {},
|
||||
numeric_labels: {},
|
||||
systemMessage,
|
||||
messages: initialMessagesWithAddedMessages,
|
||||
})
|
||||
)
|
||||
.pipe(
|
||||
map((conversation): ConversationUpdateEvent => {
|
||||
).pipe(
|
||||
map((conversationCreated): ConversationCreateEvent => {
|
||||
return {
|
||||
conversation: conversation.conversation,
|
||||
type: StreamingChatResponseEventType.ConversationUpdate,
|
||||
conversation: conversationCreated.conversation,
|
||||
type: StreamingChatResponseEventType.ConversationCreate,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return from(
|
||||
this.create({
|
||||
'@timestamp': new Date().toISOString(),
|
||||
conversation: {
|
||||
title,
|
||||
id: conversationId,
|
||||
},
|
||||
public: !!isPublic,
|
||||
labels: {},
|
||||
numeric_labels: {},
|
||||
systemMessage,
|
||||
messages: initialMessagesWithAddedMessages,
|
||||
})
|
||||
).pipe(
|
||||
map((conversation): ConversationCreateEvent => {
|
||||
return {
|
||||
conversation: conversation.conversation,
|
||||
type: StreamingChatResponseEventType.ConversationCreate,
|
||||
};
|
||||
})
|
||||
);
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return output$.pipe(
|
||||
|
@ -537,6 +560,10 @@ export class ObservabilityAIAssistantClient {
|
|||
throw notFound();
|
||||
}
|
||||
|
||||
if (!this.isConversationOwnedByUser(persistedConversation._source!)) {
|
||||
throw new Error('Cannot update conversation that is not owned by the user');
|
||||
}
|
||||
|
||||
const updatedConversation: Conversation = merge(
|
||||
{},
|
||||
conversation,
|
||||
|
@ -604,6 +631,23 @@ export class ObservabilityAIAssistantClient {
|
|||
return createdConversation;
|
||||
};
|
||||
|
||||
duplicateConversation = async (conversationId: string): Promise<Conversation> => {
|
||||
const conversation = await this.getConversationWithMetaFields(conversationId);
|
||||
|
||||
if (!conversation) {
|
||||
throw notFound();
|
||||
}
|
||||
const _source = conversation._source!;
|
||||
return this.create({
|
||||
..._source,
|
||||
conversation: {
|
||||
..._source.conversation,
|
||||
id: v4(),
|
||||
},
|
||||
public: false,
|
||||
});
|
||||
};
|
||||
|
||||
recall = async ({
|
||||
queries,
|
||||
categories,
|
||||
|
|
|
@ -249,6 +249,291 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
|
|||
});
|
||||
});
|
||||
|
||||
describe('when creating private and public conversations', () => {
|
||||
before(async () => {
|
||||
const promises = [
|
||||
{
|
||||
username: 'editor' as const,
|
||||
isPublic: true,
|
||||
},
|
||||
{
|
||||
username: 'editor' as const,
|
||||
isPublic: false,
|
||||
},
|
||||
{
|
||||
username: 'admin' as const,
|
||||
isPublic: true,
|
||||
},
|
||||
{
|
||||
username: 'admin' as const,
|
||||
isPublic: false,
|
||||
},
|
||||
].map(async ({ username, isPublic }) => {
|
||||
const { status } = await observabilityAIAssistantAPIClient[username]({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversation',
|
||||
params: {
|
||||
body: {
|
||||
conversation: {
|
||||
...conversationCreate,
|
||||
public: isPublic,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).to.be(200);
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
async function deleteConversations(username: 'editor' | 'admin') {
|
||||
const response = await observabilityAIAssistantAPIClient[username]({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversations',
|
||||
});
|
||||
|
||||
for (const conversation of response.body.conversations) {
|
||||
await observabilityAIAssistantAPIClient[username]({
|
||||
endpoint: `DELETE /internal/observability_ai_assistant/conversation/{conversationId}`,
|
||||
params: {
|
||||
path: {
|
||||
conversationId: conversation.conversation.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await deleteConversations('editor');
|
||||
await deleteConversations('admin');
|
||||
});
|
||||
|
||||
it('user_1 can retrieve their own private and public conversations', async () => {
|
||||
const { status, body } = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversations',
|
||||
});
|
||||
|
||||
expect(status).to.be(200);
|
||||
expect(body.conversations).to.have.length(3);
|
||||
expect(body.conversations.filter((conversation) => !conversation.public)).to.have.length(1);
|
||||
expect(body.conversations.filter((conversation) => conversation.public)).to.have.length(2);
|
||||
});
|
||||
|
||||
it('user_2 can retrieve their own private and public conversations', async () => {
|
||||
const { status, body } = await observabilityAIAssistantAPIClient.admin({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversations',
|
||||
});
|
||||
|
||||
expect(status).to.be(200);
|
||||
expect(body.conversations).to.have.length(3);
|
||||
expect(body.conversations.filter((conversation) => !conversation.public)).to.have.length(1);
|
||||
expect(body.conversations.filter((conversation) => conversation.public)).to.have.length(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public conversation ownership checks', () => {
|
||||
let createdConversationId: string;
|
||||
|
||||
before(async () => {
|
||||
const { status, body } = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversation',
|
||||
params: {
|
||||
body: {
|
||||
conversation: {
|
||||
...conversationCreate,
|
||||
public: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
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 (editor) to update their public conversation', async () => {
|
||||
const updateRequest = {
|
||||
...conversationUpdate,
|
||||
conversation: {
|
||||
...conversationUpdate.conversation,
|
||||
id: createdConversationId,
|
||||
title: 'Public conversation updated by owner',
|
||||
},
|
||||
};
|
||||
|
||||
const updateResponse = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'PUT /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
params: {
|
||||
path: { conversationId: createdConversationId },
|
||||
body: { conversation: updateRequest },
|
||||
},
|
||||
});
|
||||
expect(updateResponse.status).to.be(200);
|
||||
expect(updateResponse.body.conversation.title).to.eql(
|
||||
'Public conversation updated by owner'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not allow a different user (admin) to update the same public conversation', async () => {
|
||||
const updateRequest = {
|
||||
...conversationUpdate,
|
||||
conversation: {
|
||||
...conversationUpdate.conversation,
|
||||
id: createdConversationId,
|
||||
title: 'Trying to update by a different user',
|
||||
},
|
||||
};
|
||||
|
||||
const updateResponse = await observabilityAIAssistantAPIClient.admin({
|
||||
endpoint: 'PUT /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
params: {
|
||||
path: { conversationId: createdConversationId },
|
||||
body: { conversation: updateRequest },
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateResponse.status).to.be(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversation duplication', () => {
|
||||
let publicConversationId: string;
|
||||
let privateConversationId: string;
|
||||
|
||||
before(async () => {
|
||||
const publicCreateResp = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversation',
|
||||
params: {
|
||||
body: {
|
||||
conversation: {
|
||||
...conversationCreate,
|
||||
public: true,
|
||||
conversation: {
|
||||
...conversationCreate.conversation,
|
||||
title: 'Public conversation',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(publicCreateResp.status).to.be(200);
|
||||
publicConversationId = publicCreateResp.body.conversation.id;
|
||||
|
||||
const privateCreateResp = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/conversation',
|
||||
params: {
|
||||
body: {
|
||||
conversation: {
|
||||
...conversationCreate,
|
||||
public: false,
|
||||
conversation: {
|
||||
...conversationCreate.conversation,
|
||||
title: 'Private conversation',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(privateCreateResp.status).to.be(200);
|
||||
privateConversationId = privateCreateResp.body.conversation.id;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
for (const id of [publicConversationId, privateConversationId]) {
|
||||
const { status } = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'DELETE /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
params: { path: { conversationId: id } },
|
||||
});
|
||||
expect(status).to.be(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows the owner to duplicate their own private conversation', async () => {
|
||||
const duplicateResponse = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint:
|
||||
'POST /internal/observability_ai_assistant/conversation/{conversationId}/duplicate',
|
||||
params: {
|
||||
path: { conversationId: privateConversationId },
|
||||
},
|
||||
});
|
||||
expect(duplicateResponse.status).to.be(200);
|
||||
|
||||
const duplicatedId = duplicateResponse.body.conversation.id;
|
||||
expect(duplicatedId).not.to.eql(privateConversationId);
|
||||
expect(duplicateResponse.body.user?.name).to.eql('elastic_editor');
|
||||
|
||||
const { status } = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'DELETE /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
params: { path: { conversationId: duplicatedId } },
|
||||
});
|
||||
expect(status).to.be(200);
|
||||
});
|
||||
|
||||
it('allows the owner to duplicate their own public conversation', async () => {
|
||||
const duplicateResponse = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint:
|
||||
'POST /internal/observability_ai_assistant/conversation/{conversationId}/duplicate',
|
||||
params: {
|
||||
path: { conversationId: publicConversationId },
|
||||
},
|
||||
});
|
||||
expect(duplicateResponse.status).to.be(200);
|
||||
|
||||
const duplicatedId = duplicateResponse.body.conversation.id;
|
||||
expect(duplicatedId).not.to.eql(publicConversationId);
|
||||
expect(duplicateResponse.body.user?.name).to.eql('elastic_editor');
|
||||
|
||||
const { status } = await observabilityAIAssistantAPIClient.editor({
|
||||
endpoint: 'DELETE /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
params: { path: { conversationId: duplicatedId } },
|
||||
});
|
||||
expect(status).to.be(200);
|
||||
});
|
||||
|
||||
it('allows another user to duplicate a public conversation, making them the new owner', async () => {
|
||||
const duplicateResponse = await observabilityAIAssistantAPIClient.admin({
|
||||
endpoint:
|
||||
'POST /internal/observability_ai_assistant/conversation/{conversationId}/duplicate',
|
||||
params: {
|
||||
path: { conversationId: publicConversationId },
|
||||
},
|
||||
});
|
||||
expect(duplicateResponse.status).to.be(200);
|
||||
|
||||
const duplicatedId = duplicateResponse.body.conversation.id;
|
||||
expect(duplicatedId).not.to.eql(publicConversationId);
|
||||
expect(duplicateResponse.body.user?.name).to.eql('elastic_admin');
|
||||
|
||||
const { status } = await observabilityAIAssistantAPIClient.admin({
|
||||
endpoint: 'DELETE /internal/observability_ai_assistant/conversation/{conversationId}',
|
||||
params: { path: { conversationId: duplicatedId } },
|
||||
});
|
||||
expect(status).to.be(200);
|
||||
});
|
||||
|
||||
it('does not allow another user to duplicate a private conversation', async () => {
|
||||
const duplicateResponse = await observabilityAIAssistantAPIClient.admin({
|
||||
endpoint:
|
||||
'POST /internal/observability_ai_assistant/conversation/{conversationId}/duplicate',
|
||||
params: {
|
||||
path: { conversationId: privateConversationId },
|
||||
},
|
||||
});
|
||||
expect(duplicateResponse.status).to.be(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('security roles and access privileges', () => {
|
||||
describe('should deny access for users without the ai_assistant privilege', () => {
|
||||
let createResponse: Awaited<
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue