实现RAGFlow-0.14.1的输入框多行输入和消息框的多行显示

发布于:2024-12-09 ⋅ 阅读:(147) ⋅ 点赞:(0)

 一、Chat页面输入框的修改


1. macOS配置

我使用MacBook Pro,chip 是 Apple M3 Pro,Memory是18GB,macOS是 Sonoma 14.6.1。

2. 修改chat输入框代码

目前RAGFlow前端的chat功能,输入的内容是单行的,不能主动使用Shift+Enter实现分行。根据 src/pages/chat/index.tsx 文件,可以看出该文件是聊天页面的主入口,整体结构是将聊天内容通过 <ChatContainer /> 组件呈现。因此,如果要实现多行文本框功能,主要修改点会在 ChatContainer 组件的实现中。

chat/chat-container/index.tsx 中,可以看到消息输入功能是通过 <MessageInput /> 组件实现的。如果需要将单行输入框改为支持多行输入的 TextArea,需要修改 MessageInput 组件的实现。

修改src/components/message-input/index.tsx的代码如下:

return (
    <Flex
      className={styles.messageInputWrapper}
      style={{
        backgroundColor: '#f7f8fa', // 淡灰色背景
        border: '1px solid #e0e0e0', // 外部边框颜色
        borderRadius: '12px', // 圆角增加为原来的 1.5 倍
        padding: '10px 12px', // 内边距
        boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', // 添加阴影
      }}
      vertical
    >
      {/* 输入框 */}
      <Input.TextArea
        size="large"
        placeholder={t('sendPlaceholder')}
        value={value}
        disabled={disabled}
        autoSize={{ minRows: 1, maxRows: 6 }} // 默认一行,自动调整至 6 行
        style={{
          flex: 1,
          border: 'none', // 禁用自带边框
          outline: 'none', // 去掉选中高亮
          boxShadow: 'none', // 禁用焦点样式
          resize: 'none', // 禁用用户手动调整大小
          fontSize: '14px',
          lineHeight: '20px', // 行高,保证单行内容视觉效果
          padding: '0', // 去掉多余的填充
          // overflow: 'hidden', // 禁止滚动条显示
          backgroundColor: '#f7f8fa', // 与外层背景色一致
        }}
        onPressEnter={(e) => {
          if (!e.shiftKey) {
            e.preventDefault();
            handlePressEnter();
          }
        }}
        onChange={onInputChange as ChangeEventHandler<HTMLTextAreaElement>}
      />

      {/* 按钮区域 */}
      <Flex
        justify="space-between"
        align="center"
        style={{
          marginTop: '8px',
        }}
      >
        {showUploadIcon && (
          <Upload
            onPreview={handlePreview}
            onChange={handleChange}
            multiple={false}
            onRemove={handleRemove}
            showUploadList={false}
            beforeUpload={() => {
              return false;
            }}
          >
            <Button
              type={'text'}
              disabled={disabled}
              icon={
                <SvgIcon
                  name="paper-clip"
                  width={18}
                  height={22}
                  disabled={disabled}
                ></SvgIcon>
              }
            ></Button>
          </Upload>
        )}
        <Button
          type="primary"
          onClick={handlePressEnter}
          loading={sendLoading}
          disabled={sendDisabled || isUploadingFile}
          style={{
            height: '40px',
            borderRadius: '12px', // 按钮圆角同步调整
            padding: '0 16px',
          }}
        >
          {t('send')}
        </Button>
      </Flex>

 实际页面输入效果如下:

2.1 替换Input为 Input.TextArea

Input 替换为 Input.TextArea,并添加 autoSize 属性,以实现多行输入框的自动伸缩功能。

2.2 修改发送逻辑

在原有逻辑中,按 Enter 会直接触发消息发送。对于多行输入框,需要支持:

  • Shift + Enter 换行。

  • Enter 发送消息。

上面代码中,onPressEnter 事件已经处理了此逻辑。

  

二、Agent Flow页面中输入框的修改

项目的Agent页面上还有chat,改了component下的message-input,对这个chat不起作用。修改src/pages/flow/box.tsx,关键点说明:

  • Input.TextArea 的使用

    • 替换了原来的 Input,支持多行输入。
    • autoSize 参数允许输入框高度根据内容自动扩展。
  • Shift + Enter 处理

    • 检测 e.shiftKey 是否被按下。
    • Shift 被按下时,不触发消息发送,只换行。
    • 当未按下 Shift 时,发送消息并阻止默认行为。
  • suffix 按钮

    • 保留了发送按钮的逻辑,用户也可以点击按钮发送消息。
return (
    <>
      <Flex flex={1} className={styles.chatContainer} vertical>
        <Flex flex={1} vertical className={styles.messageContainer}>
          <div>
            <Spin spinning={loading}>
              {derivedMessages?.map((message, i) => {
                return (
                  <MessageItem
                    loading={
                      message.role === MessageType.Assistant &&
                      sendLoading &&
                      derivedMessages.length - 1 === i
                    }
                    key={message.id}
                    nickname={userInfo.nickname}
                    avatar={userInfo.avatar}
                    item={message}
                    reference={buildMessageItemReference(
                      { message: derivedMessages, reference },
                      message,
                    )}
                    clickDocumentButton={clickDocumentButton}
                    index={i}
                    showLikeButton={false}
                    sendLoading={sendLoading}
                  ></MessageItem>
                );
              })}
            </Spin>
          </div>
          <div ref={ref} />
        </Flex>
        <Flex
          align="flex-start" // 改为 flex-start,使内容顶部对齐
          style={{
            padding: '12px 20px',
            backgroundColor: '#ffffff', // 白色背景
            borderTop: '1px solid #e8e8e8', // 分割线颜色
            position: 'sticky', // 固定在底部
            bottom: 0,
            zIndex: 100, // 确保浮于内容上方
          }}
        >
          <Input.TextArea
            placeholder={t('sendPlaceholder')}
            value={value}
            autoSize={{ minRows: 1, maxRows: 6 }} // 自动调整高度
            onChange={handleInputChange as React.ChangeEventHandler<HTMLTextAreaElement>}
            onPressEnter={(e) => {
              if (!e.shiftKey) { // Shift+Enter 换行
                e.preventDefault();
                handlePressEnter();
              }
            }}
            style={{
              flex: 1,
              border: '1px solid #e0e0e0', // 边框颜色
              borderRadius: '8px', // 圆角边框
              padding: '10px 12px',
              fontSize: '14px',
              lineHeight: '20px',
              boxShadow: 'none', // 去除阴影
              resize: 'none', // 禁止拖动调整大小
            }}
          />
          <Button
            type="primary"
            onClick={handlePressEnter}
            loading={sendLoading}
            style={{
              marginLeft: '10px',
              borderRadius: '8px',
              padding: '0 16px',
              height: '40px',
              fontSize: '14px',
              display: 'flex',
              alignItems: 'center', // 保持内容居中
              justifyContent: 'center',
              marginTop: 'auto', // 自动保持按钮与输入框底部对齐
            }}
          >
            {t('send')}
          </Button>
        </Flex>
      </Flex>
      <PdfDrawer
        visible={visible}
        hideModal={hideModal}
        documentId={documentId}
        chunk={selectedChunk}
      ></PdfDrawer>
    </>
  );

实际页面输入效果如下:

三、消息框中的显示内容的修改

虽然对话的多行输入没有问题了,对话chat上的消息显示没有跟随输入分行,只是将分行的地方加了一个空格,显得很怪异,现在将chat的消息显示也适配一下多行。


1. 修改src/components/message-item/index.tsx:

要实现消息内容中的换行处理,确保用户输入的内容能够正确地显示多行,我们需要确保在 MessageItem 组件中渲染消息文本时能够正确处理换行符。

修改目标:

  1. 支持多行显示:当用户发送多行消息时,确保文本能够按行显示,而不仅仅是将换行符替换为空格。
  2. CSS 样式处理:通过合适的 CSS 属性(如 white-space: pre-line)来保留换行符。

主要改动:

  • MessageItem 组件中确保显示消息的部分使用正确的 white-space 样式。
  • 如果 item.content 包含换行符,它们将被正确处理并显示为多行。
import { ReactComponent as AssistantIcon } from '@/assets/svg/assistant.svg';
import { MessageType } from '@/constants/chat';
import { useSetModalState } from '@/hooks/common-hooks';
import { IReference } from '@/interfaces/database/chat';
import { IChunk } from '@/interfaces/database/knowledge';
import classNames from 'classnames';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';

import {
  useFetchDocumentInfosByIds,
  useFetchDocumentThumbnailsByIds,
} from '@/hooks/document-hooks';
import { IRegenerateMessage, IRemoveMessageById } from '@/hooks/logic-hooks';
import { IMessage } from '@/pages/chat/interface';
import MarkdownContent from '@/pages/chat/markdown-content';
import { getExtension, isImage } from '@/utils/document-util';
import { Avatar, Button, Flex, List, Space, Typography } from 'antd';
import FileIcon from '../file-icon';
import IndentedTreeModal from '../indented-tree/modal';
import NewDocumentLink from '../new-document-link';
import { AssistantGroupButton, UserGroupButton } from './group-button';
import styles from './index.less';

const { Text } = Typography;

interface IProps extends Partial<IRemoveMessageById>, IRegenerateMessage {
  item: IMessage;
  reference: IReference;
  loading?: boolean;
  sendLoading?: boolean;
  nickname?: string;
  avatar?: string;
  clickDocumentButton?: (documentId: string, chunk: IChunk) => void;
  index: number;
  showLikeButton?: boolean;
}

const MessageItem = ({
  item,
  reference,
  loading = false,
  avatar = '',
  sendLoading = false,
  clickDocumentButton,
  index,
  removeMessageById,
  regenerateMessage,
  showLikeButton = true,
}: IProps) => {
  const isAssistant = item.role === MessageType.Assistant;
  const isUser = item.role === MessageType.User;
  const { data: documentList, setDocumentIds } = useFetchDocumentInfosByIds();
  const { data: documentThumbnails, setDocumentIds: setIds } =
    useFetchDocumentThumbnailsByIds();
  const { visible, hideModal, showModal } = useSetModalState();
  const [clickedDocumentId, setClickedDocumentId] = useState('');

  const referenceDocumentList = useMemo(() => {
    return reference?.doc_aggs ?? [];
  }, [reference?.doc_aggs]);

  const handleUserDocumentClick = useCallback(
    (id: string) => () => {
      setClickedDocumentId(id);
      showModal();
    },
    [showModal],
  );

  const handleRegenerateMessage = useCallback(() => {
    regenerateMessage?.(item);
  }, [regenerateMessage, item]);

  useEffect(() => {
    const ids = item?.doc_ids ?? [];
    if (ids.length) {
      setDocumentIds(ids);
      const documentIds = ids.filter((x) => !(x in documentThumbnails));
      if (documentIds.length) {
        setIds(documentIds);
      }
    }
  }, [item.doc_ids, setDocumentIds, setIds, documentThumbnails]);

  return (
    <div
      className={classNames(styles.messageItem, {
        [styles.messageItemLeft]: item.role === MessageType.Assistant,
        [styles.messageItemRight]: item.role === MessageType.User,
      })}
    >
      <section
        className={classNames(styles.messageItemSection, {
          [styles.messageItemSectionLeft]: item.role === MessageType.Assistant,
          [styles.messageItemSectionRight]: item.role === MessageType.User,
        })}
      >
        <div
          className={classNames(styles.messageItemContent, {
            [styles.messageItemContentReverse]: item.role === MessageType.User,
          })}
        >
          {item.role === MessageType.User ? (
            <Avatar
              size={40}
              src={
                avatar ??
                'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png'
              }
            />
          ) : (
            <AssistantIcon></AssistantIcon>
          )}
          <Flex vertical gap={8} flex={1}>
            <Space>
              {isAssistant ? (
                index !== 0 && (
                  <AssistantGroupButton
                    messageId={item.id}
                    content={item.content}
                    prompt={item.prompt}
                    showLikeButton={showLikeButton}
                    audioBinary={item.audio_binary}
                  ></AssistantGroupButton>
                )
              ) : (
                <UserGroupButton
                  content={item.content}
                  messageId={item.id}
                  removeMessageById={removeMessageById}
                  regenerateMessage={
                    regenerateMessage && handleRegenerateMessage
                  }
                  sendLoading={sendLoading}
                ></UserGroupButton>
              )}

              {/* <b>{isAssistant ? '' : nickname}</b> */}
            </Space>
            <div
              className={
                isAssistant ? styles.messageText : styles.messageUserText
              }
              style={{ whiteSpace: 'pre-line' }} // 保留换行符并自动换行
            >
              <MarkdownContent
                loading={loading}
                content={item.content}
                reference={reference}
                clickDocumentButton={clickDocumentButton}
              ></MarkdownContent>
            </div>
            {isAssistant && referenceDocumentList.length > 0 && (
              <List
                bordered
                dataSource={referenceDocumentList}
                renderItem={(item) => {
                  return (
                    <List.Item>
                      <Flex gap={'small'} align="center">
                        <FileIcon
                          id={item.doc_id}
                          name={item.doc_name}
                        ></FileIcon>

                        <NewDocumentLink
                          documentId={item.doc_id}
                          documentName={item.doc_name}
                          prefix="document"
                        >
                          {item.doc_name}
                        </NewDocumentLink>
                      </Flex>
                    </List.Item>
                  );
                }}
              />
            )}
            {isUser && documentList.length > 0 && (
              <List
                bordered
                dataSource={documentList}
                renderItem={(item) => {
                  // TODO:
                  const fileThumbnail =
                    documentThumbnails[item.id] || documentThumbnails[item.id];
                  const fileExtension = getExtension(item.name);
                  return (
                    <List.Item>
                      <Flex gap={'small'} align="center">
                        <FileIcon id={item.id} name={item.name}></FileIcon>

                        {isImage(fileExtension) ? (
                          <NewDocumentLink
                            documentId={item.id}
                            documentName={item.name}
                            prefix="document"
                          >
                            {item.name}
                          </NewDocumentLink>
                        ) : (
                          <Button
                            type={'text'}
                            onClick={handleUserDocumentClick(item.id)}
                          >
                            <Text
                              style={{ maxWidth: '40vw' }}
                              ellipsis={{ tooltip: item.name }}
                            >
                              {item.name}
                            </Text>
                          </Button>
                        )}
                      </Flex>
                    </List.Item>
                  );
                }}
              />
            )}
          </Flex>
        </div>
      </section>
      {visible && (
        <IndentedTreeModal
          visible={visible}
          hideModal={hideModal}
          documentId={clickedDocumentId}
        ></IndentedTreeModal>
      )}
    </div>
  );
};

export default memo(MessageItem);

 

2. 修改src/components/message-item/index.less:

要确保文本内容(特别是多行消息)能够正确显示换行符并且样式合理,我们可以对现有的 .messageText.messageUserText 样式做一些调整。以下是针对 index.less 样式的改进:

关键改动:

  1. 保留换行符: 使用 white-space: pre-line 来保留文本中的换行符(\n),并且自动换行。
  2. 避免内容溢出: 适当设置 word-breakoverflow-wrap 属性,以确保长单词或无空格的长文本能够正确换行,避免溢出。
  3. 简化重复的 .messageText.messageUserText 样式: 让这两者有一个统一的基础样式,便于管理。
.messageItem {
  padding: 24px 0;
  .messageItemSection {
    display: inline-block;
  }
  .messageItemSectionLeft {
    width: 80%;
  }
  .messageItemSectionRight {
    // width: 80%;
    // max-width: 50vw;
  }
  .messageItemContent {
    display: inline-flex;
    gap: 20px;
    flex-wrap: wrap;  // 允许内容换行
  }
  .messageItemContentReverse {
    flex-direction: row-reverse;
  }
  .messageText {
    .chunkText();
    padding: 0 14px;
    background-color: rgba(249, 250, 251, 1);
    word-break: break-all;
  }
  /* 共同的文本样式基础 */
  .messageTextBase {
    padding: 6px 10px;
    border-radius: 8px;
    word-wrap: break-word;  // 强制长单词换行
    overflow-wrap: break-word;  // 强制长单词换行
    white-space: pre-line;  // 保留换行符并换行
  }

  /* Assistant 消息文本样式 */
  .messageText {
    .chunkText();
    .messageTextBase();
    background-color: #e6f4ff;
    word-break: break-word;  // 自动换行
  }

  /* User 消息文本样式 */
  .messageUserText {
    .chunkText();
    .messageTextBase();
    background-color: rgb(248, 247, 247);
    word-break: break-word;  // 自动换行
    text-align: justify;  // 用户消息文本两端对齐
  }
  
  .messageEmpty {
    width: 300px;
  }

  .thumbnailImg {
    max-width: 20px;
  }
}

.messageItemLeft {
  text-align: left;
}

.messageItemRight {
  text-align: right;
}

实际对话消息,显示如下: