in x-pack/platform/packages/shared/kbn-ai-assistant/src/chat/chat_body.tsx [117:684]
export function ChatBody({
connectors,
currentUser,
flyoutPositionMode,
initialConversationId,
initialMessages,
initialTitle,
knowledgeBase,
showLinkToConversationsApp,
onConversationUpdate,
onToggleFlyoutPositionMode,
navigateToConversation,
setIsUpdatingConversationList,
refreshConversations,
updateDisplayedConversation,
onConversationDuplicate,
}: {
connectors: ReturnType<typeof useGenAIConnectors>;
currentUser?: Pick<AuthenticatedUser, 'full_name' | 'username' | 'profile_uid'>;
flyoutPositionMode?: FlyoutPositionMode;
initialTitle?: string;
initialMessages?: Message[];
initialConversationId?: string;
knowledgeBase: UseKnowledgeBaseResult;
showLinkToConversationsApp: boolean;
onConversationUpdate: (conversation: { conversation: Conversation['conversation'] }) => void;
onConversationDuplicate: (conversation: Conversation) => void;
onToggleFlyoutPositionMode?: (flyoutPositionMode: FlyoutPositionMode) => void;
navigateToConversation?: (conversationId?: string) => void;
setIsUpdatingConversationList: (isUpdating: boolean) => void;
refreshConversations: () => void;
updateDisplayedConversation: (id?: string) => void;
}) {
const license = useLicense();
const hasCorrectLicense = license?.hasAtLeast('enterprise');
const theme = useEuiTheme();
const scrollBarStyles = euiScrollBarStyles(theme);
const { euiTheme } = theme;
const chatService = useAIAssistantChatService();
const {
services: { uiSettings },
} = useKibana();
const simulateFunctionCalling = uiSettings!.get<boolean>(
aiAssistantSimulatedFunctionCalling,
false
);
const {
conversation,
conversationId,
messages,
next,
state,
stop,
saveTitle,
duplicateConversation,
isConversationOwnedByCurrentUser,
user: conversationUser,
updateConversationAccess,
} = useConversation({
currentUser,
initialConversationId,
initialMessages,
initialTitle,
chatService,
connectorId: connectors.selectedConnector,
onConversationUpdate,
onConversationDuplicate,
});
const timelineContainerRef = useRef<HTMLDivElement | null>(null);
const isLoading = Boolean(
connectors.loading ||
knowledgeBase.status.loading ||
state === ChatState.Loading ||
conversation.loading
);
let title = conversation.value?.conversation.title || initialTitle;
if (!title) {
if (!connectors.selectedConnector) {
title = ASSISTANT_SETUP_TITLE;
} else if (!hasCorrectLicense && !initialConversationId) {
title = UPGRADE_LICENSE_TITLE;
} else {
title = EMPTY_CONVERSATION_TITLE;
}
}
const headerContainerClassName = css`
padding-right: ${showLinkToConversationsApp ? '32px' : '0'};
`;
const [stickToBottom, setStickToBottom] = useState(true);
const isAtBottom = (parent: HTMLElement) =>
parent.scrollTop + parent.clientHeight >= parent.scrollHeight;
const [promptEditorHeight, setPromptEditorHeight] = useState<number>(0);
const handleFeedback = (feedback: Feedback) => {
if (conversation.value?.conversation && 'user' in conversation.value) {
const {
messages: _removedMessages, // Exclude messages
systemMessage: _removedSystemMessage, // Exclude systemMessage
conversation: { title: _removedTitle, id, last_updated: lastUpdated }, // Exclude title
user,
labels,
numeric_labels: numericLabels,
namespace,
public: isPublic,
'@timestamp': timestamp,
archived,
} = conversation.value;
const conversationWithoutMessagesAndTitle: ChatFeedback['conversation'] = {
'@timestamp': timestamp,
user,
labels,
numeric_labels: numericLabels,
namespace,
public: isPublic,
archived,
conversation: { id, last_updated: lastUpdated },
};
chatService.sendAnalyticsEvent({
type: ObservabilityAIAssistantTelemetryEventType.ChatFeedback,
payload: {
feedback,
conversation: conversationWithoutMessagesAndTitle,
},
});
}
};
const handleChangeHeight = useCallback((editorHeight: number) => {
if (editorHeight === 0) {
setPromptEditorHeight(0);
} else {
setPromptEditorHeight(editorHeight + PADDING_AND_BORDER);
}
}, []);
useEffect(() => {
const parent = timelineContainerRef.current?.parentElement;
if (!parent) {
return;
}
function onScroll() {
setStickToBottom(isAtBottom(parent!));
}
parent.addEventListener('scroll', onScroll);
return () => {
parent.removeEventListener('scroll', onScroll);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timelineContainerRef.current]);
useEffect(() => {
const parent = timelineContainerRef.current?.parentElement;
if (!parent) {
return;
}
if (stickToBottom) {
parent.scrollTop = parent.scrollHeight;
}
});
const handleActionClick = useCallback(
({ message, payload }: { message: Message; payload: ChatActionClickPayload }) => {
setStickToBottom(true);
switch (payload.type) {
case ChatActionClickType.executeEsqlQuery:
next(
messages.concat({
'@timestamp': new Date().toISOString(),
message: {
role: MessageRole.Assistant,
content: '',
function_call: {
name: 'execute_query',
arguments: JSON.stringify({
query: payload.query,
}),
trigger: MessageRole.User,
},
},
})
);
break;
case ChatActionClickType.updateVisualization:
const visualizeQueryResponse = message;
const visualizeQueryResponseData = JSON.parse(
visualizeQueryResponse.message.data ?? '{}'
);
next(
messages.slice(0, messages.indexOf(visualizeQueryResponse)).concat({
'@timestamp': new Date().toISOString(),
message: {
name: 'visualize_query',
content: visualizeQueryResponse.message.content,
data: JSON.stringify({
...visualizeQueryResponseData,
userOverrides: payload.userOverrides,
}),
role: MessageRole.User,
},
})
);
break;
case ChatActionClickType.visualizeEsqlQuery:
next(
messages.concat({
'@timestamp': new Date().toISOString(),
message: {
role: MessageRole.Assistant,
content: '',
function_call: {
name: 'visualize_query',
arguments: JSON.stringify({
query: payload.query,
intention: VisualizeESQLUserIntention.visualizeAuto,
}),
trigger: MessageRole.User,
},
},
})
);
break;
}
},
[messages, next]
);
const handleConversationAccessUpdate = async (access: ConversationAccess) => {
await updateConversationAccess(access);
conversation.refresh();
refreshConversations();
};
const { copyConversationToClipboard, copyUrl, deleteConversation, archiveConversation } =
useConversationContextMenu({
setIsUpdatingConversationList,
refreshConversations,
});
const handleArchiveConversation = async (id: string, isArchived: boolean) => {
await archiveConversation(id, isArchived);
conversation.refresh();
};
const isPublic = conversation.value?.public;
const isArchived = !!conversation.value?.archived;
const showPromptEditor = !isArchived && (!isPublic || isConversationOwnedByCurrentUser);
const sharedBannerTitle = i18n.translate('xpack.aiAssistant.shareBanner.title', {
defaultMessage: 'This conversation is shared with your team.',
});
const viewerDescription = i18n.translate('xpack.aiAssistant.banner.viewerDescription', {
defaultMessage:
"You can't edit or continue this conversation, but you can duplicate it into a new private conversation. The original conversation will remain unchanged.",
});
const duplicateButton = i18n.translate('xpack.aiAssistant.duplicateButton', {
defaultMessage: 'Duplicate',
});
let sharedBanner = null;
if (isPublic && !isConversationOwnedByCurrentUser) {
sharedBanner = (
<ChatBanner
title={sharedBannerTitle}
description={viewerDescription}
button={
<EuiButton onClick={duplicateConversation} iconType="copy" size="s">
{duplicateButton}
</EuiButton>
}
/>
);
} else if (isConversationOwnedByCurrentUser && isPublic) {
sharedBanner = (
<ChatBanner
title={sharedBannerTitle}
description={i18n.translate('xpack.aiAssistant.shareBanner.ownerDescription', {
defaultMessage:
'Any further edits you do to this conversation will be shared with the rest of the team.',
})}
/>
);
}
let archivedBanner = null;
const archivedBannerTitle = i18n.translate('xpack.aiAssistant.archivedBanner.title', {
defaultMessage: 'This conversation has been archived.',
});
if (isConversationOwnedByCurrentUser) {
archivedBanner = (
<ChatBanner
title={archivedBannerTitle}
icon="folderOpen"
description={i18n.translate('xpack.aiAssistant.archivedBanner.ownerDescription', {
defaultMessage:
"You can't edit or continue this conversation as it's been archived, but you can unarchive it.",
})}
button={
<EuiButton
onClick={() => handleArchiveConversation(conversationId!, !isArchived)}
iconType="folderOpen"
size="s"
>
{i18n.translate('xpack.aiAssistant.unarchiveButton', {
defaultMessage: 'Unarchive',
})}
</EuiButton>
}
/>
);
} else {
archivedBanner = (
<ChatBanner
title={archivedBannerTitle}
icon="folderOpen"
description={viewerDescription}
button={
<EuiButton onClick={duplicateConversation} iconType="copy" size="s">
{duplicateButton}
</EuiButton>
}
/>
);
}
let footer: React.ReactNode;
if (!hasCorrectLicense && !initialConversationId) {
footer = (
<>
<EuiFlexItem grow className={incorrectLicenseContainer(euiTheme)}>
<IncorrectLicensePanel />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHorizontalRule margin="none" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="m">
<PromptEditor
hidden={connectors.loading || connectors.connectors?.length === 0}
loading={isLoading}
disabled
onChangeHeight={setPromptEditorHeight}
onSubmit={(message) => {
next(messages.concat(message));
}}
onSendTelemetry={(eventWithPayload) =>
chatService.sendAnalyticsEvent(eventWithPayload)
}
/>
<EuiSpacer size="s" />
</EuiPanel>
</EuiFlexItem>
</>
);
} else if (!conversation.value && conversation.loading) {
footer = null;
} else {
footer = (
<>
<EuiFlexItem grow className={timelineClassName(scrollBarStyles)}>
<div ref={timelineContainerRef} className={fullHeightClassName}>
<EuiPanel
grow
hasBorder={false}
hasShadow={false}
paddingSize="m"
className={animClassName(euiTheme)}
>
{connectors.connectors?.length === 0 || messages.length === 0 ? (
<WelcomeMessage
connectors={connectors}
knowledgeBase={knowledgeBase}
onSelectPrompt={(message) =>
next(
messages.concat([
{
'@timestamp': new Date().toISOString(),
message: { content: message, role: MessageRole.User },
},
])
)
}
/>
) : (
<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}
isArchived={isArchived}
/>
)}
</EuiPanel>
</div>
</EuiFlexItem>
{simulateFunctionCalling ? (
<EuiFlexItem grow={false}>
<SimulatedFunctionCallingCallout />
</EuiFlexItem>
) : null}
<>
{conversationId && !isArchived ? sharedBanner : null}
{conversationId && isArchived ? archivedBanner : null}
{showPromptEditor ? (
<EuiFlexItem
grow={false}
className={promptEditorClassname(euiTheme)}
css={{ height: promptEditorHeight }}
>
<EuiHorizontalRule margin="none" />
<EuiPanel
hasBorder={false}
hasShadow={false}
paddingSize="m"
color="subdued"
className={promptEditorContainerClassName}
>
<PromptEditor
disabled={!connectors.selectedConnector || !hasCorrectLicense}
hidden={connectors.loading || connectors.connectors?.length === 0}
loading={isLoading}
onChangeHeight={handleChangeHeight}
onSendTelemetry={(eventWithPayload) =>
chatService.sendAnalyticsEvent(eventWithPayload)
}
onSubmit={(message) => {
setStickToBottom(true);
return next(messages.concat(message));
}}
/>
<EuiSpacer size="s" />
</EuiPanel>
</EuiFlexItem>
) : null}
</>
</>
);
}
if (conversation.error) {
return (
<EuiFlexGroup
direction="column"
className={containerClassName}
gutterSize="none"
justifyContent="center"
responsive={false}
>
<EuiFlexItem grow={false} className={chatBodyContainerClassNameWithError}>
<EuiCallOut
color="danger"
title={i18n.translate('xpack.aiAssistant.couldNotFindConversationTitle', {
defaultMessage: 'Conversation not found',
})}
iconType="warning"
>
{i18n.translate('xpack.aiAssistant.couldNotFindConversationContent', {
defaultMessage:
'Could not find a conversation with id {conversationId}. Make sure the conversation exists and you have access to it.',
values: { conversationId: initialConversationId },
})}
</EuiCallOut>
</EuiFlexItem>
</EuiFlexGroup>
);
}
return (
<EuiFlexGroup
direction="column"
gutterSize="none"
className={containerClassName}
responsive={false}
>
<EuiFlexItem
grow={false}
className={conversation.error ? chatBodyContainerClassNameWithError : undefined}
>
{conversation.error ? (
<EuiCallOut
color="danger"
title={i18n.translate('xpack.aiAssistant.couldNotFindConversationTitle', {
defaultMessage: 'Conversation not found',
})}
iconType="warning"
>
{i18n.translate('xpack.aiAssistant.couldNotFindConversationContent', {
defaultMessage:
'Could not find a conversation with id {conversationId}. Make sure the conversation exists and you have access to it.',
values: { conversationId: initialConversationId },
})}
</EuiCallOut>
) : null}
</EuiFlexItem>
<EuiFlexItem grow={false} className={headerContainerClassName}>
<ChatHeader
connectors={connectors}
conversationId={conversationId}
conversation={conversation.value as Conversation}
flyoutPositionMode={flyoutPositionMode}
licenseInvalid={!hasCorrectLicense && !initialConversationId}
loading={isLoading}
title={title}
onDuplicateConversation={duplicateConversation}
onSaveTitle={(newTitle) => {
saveTitle(newTitle);
}}
onToggleFlyoutPositionMode={onToggleFlyoutPositionMode}
navigateToConversation={
initialMessages?.length && !initialConversationId ? undefined : navigateToConversation
}
updateDisplayedConversation={updateDisplayedConversation}
handleConversationAccessUpdate={handleConversationAccessUpdate}
isConversationOwnedByCurrentUser={isConversationOwnedByCurrentUser}
copyConversationToClipboard={copyConversationToClipboard}
copyUrl={copyUrl}
deleteConversation={deleteConversation}
handleArchiveConversation={handleArchiveConversation}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHorizontalRule margin="none" />
</EuiFlexItem>
{footer}
</EuiFlexGroup>
);
}