本文将介绍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代码请继续阅读文章!
样式
组件
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
,扩展部分我就不举例了!
希望文章帮助到你!完~
感谢文章