前言
本文将介绍前端实现ChatGPT式逐字展示效果的主流实现。
原理
AI 生成问答结果是一个逐步生成的过程,因此传统的 HTTP 请求无法一次性获取所有响应内容。目前,大多数大型模型聊天网站都采用 Server-Sent Events (SSE) 来实现结果的展示。SSE 的优势在于它允许服务器主动向客户端推送消息(实际上是通过保持一个连接的 HTTP 请求实现的),并且服务器向浏览器发送的 SSE 数据必须是 UTF-8 编码的文本。不得不说SSE 与 AI 的就是天作之合。
想了解更多可以参考阮一峰老师的这篇文章:Server-Sent Events 教程。
kimi数据响应
实现
后端
这里使用expree来实现后端代码:
import cors from "cors";
import express from "express";
const app = express();
app.use(cors());
app.get("/sse", (req, res) => {
// 协议规范header得带上
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Connection", "keep-alive");
res.setHeader("Cache-Control", "no-cache");
// 切割索引
let sliceIndex = req.headers["last-event-id"]
? Number.parseInt(req.headers["last-event-id"] as string)
: 0;
const msg = `
《水浒传》是中国四大名著之一,作者施耐庵,后由罗贯中修订。这部小说讲述
了108位好汉在宋代末年反抗腐败官府、追求正义的故事。书中的主要人物有宋江、
卢俊义、吴用、林冲等,他们各具特色,有的智勇双全,有的忠义勇敢,聚集在水泊梁山,组成了一个反抗势力。
故事分为几个部分,开头描写了好汉们的背景和遭遇,随后是他们如何聚集在一起,形
成梁山泊的强大团队。随着故事的发展,梁山好汉们与官府的冲突不断升级,最终以悲剧收
尾,反映了对社会不公的控诉和对忠义精神的赞美。
《水浒传》不仅是中国古代小说的经典之作,也影响了后世的文学、戏剧和影视作品,展
现了深刻的社会问题和人性的复杂。你对这部作品有什么特别的兴趣或问题吗?
`;
const sendEvent = () => {
if (sliceIndex >= msg.length) {
res.write(`data:all_done\n\n`);
sliceIndex = 0;
res.end();
} else {
res.write(`data: ${msg.slice(sliceIndex, sliceIndex + 1)}\n\n`);
sliceIndex++;
}
};
// 模拟数据更新
const intervalId = setInterval(() => {
sendEvent();
});
req.on("close", () => {
clearInterval(intervalId);
});
});
app.listen(9785, () => {
console.log("后端开始运行");
});
后端会在响应中主动告知客户端服务器发送的数据类型是文本事件流,每次setInterval响应一个字符给前端。
前端
使用Fetch发生SSE请求
fetch是天然支持SSE的,这是相比较于xhr的一大优势。在代码中,首个 await fetch
的目的是等待后端响应头的到达(见:developer.mozilla.org/zh-CN/docs/… HTTP 响应中,响应头和响应体是分开的,对于 SSE,响应体的传输可能会持续很长时间,因此我们仅需等待响应头的返回即可。
import React, { useEffect, useState, useSyncExternalStore } from "react";
import styles from "./index.module.scss";
const URL = "http://localhost:9785/sse";
const ALL_DONE = "all_done";
// 处理响应的Uint8Array
const disposeReplay = (replay: Uint8Array): string => {
return new TextDecoder().decode(replay).replace(/(\r?\n|\r| )|data:/g, "");
};
// 节流
function throttle<T extends Function>(fun: T, delay: number) {
let timer = null;
return function (...props: any[]) {
if (!timer) {
fun.apply(this, props);
timer = setTimeout(() => {
timer = null;
}, delay);
}
};
}
function SSETest() {
const [messages, _setMessages] = useState("");
const setMessages = throttle(_setMessages, 50);
const handleGetReplay = async () => {
let buffer = "";
const resp = await fetch(URL, {
method: "GET",
});
const reader = resp.body.getReader();
while (true) {
let { done, value } = await reader.read();
if (done) break;
const txt = disposeReplay(value);
if (ALL_DONE === txt) break;
buffer += txt;
setMessages(buffer);
}
};
useEffect(() => {
handleGetReplay();
}, []);
return (
<div className="App">
<div className={styles["content"]}>
<h1>ChatGPT</h1>
<ul>{messages}</ul>
</div>
</div>
);
}
export default SSETest;
在拿到响应头之后,resp.body.getReader();可以拿到一个响应体的可读流,我们不断的读取流中的信息展示到页面上即可。
使用EventSource读取
浏览器中是有提供专门了一个对象 EventStream 处理SSE的:
注意:ie没有EventSource对象
改写一下前端代码使用EventSource来实现,EventSource有个好处就是会自动处理好sse响应体,我们可以很方便的通过event拿到data。
function SSETest() {
const [messages, _setMessages] = useState("");
const setMessages = throttle(_setMessages, 50);
const handleGetReplay = async () => {
let buffer = "";
const es = new EventSource(URL);
es.addEventListener("message", (event) => {
const data = event.data;
buffer += data;
setMessages(buffer);
});
};
useEffect(() => {
handleGetReplay();
}, []);
return (
<div className="App">
<div className={styles["content"]}>
<h1>ChatGPT</h1>
<ul>{messages}</ul>
</div>
</div>
);
}
最终效果: