如何实现chatgpt官网的打字效果

5/8/2023 http

# 一、前言

最近一段时间, 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 打字特效。

如果觉得本文对你有帮忙,麻烦点赞支持,谢谢。

文中若有错误或者可优化之处, 望请不吝赐教