❤ 感谢点赞👍收藏⭐评论✍

准备:

  • 前端技术栈:React
  • 前端工具:Antd、TailwindCSS、ahooks
  • 相关依赖:react-markdown、remark-gfm、clipboard-polyfill
  • Http请求:fetch
  • 后端数据结构:EventStream 流式结构

实现功能

  • AI 回复内容流式输出
  • 问题回答自动滚动底部
  • 快捷复制
  • 快捷重新询问
  • 输入框 Enter 发送、Ctrl/Shift + Enter 换行、光标位置换行
  • 内容生成过程中,复制、刷新、发送、输入等功能禁用问题

废话不多说,直接上干货!

代码实现

import { FC, useState, useEffect, useRef } from 'react';
import { Input, Button, message } from 'antd';
import { ArrowUpOutlined, LoadingOutlined, CopyOutlined, RedoOutlined, TwitchOutlined } from '@ant-design/icons';
import { useRequest } from 'ahooks';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import * as clipboard from 'clipboard-polyfill';

import './index.less';

/* API */
export const customerServiceOttaiSeek = {
  // AI聊天流式对话
  chatStream: (params: { question: string }) => {
    return fetch(`/xxx?question=${params.question}`);
  },
};

/* 组件 */
const AIChat: FC = () => {
  const scrollRef = useRef<any>();
  const textareaRef = useRef(null);

  const [question, setQuestion] = useState('');
  const [historyList, setHistoryList] = useState <any[]> ([]);
  const [loading, setLoading] = useState(false);

  // 滚动到最底部
  useEffect(() => {
    const scrollToBottom = () => {
      const { current }: any = scrollRef;
      if (current) {
        current.scrollTop = current.scrollHeight;
      }
    };
    scrollToBottom();
  }, [historyList]);

  const { run: handleFetch } = useRequest(async (reqQuestion: string) => {
    setLoading(true);
    const loadingItem = {
      type: 1,
      content: <LoadingOutlined />,
      timeStamp: new Date().getTime(),
      contentType: 'loading',
    };
    setHistoryList((list) => [...list, loadingItem]);

    const data: any = await customerServiceOttaiSeek.chatStream({
      question: reqQuestion,
    })

    const reader = data.body.getReader();
    const decoder = new TextDecoder();
    if (!reader) return;

    let partialData = '';
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;

      const chunk = decoder.decode(value, { stream: true });
      partialData += chunk; // 累积流数据

      // 处理 SSE 格式,每行 `data: {...}`
      const lines = partialData
        .split('\n')
        .filter((line) => line.startsWith('data:'));

      for (const line of lines) {
        try {
          const json = JSON.parse(line.replace(/^data:/, '').trim());
          await new Promise((resolve) => setTimeout(resolve, 50)); // 模拟打字延迟

          // 更新聊天列表
          setHistoryList((prevHistory: any[]) => {
            const lastMessage = prevHistory[prevHistory.length - 1];
            if (lastMessage && lastMessage.isStreaming) {
              // 如果最后一条消息是流式消息,则追加内容
              return [
                ...prevHistory.slice(0, -1),
                {
                  ...lastMessage,
                  type: 1,
                  content: lastMessage.content + json.answer,
                },
              ];
            } else {
              // 否则,添加一条新的流式消息
              return [
                ...prevHistory.slice(0, -1),
                {
                  content: json.answer,
                  isStreaming: true,
                  type: 1,
                  timeStamp: new Date().getTime(),
                },
              ];
            }
          });
        } catch (e) {
          console.error('解析 JSON 失败:', e);
        }
      }

      /**
       * 前面 partialData.split('\n').filter((line) => line.startsWith('data:')) 方法已经将部分解析完整的字段循环遍历展示;
       *
       * 这里通过 lastIndexOf('\n') + 1 将剩余未完整解析的 JSON 继续拼接处理,例如:
       *    a. partialData 为 "\n\ndata:" 时,继续拼接 'data:'
       *    b. partialData 为 "data:{...}\n\ndata\n\n" 时, 也会继续拼接 ''
       *
       * 如果上次的 partialData 刚好为 "data:{...}\n\ndata:{}",则 lastIndexOf('\n') + 1 结果为完整的 JSON => data:{},
       * 但是通过 split('\n') 时已经展示过一次 data:{},所以这里继续拼接到 partialData 中,会导致该 JSON 进入下次循环重复展示!
      */
      const lastData = partialData.slice(partialData.lastIndexOf('\n') + 1);
      if (!(lastData.startsWith('data:{') && lastData.endsWith('}'))) {
        partialData = lastData;
      } else {
        partialData = '';
      }
    }
    setLoading(false);
  }, { manual: true });

  const handleCopy = async (text: string) => {
    if (loading) return;
    await clipboard.writeText(text);
    message.success('复制成功');
  }

  const handleRefresh = (index: number) => {
    if (loading) return;
    const questionInfo  = historyList[index - 1];
    if (questionInfo.type === 0 && questionInfo.content) {
      const searchItem = {
        type: 0,
        content: questionInfo.content,
        timeStamp: new Date().getTime(),
      };
      setHistoryList([...historyList, searchItem]);
      handleFetch(questionInfo.content);
    }
  }

  const handleSearch = () => {
    if (/^\s*$/.test(question)) return;
    const searchItem = {
      type: 0,
      content: question,
      timeStamp: new Date().getTime(),
    };
    setHistoryList([...historyList, searchItem]);
    handleFetch(question);
    setQuestion('');
  }

  const handleKeyDown = (e: any) => {
    // Enter键提交(不配合其他键)
    if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
      e.preventDefault(); // 阻止默认换行行为
      handleSearch();
    } else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
      /**
       * 解决 Ctrl + Enter 换行不生效问题
       * Ctrl+Enter 或 Cmd+Enter(Mac)允许换行(不处理,允许默认行为)
      */
      e.preventDefault();
      insertNewline();
    }
  }

  // 在光标位置插入换行符
  const insertNewline = () => {
    if (!textareaRef.current) return;

    const textarea = (textareaRef.current as any).resizableTextArea.textArea;
    const start = textarea.selectionStart;
    const end = textarea.selectionEnd;

    // 在光标位置插入换行符
    const newQuestion = question.substring(0, start) + '\n' + question.substring(end);

    setQuestion(newQuestion);

    // 更新光标位置
    setTimeout(() => {
      textarea.selectionStart = start + 1;
      textarea.selectionEnd = start + 1;
      textarea.focus();
    }, 0);
  };

  return (
    <div ref={scrollRef} className={`ottai-seek ${historyList.length ? '' : 'justify-center'}`}>
      <div className='w-[80%] pt-[20px]'>
        {
          historyList.length ? (
            historyList.map((item, index) => {
              if (item.type === 0) {
                return (
                  <div className='flex justify-end' key={index}>
                    <p className='bg-[#3B78FF] text-white p-[8px] rounded-[10px]'>
                      {item.content}
                    </p>
                  </div>
                );
              } else {
                return (
                  <div className='flex mt-[12px]' key={index}>
                    <TwitchOutlined className='text-[30px]' />
                    <div className='p-[8px]'>
                      {
                        item.contentType === 'loading' ? (
                          item.content
                        ) : (
                          <div>
                            <Markdown remarkPlugins = { [remarkGfm] }>
                              {item.content}
                            </Markdown>
                          </div>
                        )
                      }
                      <div className='flex gap-[8px] mt-[8px]'>
                        <div
                          className={`${loading ? 'answer-btn-item-disabled' : 'answer-btn-item'}`}
                          onClick={() => handleCopy(item.content)}
                        >
                            <CopyOutlined />
                        </div>
                        <div
                          className={`${loading ? 'answer-btn-item-disabled ' : 'answer-btn-item'}`}
                          onClick={() => handleRefresh(index)}
                        >
                          <RedoOutlined />
                        </div>
                      </div>
                    </div>
                  </div>
                )
              }
            })
          ) : (
            <div className='text-center pb-[20px]'>
              <TwitchOutlined className='text-[30px]' />
              <span className='text-[20px] font-bold mt-[32px] ml-[12px]'>AI 助手</span>
            </div>
          )
        }
      </div>
      {
        !!historyList.length && (
          <div className='mt-auto py-[20px] text-center'>
            <Button
              type='primary'
              ghost
              onClick={() => {
                setHistoryList([]);
                setQuestion('');
              }}
            >
              开启新对话
            </Button>
          </div>
        )
      }
      <div className='sticky bottom-0 w-[80%] pb-[20px] bg-white rounded-t-[10px]'>
        <div className='text-area-box'>
          <Input.TextArea
            ref={textareaRef}
            className='pr-0'
            placeholder='请输入查询内容'
            variant='borderless'
            autoSize={{ minRows: 4 }}
            disabled={loading}
            value={question}
            onChange={(e) => setQuestion(e.target.value)}
            onKeyDown={handleKeyDown}
          />
          <div
            className={`btn ${(!question || loading) ? 'search-disabled-btn' : 'search-btn'}`}
            onClick={handleSearch}
          >
            {loading ? <LoadingOutlined /> : <ArrowUpOutlined />}
          </div>
        </div>
      </div>
    </div>
  )
};

