手摸手带你实现前端乞丐版的 ChatGPT

   日期:2024-12-26    作者:ga8z9o 移动:http://3jjewl.riyuangf.com/mobile/quote/37671.html

本文首发于个人博客

一开始看到 chatgpt 的流式渲染,有点好奇流式渲染是如何实现的,无意之间发现 vercel 的库:ai,仔细学习了它的代码,写的小而精,把它看明白了之后,想着写一篇文章来输出一下。

所以我就根据 ai 这个库,来一步步带大家来实现前端乞丐版 chatgpt ,把这篇文章看完之后,也可以去看看 ai 这个库,代码是写的真不错(会发现代码都是抄它的,哈哈哈哈)!!!

本文会使用 Next13,不熟悉也没关系,用到的 api 不多,不懂的 api 可以查看 Next13 文档。

注意:需要申请有一个 openai 的 apiKey,不然就无法调用接口哦。

源代码在这里

效果展示:

先看看一个概念:网络流,平时可能用不到。流是一种用于访问数据的数据结构,比如说:文件、接口返回的数据等等。

使用流有两个好处:

  • 可以处理大量数据,流可以将它们分成更小的部分(chunk),可以一次处理一个(chunk)。
  • 可以使用相同的数据结构、流,同时处理不同的数据,这使得代码变得更加复用。

在网络流中,一个 chunk 通常是:

  • 文本流:string
  • 二进制流:Uint8Arrays

网络流主要有三种:

  • ReadableStream:用于从数据源中读取数据。执行此操作的代码成为消费者。
  • WritableStream:用户将数据写入。执行此操作的代码成为生产者。
  • TransformStream 由两个流组成:
    • 它从其可写端(WritableStream)接收输入。
    • 它将输出发送到它的可读端,一个 ReadableStream。

本文中只会使用到 ReadableStream 和 TransformStream。

ReadableStream 可以从各种来源读取数据块,类型声明如下:

这三个属性的作用是:

  • :返回一个 Reader,可以从 ReadableStream 读取的对象,返回的 Reader 类似于迭代器。
  • :每个 ReadableStream 一次只能有一个活动的 Reader,当 Reader 在使用时,ReadableStream 被锁定并且不能被调用。
  • :将其 ReadableStream 连接到 ReadableWritablePair(一个 TransformStream)。它返回一个新的 ReadableStream(类比一下:把它理解成一个数组的 map 方法)。

下面来看看 的返回类型:

  • :在一个活动的 Reader 中,这个方法取消关联的 ReadableStream。
  • :停用 Reader 并解锁流。
  • 返回来两个属性的 ReadableStreamReadResult 的 Promise:
    • :布尔值,false 表示可以读取,true 表示最后一个块。
    • :块(chunk)。

师傅,别念 api 了,再念人都要傻了,赶紧来一个 demo 吧。

以下是通过 getReader 方式来读取 ReadableStream 的小例子。

  • A 行:不能直接读取 readableStream,需要调用来获取 Reader。
  • B 行:在 之后,readableStream 被锁定,所以 B 行打印的是 true,如果想再次调用,必须调用(E 行)。
  • C 行:返回属性 done 和 value,如果 done 为 true,表示是最后一个块,
  • D 行:可以对返回的 value 进行操作,这里是将返回的 value 全部都加在一个字符串里。

如果想通过 ReadableStream 读取外部源,可以将其包装在适配器对象中并将该对象传递给构造函数 ReadableStream。

以下是类型声明:

  • 调用构造函数后立即调用 start 方法。

controller 的参数类型如下:

  • :添加 chunk 到 ReadableStream 的内部队列。
  • :关闭 ReadableStream,消费者仍然可以清空队列,在那之后,流结束。

自定义 ReadableStream demo

ReadableStream 是异步可迭代的,可以使用来进行迭代。

使用控制器创建一个包含两个块的流(A 和 B 行),关闭流(C 行)很重要,否则永远不会结束。

转化流:

  • 通过其可写端(WritableStream)接受输入。
  • 然后它可能会或可能不会转换输入。
  • 结果可以通过 ReadableStream 读取,它是可读的。

使用最常见的方式是。

将 readableStream 传输到 transformStream 的可写端,并进行转换返回其可读端。

换句话说: 创建了一个新的 ReadableStream,它是 ReadableStream 的转换版本,类似于数组的 map。

一个简单的 demo:

TextEncoder.encode():将字符串作为输入,并返回 Uint8Array 包含 UTF-8 编码的文本。

使用了内置 TransformStream:,作用就是将接收到的二进制流转换为可读的文本流(Uint8Array -> string)。

自定义 TransformStream

跟上面的 ReadableStream 类似,如果要自定义 TransformStream,也可以传递适配器对象给构造函数 TransformStream。

它具有以下类型:

