# 一、前言
最近一段时间, chatgpt 仿佛横空出世, 瞬间冲入了所有人的视野之内. 因为种种原因, 无数的 chatgpt 网站镜像和小程序也开发了出来. 今天我们实现其打字特效
# 二、为什么是打字特效
打字特效的初衷,是因为等待请求完成的时间太长,而采用的一种优化方案。
在读取和获取大量数据时,用户需要等待很长时间,显然这种体验非常差。如果我们能每读取到一点数据,就将数据渲染出来,就能极大缩短用户初始的反馈时间。且打字的效果,能持续给人以正向的反馈,让人产生期待的心理。
此外,打字效果,与人说话非常相似,想到一点说一点,让人感觉对面是个真实的人
# 三、如何实现打字特效
首先,我们需要将数据以 stream 流的形式传递给前端
这里我们使用 koa 实现一个简单的后台服务
基础的环境搭建,如 ts、eslint、安装、启动等,请 阅读我的 超细致 nodejs + koa2 + ts + mysql + redis 后端框架搭建 1.后端环境搭建和常见中间件使用和开发 的第二章节,里面有完整的介绍,这里就不在赘述
创建 app.ts
// app.ts
import Koa from "koa";
import path from "path";
import fs from "fs";
// 创建koa实例
const app = new Koa();
// 监听端口
app.listen(3000, () => {
console.log(`server success ${3000}`);
});
此时,访问 localhost:3000,页面会提示 404,这表明我们的后端服务已启动成功
然后我们对请求做出响应
在 app.ts 里新增以下内容
// app.ts
import { ChatTransform } from "./ChatTransform";
// 请求到来时的处理
app.use((ctx) => {
const originalUrl = ctx.request.originalUrl;
// 浏览器会自动注册serviceWorker脚本,不用处理
if (originalUrl === "/serviceWorker.js") {
return;
}
const fileName = path.resolve(__dirname, "data.txt");
// 以流的形式创建文件读取流
const stream = fs.createReadStream(fileName);
// 继承了Transform,实现了一个自定义的转换流
const transformStream = new ChatTransform();
// 将可读流stream连接到ChatTransform这个转换流
stream.pipe(transformStream);
// 设置流的格式
ctx.set("Content-Type", "text/plain; charset=utf-8");
// 将流返回给前端
ctx.body = stream;
});
// ChatTransform.ts
import { Transform } from "stream";
export class ChatTransform extends Transform {
buffer: string;
parsedLine: string;
constructor() {
super();
this.buffer = "";
this.parsedLine = "";
}
_transform(chunk: any, _encoding: BufferEncoding, callback: () => void) {
// 对chunk的数据进行转换
const chunkText = chunk.toString();
const lines = `${this.buffer}${chunkText}`.split("\n");
this.buffer = lines.pop()!;
for (const line of lines) {
this.push(line);
}
// 将转换后的数据返回给下游
callback();
}
_flush(callback: () => void) {
if (this.buffer) {
this.push(`${this.buffer} \n`);
}
callback();
}
}
此时再访问 localhost:3000 , 页面就会以流的形式返回。如果 data.log 足够大,或者网速较慢的情况下,我们是能看到页面里显示的文本逐渐变多,出现滚动条的效果的
配合前端,就能实现打字效果。
当请求的数据是 stream 流时,我们可以使用 response.body.getReader() 来替代 response.json() 或者 response.text()
<!-- index.html -->
<script>
const response = await fetch("/api/chat-stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const reader = response.body.getReader();
// 用于将ArrayBuffe解码为字符串
const decoder = new TextDecoder();
let responseText = ''
if (res.ok) {
// 在 body 下载时,一直为无限循环
while(true) {
// 当最后一块下载完成时,done 值为 true
// value 是块字节的 Uint8Array
const {done, value} = await reader.read();
const text = decoder.decode(value);
responseText += text
if (done) {
break;
}
console.log(`Received ${value.length} bytes`)
}
console.log(responseText)
}
</script>
# 小程序等不支持 stream 的客户端如何实现 stream 流
小程序是不支持并不支持 http stream, 我们可以使用 websocket 来实现数据的流式传输
import { WebSocketServer } from "ws";
import { ChatTransform } from "./ChatTransform";
import fetch from "node-fetch";
const wss = new WebSocketServer({
port: 3001,
path: "/ws",
});
export default class WS {
constructor() {
this.initWebSocket();
}
initWebSocket() {
wss.on("connection", async (ws) => {
ws.on("message", async (message) => {
const msg = JSON.parse(message.toString("utf-8"));
const { type, body } = msg;
switch (type) {
// 收到消息
case "message": {
const url = "https://api.openai.com/v1/chat/completions";
fetch(url, {
method: "POST",
body,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ACCESS_TOKEN`,
},
})
.then(async (response) => {
const transformStream = new ChatTransform((data: string) => {
console.log("line", data);
// stream 传输数据
ws.send(
JSON.stringify({
type: "line",
data,
})
);
});
transformStream.on("finish", async () => {
console.log("finish");
// stream传输完毕
ws.send(
JSON.stringify({
type: "finish",
})
);
});
response.body?.pipe(transformStream);
})
.catch((err) => {
ws.send(
JSON.stringify({
type: "line-error",
data: err.message,
})
);
console.log(err);
});
break;
}
}
});
});
}
}
然后在前端使用 websocket 监听并更新数据
const socketTask = uni.connectSocket({
url: "ws://127.0.0.1:3001",
header: {
"content-type": "application/json",
authorization: 1,
},
});
socketTask.onOpen((res) => {
console.log("WebSocket连接正常!");
this.socketTask.onMessage((res) => {
//onMessage这个监听在封装的js中赋值给了socketTask对象
const msg = JSON.parse(res.data);
const { type, data } = msg;
switch (type) {
case "line": {
// 将数据渲染到页面上
break;
}
case "finish": {
// 响应完毕
break;
}
}
});
});
# 总结
本文主要介绍了 chatgpt 使用打字特效的原因,已经分别通过 http 和 websocket 实现 stream 打字特效。
如果觉得本文对你有帮忙,麻烦点赞支持,谢谢。
文中若有错误或者可优化之处, 望请不吝赐教