export function ChatBody()

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