export default AIChat;

// index.less
.ottai-seek {
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  overflow-y: scroll;
  background-color: white;

  .text-area-box {
    display: flex;
    padding: 8px 0;
    background-color: #F8F8F8;
    border-radius: 10px;
  }

  .btn {
    margin-top: auto;
    margin-right: 8px;
    width: 28px;
    height: 28px;
    font-size: 16px;
    color: white;
    border-radius: 14px;
    text-align: center;
  }

  .search-disabled-btn {
    cursor: not-allowed;
    background-color: #BFBFBF;
  }

  .search-btn {
    cursor: pointer;
    background-color: #4d6bfe;
  }

  .search-btn:hover {
    background-color: #2563eb;
  }

  .answer-btn-item, .answer-btn-item-disabled {
    width: 24px;
    height: 24px;
    display: flex;
    justify-content: center;
    align-items: center;
    background-color: #EFEFEF;
    border-radius: 12px;
  }

  .answer-btn-item {
    cursor: pointer;
  }

  .answer-btn-item:hover {
    background-color: #D9D9D9;
  }

  .answer-btn-item-disabled {
    color: rgba(0, 0, 0, 0.3);
    cursor: not-allowed;
  }
}

后端流式数据结构:
在这里插入图片描述
在这里插入图片描述
页面demo预览
1.无会话样式
在这里插入图片描述

2.会话样式
在这里插入图片描述

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