上面属性的解释:

  • :在调用构造函数后立即被调用,可以在转换之前做一些准备。
  • 执行实际的转化 。接受一个输入块,并可以使用 controller 将一个或多个转换后的输出块排队。

该 contrller 具有以下类型:

  • :添加 chunk 到 TransformStream 的可读端(输出)。
  • :关闭 TransformStream 的可读端(输出)并在可写端(输入)出错。如果转换器对可写端(输入)的剩余块不感兴趣并想跳过他们,则可以使用它。

小 demo

说了一大堆 api,来一个简单的例子:

Fetch API 是一种用于获取和发送网络资源的现代 Web API。它提供了一种替代 XMLHttpRequest 的方式,可以更简单、更灵活地进行网络请求。

Fetch API 使用 Promise 对象来返回请求结果,可以轻松地将其与 async/await 结合使用。

简单的小例子:

这里要用的是 fetch 返回的属性,它返回的是。

用到了上面提到的 ReadableStream,也算是简单的回顾一下。

需要注意的是:返回的是二进制流,后面会再提到

讲到这里,终于把前置的知识熟悉一下,我知道你很急,但是你先别急。

我们来进入实战环节。

首先使用初始化一个 Next13 项目。

  • What is your project named? openai-stream
  • Would you like to use TypeScript with this project? Yes
  • Would you like to use ESLint with this project? Yes
  • Would you like to use Tailwind CSS with this project? Yes
  • Would you like to use directory with this project? No
  • Use App Router (recommended)? Yes
  • Would you like to customize the default import alias? No

初始化之后就会安装 TypeScript、Eslint 和 Tailwind CSS。

第一步就开始画 UI。

UI 的话主要有两个部分:

  1. 消息列表展示。
  2. input 输入框。

MessageCard 渲染信息

创建类型文件定义关于 message 的类型。

MessageCard 组件用来渲染输入和 openai 返回的信息。

基础页面 + input 输入框

下一步画基础的页面和 input 输入框。

直接在里面书写即可。

先 mock 消息列表,看看展示效果咋样。

在 app 目录下新建文件,用来处理 api 请求。

可以直接访问 http://localhost:3000/api/chat 可以看到返回的数据。

关于 Next.js 的 Route 可以查看相关文档,不过多赘述。

需要发送 POST 请求将 message 传递给 openai,通过就可以处理 POST 请求。

就是对于 Response 的简单封装,将状态码置为 200。

创建文件用来处理网络流,使用 ReadableStream 先 mock 两条数据。

下一步写一个 hook 来进行页面交互,创建文件。

装包

需要安装一些依赖包:nanoid 和 swr。

  • nanoid 是可以生成唯一 ID 的库。
  • swr 是用于数据请求和缓存的库。

类型

将 CreateMessage 和 UseChatOptions 添加到文件,定义好 use-chat 的类型声明。

use-chat 具体逻辑

下面来写对应的 use-chat 具体逻辑:

使用来声明一个状态,第二个参数(fetcher)传入 null,表示不需要进行网络请求,可以把它当成本地状态来处理,返回的 mutate 函数可以对这个状态进行更新。

小小的工具函数

新建文件用来保存两个工具函数: nanoid、createChunkDecoder。

  • nanoid:使用 customAlphabet 函数创建了自定义的 ID 生成器 nanoid,用于生成唯一 ID。
  • createChunkDecoder:用于将 Uint8Array 类型的数据块解码成字符串(Uint8Array -> string)。

请求接口

接下来就是发送网络请求到,请求成功之后将数据渲染出来。

事件处理

接下来就是将上面的 hook 逻辑和视图绑定在一起。

连通性验证

不出意外的报 bug 了。

问题在于方法返回的是一个二进制流(Uint8Array),fetch 提供了、、等方式将二进制流转化为其他数据格式。

刚刚在里面推到队列里面的是字符串,所以就会报错。

有两种解决方式。

  1. enqueue 推到队列的类型改成二进制形式:

创建了 TextEncoder 对象,用于将字符串编码为 Uint8Array 对象。

  1. 采用 TransformStream 可以对输入的数据进行转换处理:

通过使用 pipeThrough 方法,将 AIStream 的输出流连接到 createCallbacksTransformer 的输入流,实现了数据的转换和传递。

实现将 string 转换为 Uint8Array 对象的流处理过程。

本文后面会使用第二种方式。

我们来看看效果:


特别提示:本信息由相关用户自行提供,真实性未证实,仅供参考。请谨慎采用,风险自负。


举报收藏 0评论 0
0相关评论
相关最新动态
推荐最新动态
点击排行
{
网站首页  |  关于我们  |  联系方式  |  使用协议  |  隐私政策  |  版权隐私  |  网站地图  |  排名推广  |  广告服务  |  积分换礼  |  网站留言  |  RSS订阅  |  违规举报  |  鄂ICP备2020018471号