基于TypedJs实现ChatGPT的流式输出的打印机效果

1,348 阅读3分钟

前言

在日常的一些ai工具,例如ChatGPT、豆包等在使用时,能够实时地、连续地输出文本内容,而不是等待整个回答完全生成后再一次性输出,同时用户能够接受到数据就像打印机一样输出,这种方式使得ChatGPT的响应更加迅速,用户体验上更加流畅。那么这个流程中用到了哪些技术方案来实现呢?

1. ChatGPT流式输出的原理、SSE技术和typed.js的使用

SSE技术是一种基于HTTP协议的服务器推送技术,它允许服务器主动向客户端发送数据和信息,实现了服务器到客户端的单向通信,ChatGPT就是采用SSE技术实现流式输出(这里需要和websocket区别开来,websocket是双工通信,SSE属于服务端向客户端单向发送消息):

  1. 建立连接 —— 当用户与ChatGPT进行对话时,客户端与服务器之间会建立一个基于HTTP的长连接。这个连接通过SSE机制保持打开状态,允许服务器随时向客户端发送数据。
  2. 分步生成与实时推送 —— ChatGPT根据用户的输入和当前的上下文信息,逐步生成回答的一部分。每当有新的内容生成时,服务器就会通过SSE连接将这些内容作为事件推送给客户端。
  3. 客户端接收与展示 —— 客户端通过JavaScript的EventSource 对象监听SSE连接上的事件。一旦接收到服务器推送的数据,客户端会立即将其展示给用户,实现流式输出的效果。

typed.js 是一个前端工具库,效果是用打字机的方式显示一段话,可以自定义任何字符串显示速度等,易用、可配置、无依赖并且支持主流的浏览器,包括最新的版本。

2. typed.js的使用

可以通过npm包管理安装并使用:

npm install typed.js

或者使用cdn链接:

<script src="https://cdn.jsdelivr.net/npm/typed.js@2.0.11"></script>

我们通过实现一个简单的demo看一下,typed.js 是怎么使用的。我们直接通过一个原生js来实现,因此通过cdn地址或者本地的方式引入typed.js 源码,其html代码如下,其中建一个 span 标签来展示输入文本内容(文字放在span标签里面,输入的光标才会正常显示):

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <script src="./typed.js"></script>
  <body>
    <span id="typed" style="white-space: pre-wrap; line-height: 30px"></span>
  </body>
</html>

js代码中实现核心功能,相关的配置说明也可以看见。其中 options里面定义了打印效果相关的一些基础参数,实际的参数配置可以参考官方文档:mattboldt.github.io/typed.js/do… ,下面代码中是通过实现前端的ajax请求获取一个段demo诗句(v2.jinrishici.com/one.json 可以获取随机诗句),然后将获取的demo数据进行打印效果的展示。

<script>
    // 封装打印效果的方法
    function printFn(data) {
      const options = {
        strings: [data],
        typeSpeed: 50, // 打印速度
        startDelay: 300, // 开始之前的延迟300毫秒
        loop: true, // 是否循环
        loopCount: 1, // 循环次数,Infinity 为无限循环。
        showCursor: false, // 是否显示光标
        contentType: "html", // 明文的'html'或'null'
        onComplete: (data) => {
          // 所有打字都已完成调用的回调函数
          console.log("onComplete", data);
        },
        onStringTyped: (index, data) => {
          // 输入每个字符串后调用的回调函数
          console.log("onStringTyped", index, data);
        },
      };
      const typed = new Typed("#typed", options);
    }

    var xhr = new XMLHttpRequest();
    xhr.open("get", "https://v2.jinrishici.com/one.json"); // 生成随机诗句
    xhr.withCredentials = true;
    xhr.onreadystatechange = function () {
      if (xhr.readyState === 4) {
        let data = JSON.parse(xhr.responseText);
        let content = `<h4>${data.data.content}</h4>`;
        printFn(content)
      }
    };
    xhr.send();
</script>

3. 流式输出和打印效果的结合实现

前端用原生js实现一个简单的文本打印区域,后端使用nodejs结合 SSE 技术实现一个简单的流式传输的服务。如下代码是服务端的实现,通过他引入 http 模块实现一个服务,并且监听 8080 端口,模拟ChatGPT的流式输出是通过每隔5秒钟写入一个文本内容实现(模拟后台接口主动向前端推送消息)。(注意服务端跨域的设置应该放开)

//  package.json 中增加了 "type": "module", 可以通过es模式导入模块
import http from 'http'

const server = http.createServer((req, res) => {
  if (req.url === '/stream') {
    res.setHeader('Access-Control-Allow-Origin', '*'); // 设置跨域允许
    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive'
    });

    // 模拟ChatGPT的流式输出
    let counter = 0;
    res.write(`ChatGPT says: Hello World, this is message ${++counter}\n\n`);
    const interval = setInterval(() => {
      const data = `ChatGPT says: Hello World, this is message ${++counter}\n\n`;
      res.write(data);
    }, 5000); // 每秒发送一次数据

    req.on('close', () => {
      clearInterval(interval);
      res.end();
    });
  } else {
    res.writeHead(404);
    res.end();
  }
});

server.listen(8080, () => {
  console.log('Server listening on port 8080');
});

如下代码是前端的实现,前端通过new EventSource("http://localhost:8080/stream") 的方式连到服务端 SSE 服务,在通过这个实例子来 onopen 监听连接状态,通过 onmessage 接收服务端消息。每接收到一次服务端消息就将消息在文本区域打印出来。当前前端代码通过本地服务跑起来(可以通过live-server插件实现),在服务端放开跨域限制的条件下,可以看到正常的打印机效果。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>SSE Output</title>
  </head>
  <script src="./typed.js"></script>
  <body>
    <div id="main" style="width: 400px;">
    </div>
  </body>
  <script>
    function printFn(domId, data) {
      const options = {
        strings: [data],
        typeSpeed: 50, // 打印速度
        startDelay: 300, // 开始之前的延迟300毫秒
        loop: true, // 是否循环
        loopCount: 1, // 循环次数,Infinity 为无限循环。
        showCursor: false, // 是否显示光标
        contentType: "html", // 明文的'html'或'null'
        onComplete: (data) => {
          // 所有打字都已完成调用的回调函数
          console.log("onComplete", data);
        },
        onStringTyped: (index, data) => {
          // 输入每个字符串后调用的回调函数
          console.log("onStringTyped", index, data);
        },
      };
      const typed = new Typed(`#${domId}`, options);
    }

    const eventSource = new EventSource("http://localhost:8080/stream"); // 连接到SSE服务器

    // 连接建立时的操作
    eventSource.onmessage = (event) => {
      const data = event.data;
      if (data === "[done]") {
        eventSource.close();
        return;
      }
      // 每次接受到新的消息,就新建一个dom标签展示新接受的消息
      const div = document.getElementById('main');
      const span = document.createElement("span");
      const domId = 'type'+Date.now();
      span.id = domId;
      span.style= "white-space: pre-wrap; line-height: 30px; display: block; color: #338af6; font-size: 16px";
      div.appendChild(span);
      printFn(domId, data) // 展示接收到的数据
    };

    // 连接建立时的操作
    eventSource.onopen = (event) => {
      console.log("EventSource onopen:", event);
    };

    // 错误处理
    eventSource.onerror = (event) => {
      console.error("EventSource failed:", event);
    };
  </script>
</html>