React实现-AI式逐字展示效果

本文将介绍React前端实现AI流式逐字展示效果的主流实现!Server-Sent Events!

一、原理

「机器人式的流式回复」效果如下,内容是逐步根据后端返回的数据进行展示的。

此处为语雀卡片,点击链接查看

如何实现呢?目前,大多数大型模型聊天网站都采用 Server-Sent Events (SSE) 来实现结果的展示。SSE 的优势在于它允许服务器主动向客户端推送消息(实际上是通过保持一个连接的 HTTP 请求实现的),并且服务器向浏览器发送的 SSE 数据必须是 UTF-8 编码的文本。

为什么不是WebSocket?

WebSocket尽管都用于在客户端和服务器之间建立持久的实时通信通道,但与sse的设计目标、使用场景和工作方式存在显著差异。如下所示:

特性Server-Sent Events(SSE)WebSocket
通信方向单向(服务器→客户端)双向(客户端↔️服务器)
协议基于 HTTP 协议WebSocket 协议(ws://
wss://
使用场景实时股票数据 社交媒体更新 新闻流在线聊天 多人游戏 实时协作工具等
性能适用于轻量的单向数据流高效的双向通信,适合高频数据交换
实现复杂度较简单,支持 HTTP/1.1、HTTP/2更复杂,涉及到双向通信、连接管理等
兼容性很好的浏览器支持,尤其适用于现代浏览器广泛支持,但可能受限于防火墙和代理

选择SSE还是WebSocket取决于应用的需求,且只需单向通信,SSE 是一个更简单的选择;如果你需要双向、低延迟的实时交互,WebSocket 是更合适的方案。

二、实现

后端

这里使用nest.js来实现后端代码(nest采用三层架构+依赖注入,与实际Java后端逻辑类似),模拟返回stream数据。为啥不是express?实际开发中,纯前端方面,用express.js开发后端较少。参考NodeJS框架对比

注意点:

1、sse接口需是get请求。

2、Content-Type响应头需要设置 ‘text/event-stream’,表示流式响应。

3、如果线上配置了nginx代理,一定要禁用该接口nginx缓存,设置X-Accel-Buffering,且立即发送头部(坑点,或者在nginx配置处设置,根据情况二选一即可!

// 设置流式stream传输
response.setHeader('Content-Type', 'text/event-stream');
response.setHeader('Connection', 'keep-alive');
response.setHeader('Cache-Control', 'no-cache');

// 禁用 Nginx 的缓冲
response.setHeader('X-Accel-Buffering', 'no'); 
// 立即发送头部
response.flushHeaders();

server {
  listen 80;
  server_name your_domain.com;

  location / {
    #....其他配置

    # 添加这些设置来禁用缓冲,需单独为sse接口配置
    proxy_buffering off;
    proxy_cache off;
    proxy_set_header Cache-Control 'no-cache';
    proxy_set_header Connection 'keep-alive';  

    #...其他配置
    #...
  }
}

具体实现代码如下:

controller层

import { Controller, Get, Post, Body, Patch, Param, Delete, Query, Res } from '@nestjs/common';
import { CardService } from './card.service';

@Controller('card')
  export class CardController {

    //依赖注入
    constructor(private readonly cardService: CardService) { }

    @Get('/getContentAI')
    async getContentAI(@Query('content') content: string, @Res() response) {

      //调用service方法
      const result = await this.cardService.getContentAI(content);

      // 设置流式stream传输
      response.setHeader('Content-Type', 'text/event-stream');
      response.setHeader('Connection', 'keep-alive');
      response.setHeader('Cache-Control', 'no-cache');

      // 禁用 Nginx 的缓冲
      response.setHeader('X-Accel-Buffering', 'no'); 
      // 立即发送头部
      response.flushHeaders();

      // 模拟分段发送数据给前端
      const characters = result.toString();
      let index = 0;
      const interval = setInterval(() => {
        if (index < characters.length) {
          // 生成1-10之间的随机数
          const chunkSize = Math.floor(Math.random() * 5) + 1; 
          const chunk = characters.slice(index, index + chunkSize);
          response.write(`data:${chunk}\n\n`);
          index += chunkSize;
        } else {
          clearInterval(interval);
          response.write('data: DONE');
          response.end();
        }
      }, 100); 


      // 监听客户端断开连接
      response.on('close', () => {
        clearInterval(interval);
      });

    }
  }

service层

import { Injectable } from '@nestjs/common';

@Injectable()
export class CardService {
    //该方法被controller层调用,如上
    async getContentAI() {
      const msg = `《水浒传》是中国四大名著之一,作者施耐庵,后由罗贯中修订。这部小说讲述
    了108位好汉在宋代末年反抗腐败官府、追求正义的故事。书中的主要人物有宋江、
    卢俊义、吴用、林冲等,他们各具特色,有的智勇双全,有的忠义勇敢,聚集在水泊梁山,组成了一个反抗势力。
    故事分为几个部分,开头描写了好汉们的背景和遭遇,随后是他们如何聚集在一起,形
    成梁山泊的强大团队。随着故事的发展,梁山好汉们与官府的冲突不断升级,最终以悲剧收
    尾,反映了对社会不公的控诉和对忠义精神的赞美。
    《水浒传》不仅是中国古代小说的经典之作,也影响了后世的文学、戏剧和影视作品,展
    现了深刻的社会问题和人性的复杂。你对这部作品有什么特别的兴趣或问题吗?`;
      return msg
    }

  }

码友们不想写后端,可以调用现成http://nest.liboscrg.com/prod/card/getContentAI 接口。

前端
UI页面

一个简单的AI对话聊天框,包括SSE代码、交互机UI代码请继续阅读文章!

样式

index.module.scss

组件

AI页面如下所示

import React, { useState, useRef, useEffect } from 'react';
import { SendOutlined, LoadingOutlined, DeleteOutlined } from '@ant-design/icons';
import { Input, Button, Avatar, Spin } from 'antd';
import { throttle } from 'lodash-es';
import axios from 'axios';
import styles from './index.module.scss';

interface Message {
  content: string;
  isUser: boolean;
  timestamp: number;
  isStreaming?: boolean;
}

const Chat: React.FC = () => {
  //输入数据
  const [input, setInput] = useState('');
  //对话问答数据
  const [messages, setMessages] = useState<Message[]>([]);
  //loading
  const [isProcessing, setIsProcessing] = useState(false);
  //打字标
  const messagesEndRef = useRef<HTMLDivElement>(null);

  //发送
  const handleSend = async () => {
    if (!input.trim() || isProcessing) return;

    const userMessage: Message = {
      content: input,
      isUser: true,
      timestamp: Date.now(),
    };
    setMessages(prev => [...prev, userMessage]);
    setInput('');

    // 调用SSE进行通信
    // fetchStreamAI() | eventSourceAI() | axiosStreamAI();
  };

  const handleClearHistory = () => {
    setMessages([]);
  };

  return (
    <div className={styles.chatContainer}>
      <div className={styles.messageList}>
        {messages.map((message) => (
          <div key={message.timestamp} className={`${styles.messageItem} ${message.isUser ? styles.isUser : ''}`}>
            <Avatar
              size={40}
              src={message.isUser ? '/user-avatar.png' : '/ai-avatar.png'}
              style={{ backgroundColor: message.isUser ? '#1890ff' : '#87d068' }}
            >
              {message.isUser ? 'U' : 'AI'}
            </Avatar>
            <div className={styles.messageContent}>
              {message.content}
              {message.isStreaming && <span className={styles.cursor}>|</span>}
            </div>
          </div>
        ))}
        {isProcessing && !messages[messages.length - 1]?.isStreaming && (
          <div className={styles.messageItem}>
            <Avatar size={40} style={{ backgroundColor: '#87d068' }}>AI</Avatar>
            <div className={styles.messageContent}>
              <Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
            </div>
          </div>
        )}
        <div ref={messagesEndRef} />
      </div>

      <div className={styles.inputContainer}>
        <Button
          icon={<DeleteOutlined />}
          onClick={handleClearHistory}
          style={{ marginRight: '10px' }}
        />
        <Input.TextArea
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onPressEnter={(e) => {
            if (!e.shiftKey) {
              e.preventDefault();
              handleSend();
            }
          }}
          placeholder="输入消息,按Enter发送,Shift+Enter换行"
          autoSize={{ minRows: 1, maxRows: 4 }}
          style={{ flex: 1 }}
        />
        <Button
          type="primary"
          icon={<SendOutlined />}
          onClick={handleSend}
          disabled={isProcessing}
        />
      </div>
    </div>
  );
};

export default Chat; 
1、使用fetch发送sse请求

fetch是天然支持SSE的,这是相比较于xhr的一大优势。在代码HTTP 响应中,响应头和响应体是分开的,对于 SSE,响应体的传输可能会持续很长时间,因此我们仅需等待响应头的返回即可。

//滚动到底部-采用lodash-es节流
const scrollToBottom = throttle(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, 400);

  const disposeReplay = (replay: Uint8Array): string => {
    return new TextDecoder().decode(replay).replace(/(\r?\n|\r| )|data:/g, "");
  };

  /**
   * 原生fetch获取回复
   * 注意:fetch是天然支持sse的
   */
  const fetchStreamAI = async () => {
    setIsProcessing(true);

    try {
      const aiMessage: Message = {
        content: '',
        isUser: false,
        timestamp: Date.now() + 1000,
        isStreaming: true,
      };
      setMessages(prev => [...prev, aiMessage]);

      const resp = await fetch('http://nest.liboscrg.com/prod/card/getContentAI?content=' + input, {
        method: "GET",
        // 允许携带cookies,需要服务端允许跨域请求
        // credentials: 'include', 
      });

      const reader = resp.body?.getReader();
      if (!reader) throw new Error('Failed to get reader');

      let accumulatedContent = '';

      while (true) {
        const { done, value } = await reader.read();
        if (done) {
          setMessages(prev => prev.map(msg => {
            return msg.timestamp === aiMessage.timestamp
              ? { ...msg, isStreaming: false }
              : msg
          }));

          break;
        };
        const text = disposeReplay(value);

        accumulatedContent += text;
        //同步更新消息
        setMessages(prev => prev.map(msg => {
          return msg.timestamp === aiMessage.timestamp
            ? { ...msg, content: accumulatedContent }
            : msg
        }
        ));
        scrollToBottom();
      }

    } catch (error) {
      console.error('Error getting AI response:', error);
    } finally {
      setIsProcessing(false);
    }
  }
2、EventSource发送

浏览器中是有提供专门了一个对象 EventStream 处理SSE的:但是需要注意:ie浏览器没有EventSource对象。

改写一下前端代码使用EventSource来实现,EventSource有个好处就是会自动处理好sse响应体,我们可以很方便的通过event拿到data。


  /**
   * EventSource获取回复
   * 注意:EventSource不支持sse
   */
  const eventSourceAI = async () => {
    setIsProcessing(true);
    let eventSource: EventSource;
    try {
      const aiMessage: Message = {
        content: '',
        isUser: false,
        timestamp: Date.now() + 1000,
        isStreaming: true,
      };
      setMessages(prev => [...prev, aiMessage]);

      eventSource = new EventSource('http://nest.liboscrg.com/prod/card/getContentAI?content=' + input,
        // 1、允许携带cookies,需要服务端允许跨域请求 2、 其他参数例如token,通过url参数传递
        // { withCredentials: true } 
      );
      let accumulatedContent = '';

      //接收信息
      eventSource.addEventListener("message", (event) => {
        const data = event?.data;
        accumulatedContent += data;
        //同步更新消息
        setMessages(prev => prev.map(msg => {
          return msg.timestamp === aiMessage.timestamp
            ? { ...msg, content: accumulatedContent }
            : msg
        }));
        scrollToBottom();
      })
      // 预设开始连接时,触发
      eventSource.addEventListener('open', (event) => {
          //TODO...
      })

      // 添加错误处理
      eventSource.addEventListener("error", (event) => {
        eventSource?.close();
        setMessages(prev => prev.map(msg => {
          return msg.timestamp === aiMessage.timestamp
            ? { ...msg, isStreaming: false }
            : msg
        }));
        setIsProcessing(false);
      })

    } catch (error) {
      console.error('Error getting AI response:', error);
      setIsProcessing(false);
    }
  }
3、axios获取回复

可配合ahooks实现轮询,心跳,关闭

axios也可以实现请求接收数据,只需要设置responseType: 'stream’即可支持sse,axios也是项目中常用的请求方式,配合ahooks可以实现接口轮训、取消、防抖等等一系列操作。具体查看ahooks 3.0

  /**
   * Axios获取回复
   */
  const axiosStreamAI = async () => {
    setIsProcessing(true);

    try {
      const aiMessage: Message = {
        content: '',
        isUser: false,
        timestamp: Date.now() + 1000,
        isStreaming: true,
      };
      setMessages(prev => [...prev, aiMessage]);

      await axios({
        method: 'GET',
        url: 'http://nest.liboscrg.com/prod/card/getContentAI',
        params: { content: input },
        responseType: 'stream',
        // withCredentials: true, // 如果需要携带cookies
        // headers: {
        //   'Authorization': 'Bearer your-token', // 如果需要携带token
        // },
        onDownloadProgress: (progressEvent) => {
          const chunk = progressEvent?.event?.target?.response;
          if (chunk) {
            const text = disposeReplay(new TextEncoder().encode(chunk));
            //同步更新消息
            setMessages(prev => prev.map(msg => {
              return msg.timestamp === aiMessage.timestamp
                ? { ...msg, content: text }
                : msg
            }));
            scrollToBottom();
          }
        },
      });

      setMessages(prev => prev.map(msg => {
        return msg.timestamp === aiMessage.timestamp
          ? { ...msg, isStreaming: false }
          : msg
      }));

    } catch (error) {
      console.error('Error getting AI response:', error);
    } finally {
      setIsProcessing(false);
    }
  };

几种方式选择一种即可!

实现效果如下:

此处为语雀卡片,点击链接查看

数据多样化

如果要读区复杂数据,例如读取markdown数据,推荐yarn add react-markdown,扩展部分我就不举例了!

希望文章帮助到你!完~

感谢文章

https://juejin.cn/post/7432273784907431973

https://juejin.cn/post/7375152719482961954

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值