后端的同学已经做好了后端的SSE,并给出了我们选型的原因:报告。我就不再赘述,主要还是从前端的实现来阐述。
EventSource
SSE
(Server-Sent Events
,服务器发送事件)是一种实现服务器到客户端单向实时通信的技术。它允许服务器在有新数据可用的任何时候向浏览器推送信息,而不需要浏览器不断地询问服务器是否有新数据。这是通过在客户端和服务器之间建立一个持久的连接来实现的,服务器通过这个连接推送数据。
EventSource
是一个JavaScript接口,它为服务器发送事件提供了一种简单的实现方式。使用 EventSource
,开发者可以非常容易地创建一个连接到服务器的客户端,并监听服务器推送的事件。当服务器发送一个事件时,EventSource
对象会触发一个事件,开发者可以监听这个事件并相应地处理。
在SSE中,浏览发送一个请求给服务端,通过响应头中的Content-Type:text/event-stream
等向客户端声名这是一个长连接,发送的是流数据,这样客户端就不会关闭连接,一直等待服务端发送数据。
注意,sse本身是不支持post的方式,可以通过fetch的方式完成post相关操作。
post方式
使用post方式实现的大概格式如下:
// 创建一个AbortController实例,用于在未来某个时刻取消fetch请求 const controller = new AbortController(); // 获取由AbortController实例控制的信号对象 const signal = controller.signal; // 使用fetchEventSource发起一个到指定URL的POST请求,用于建立SSE连接 fetchEventSource(`${url}`, { method: 'POST', // 请求方法为POST signal: signal, // 将信号对象传递给请求,以便能够取消请求 headers: { // 设置请求头 'Content-Type': 'application/json', // 请求内容类型为JSON }, body: JSON.stringify(message), // 请求体,将message对象转换为JSON字符串 onmessage(msg) { // 定义收到消息时的回调函数 // 当服务器发送消息时,这里会接收到消息并处理 }, onerror(err) { // 定义出错时的回调函数 throw err; // 如果发生错误,抛出这个错误 // 抛出错误将中断fetchEventSource的处理流程 } });
onmessage回调函数不触发
具体实现时,我发现log的onmessage消息全部为空。但是使用apipost测试后端接口可以得到流式输出。
进一步查看network,我发现其中的response中有响应,格式如下:(因为返回的是字符串且默认解码方式为utf-8所以没有成功解码)
{"docs": ["<span style='color:red'>\u672a\u627e\u5230\u76f8\u5173\u6587\u6863,\u8be5\u56de\u7b54\u4e3a\u5927\u6a21\u578b\u81ea\u8eab\u80fd\u529b\u89e3\u7b54\uff01</span>"]} {"text": "\u60a8\u597d", "message_id": "148994c7260445e9a23736808658b4fd"}
但是EventStream中全部为空。
查看响应标头,content-type没有问题:
使用onopen方法,控制台输出一切正常:
async onopen(response) { console.log(response); if (response.ok && response.headers.get('content-type') === 'text/event-stream') { console.log(response); return; } else if (response.status >= 400 && response.status < 500 && response.status !== 429) { // client-side errors are usually non-retriable: console.log("回应错误") } else { // throw new RetriableError(); } },
说明只有onmessage的实现有问题。
查阅资料,我发现原因是因为SSE
规定了schema
,如果不符合下面的格式,前端无法解析数据
SSE
规定的schema
:
event: message\n data: {数据}\n\n
以上为纯字符串,同时message
也可以是其他消息,不会影响onmessage
回调函数。
可以发现后端的返回格式并不如我们所愿,更改后端返回的代码如下:
# event_data = json.dumps(data) event_data = json.dumps({"data":data}) # yield f"{event_data}\n\n" yield f"event: message\ndata: {event_data}\n\n"
重启后端,重新运行,将onmessage中的msg log出来,结果如下:
再查看EventStream,已经成功解析出来了:
聊天打字机输出
可以看到后端返回的消息中,其中的 Unicode字符未被正确转义,首先需要使用JSON.parse()
方法来解码它。
const parsedData = JSON.parse(msg.data);
查看data的格式,分为两种,一种里面为完整的docs文本,另一种是需要拼接的text文本。
event: message data: {"data": {"docs": ["<span style='color:red'>\u672a\u627e\u5230\u76f8\u5173\u6587\u6863,\u8be5\u56de\u7b54\u4e3a\u5927\u6a21\u578b\u81ea\u8eab\u80fd\u529b\u89e3\u7b54\uff01</span>"]}} event: message data: {"data": {"text": "\u5468", "message_id": "d67aac633ac0456f9945d3d0c5f20f45"}}
对于docs,我直接将其添加到aichat中,但是注意要将其中的换行符切换为<br>
let aiCurrentChatDocs = JSON.stringify(parsedData.data.docs); aiCurrentChatDocs = aiCurrentChatDocs.replace(/\n/g, '<br>'); // 将ai回复加入list AIReplay('参考:'+ aiCurrentChatDocs);
对于text,我使用resultAnswer拼接目前为止得到的text的字符串。
然后找到msgList中最后一条ai的消息(也就是消息内容和resultAnswer相等的消息),如果没有的话,说明当前是接收到的第一个token,于是创建AIReplay;如果可以找到的话,将当前的lastAI的content直接更改为resultAnswer.value。
因为msg是reactive创建的,所以可以动态修改。
实现如下:
// 将ai回复加入list let lastAI = msgList.find((msg) => msg.content === resultAnswer.value); resultAnswer.value +=parsedData.data.text; //如果没有回答就创建新的: if(!lastAI){ AIReplay(resultAnswer.value); } else{ lastAI.content=resultAnswer.value; }