[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:
Arturo Lidueña 2025-03-04 23:15:37 +01:00 committed by GitHub
parent df59c26083
commit b331fa1c53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 699 additions and 124 deletions

View file

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

View file

@ -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 cant edit or continue this conversation, but you can duplicate
it into a new private conversation. The original conversation will
remain unchanged.`,
})}
</p>
<EuiButton onClick={duplicateConversation} iconType="copy" size="s">
{i18n.translate('xpack.aiAssistant.duplicateButton', {
defaultMessage: 'Duplicate',
})}
</EuiButton>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
<EuiSpacer size="m" />
</>
) : null}
</>
)}
</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}>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,6 +15,7 @@ import type {
import type { ObservabilityAIAssistantChatService } from '@kbn/observability-ai-assistant-plugin/public';
import type { AbortableAsyncState } from '@kbn/observability-ai-assistant-plugin/public';
import type { UseChatResult } from '@kbn/observability-ai-assistant-plugin/public';
import 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,
};
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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