小程序实现 ChatGPT 聊天打字兼自动滚动效果

759次阅读  |  发布于1年以前

一 前言

ChatGPT 已经长时间大火,未来将会是AI的天下。人们需要更多地学习和掌握AI,而不是被AI所取代。

目前市面上已经有很多类似 chatGPT 的智能应用,应用有可能是 web h5 应用,也有可能是小程序或者是 Native 应用。随着 ChatGPT 深入,移动端也会再次火爆起来。

在 ChatGPT 的背景下,我们今天来聊聊在小程序中怎么实现类似 chatGPT 的聊天打字效果,并且实现滚动效果,具体如下:

这篇文章将深入一下内容:

二 实现打字效果

1.预热内容—数据请求与接收

开发者可以接入 openAi 提供的接口,实现自定义的问答流程。在聊天会话中,我们问 chatGPT 一句话:

介绍一下跨端开发

那么和平常的请求不同的是,数据并不是一次性返回的,而是采用 stream 流式返回的。我们可以在 Network 中看到 response 的大体结构:

如上可以看到返回的 text 是分片处理的,每次会返回一小段内容,只要前端根据返回这一小段内容就可以了,也就自然形成了打字的效果。

可能会有同学好奇,这种分片的数据结构,前端应该怎么接收呢?实际很简单,我们拿 axios 为例子,开发者可以通过监听onDownloadProgress 事件来接受服务端返回的文本片段。具体例子如下:

axios({
  method: 'post',
  url: 'https:xxx.xxx,
  onDownloadProgress: function({ event  }) {
    const xhr = event.target
    const { responseText } = xhr
    /* 获取返回的内容,本质上是 json 字符串 */
    let chunk = responseText
    try{
        /* 序列化返回的内容 */
       const data = JSON.parse(chunk)
       /* chatGPT 返回的内容 */
       console.log(data.text)
    }catch(e){

    }
  }
})

这里描述请求的流程,通过 onDownloadProgress 来监听返回的内容,然后获取到返回的内容,JSON.parse 解析内容,这里有一个注意事项,就是对于 JSON.parse 应该加上 try catch ,防止解析的失败。

2.小程序中接口处理

小程序没有如上 axios 里面监听 stream 流式响应数据的能力,也没有处理 onDownloadProgress 的回调函数。简单来说 onDownloadProgress 的实现,本质上是 axios 在浏览器发起 http 请求,会创建一个 XHR 对象,其用于发送请求和接收响应,在创建 XHR 对象后,axios 会注册一个 progress 事件监听器到 XHR 对象上,用于获取下载的进度信息。

那么小程序中如何实现分片流式下载呢?在小程序中,统一收口到 request 中,在 request 中可以用 RequestTask 的 onChunkReceived 来接收服务端的分片数据。这个方法可以监听 Transfer-Encoding Chunk Received 事件。当接收到新的 chunk 时触发。

我们来看看具体怎么使用:

const requestTask = wx.request({ 
    enableChunked:true,  // 开启分片模式
    ...
})
requestTask.onChunkReceived((res)=>{
    // 接收分片的数据
})

这样就可以通过分片来实现打字的效果。

3.打字效果实现

接下来我们看一下小程序是如何实现打字效果的,先不考虑返回的数据是 stream 流式结构,先认为返回的数据格式是整个文本,那么应该怎么样处理文本呢。

首先我们聊天的内容如下所示:

如上, 每一个消息都是一个 message-item ,所有的 message 保存到了 messageList 列表中,在 wxml 中如下所示:

<view wx:for="{{messageList}}" wx:key="id" id="item-{{item.id}}">
    <message-item 
        data-index="{{index}}" 
        role="{{item.role}}" 
        content="{{item.content}}"
        finished="{{item.finished}}" 
        bind:share="handleMessageShare" 
    />
</view>

如上,可以看到 message-item 保存了一条会话内容。

当我们发一条信息的时候,产生一条 message-item 。接下来 chatGPT 返回内容后,也会产生一条 message-item ,要实现打字效果就是这条 message-item 。

我们只需要将这条 message-item 的内容,通过 setData 方式分片渲染就可以了。比如我们想打字实现 ‘您好GPT’,那么分五次 setData 渲染就可以了,比如如下:

如上就是分五次渲染,每一次渲染的结果。接下来就是代码的实现。

this.handleRequestResolve(data.text)

比如 chatGPT 每次返回一条内容,都用 handleRequestResolve 函数处理返回的内容。看一下 handleRequestResolve 的核心实现。

handleRequestResolve(result){
    const timestamp = Date.now();
    const index = this.data.messageList.length
    const newMessageList = `messageList[${index}]`
    const contentCharArr = result.trim().split("")
    const content_key = `messageList[${index}].content`
    const finished_key = `messageList[${index}].finished`
    this.setData({
        thinking: false,
        [newMessageList]: {
            id: timestamp,
            role: 'assistant',
            finished: false
        }
    })
    currentContent = ''
    this.showText(0, content_key, finished_key, contentCharArr);
}

在 handleRequestResolve 中会构建一条新的 message-item ,然后就是 showText 展示内容,来看一下 showText 怎么处理内容。

 showText(key = 0, content_key, finished_key, value) {
     /* 所有内容展示完成 */
    if (key >= value.length) {
        this.setData({
            loading: false,
            [finished_key]: true
        })
        wx.vibrateShort()
        return;
    }
    currentContent = currentContent + value[key]
    /* 渲染回话内容 */
    this.setData({
        [content_key]: currentContent,
    })
    setTimeout(() => {
        /* 递归渲染内容 */
        this.showText(key + 1, content_key, finished_key, value);
    }, 50);
},

这样用递归就实现了打字效果。我们来看一下效果:

通过上面可以看到,在文字打印的过程中,列表不能跟随一起滚动,当文字内容超出一屏幕之后,视图就停止了(本质上数据在后面追加),这是一个很不好的效果。

接下来,我们进行优化处理,让视图可以根据内容自动滚动。

三 如何实现视图跟随内容滚动

3.1 实现原理

实现视图跟随内容滚动实际很简单,因为 message-item 的容器本质上就是一个 scroll-view , 那么想要 scroll-view 视图跟随返回内容变化,只需要动态设置 scroll-view 的 scroll-top 值就可以了。

视图跟随内容滚动,本质上就是让 scroll-view 一直自动滚动到底部, 如何要让 scroll-view 一直滚动到底部呢?先看一下如下示意图:

如上可以看到,想让 scroll-view 一直滚动到底部,只需要让 scroll-top 等于 scroll-view 内容高度减去 scroll-view 容器本身高度就可以了

所以需要我们给 scroll-view 里面的内容,用一个 view 包裹如下:

如上 scroll-view 的类名为 content, scroll-view 内部元素的类名为 scroll-view-content,接下来可以通过如下代码设置 scroll-top 值了。

handleScollTop() {
        return new Promise((resolve) => {
            const query = wx.createSelectorQuery()
            query.select('.content').boundingClientRect()
            query.select('.scroll-view-content').boundingClientRect()
            query.exec((res) => {
                const scrollViewHeight = res[0].height
                const scrollContentHeight = res[1].height
                if (scrollContentHeight > scrollViewHeight) {
                    const scrollTop = scrollContentHeight - scrollViewHeight
                    this.setData({
                        scrollTop
                    }, () => {
                        resolve()
                    })
                }else{
                    resolve()
                }
            })
        })
    },

如上通过 createSelectorQuery 分别获取 scroll-view 和 scroll-view 内部元素的高度,两者的差值就是 scroll-top 值。

接下里在渲染会话内容的时候,渲染之后,调用 handleScollTop 来动态设置 scroll-top 就可以了。

showText(key = 0, content_key, finished_key, value) {
    if (key >= value.length) {
        this.setData({
            loading: false,
            [finished_key]: true
        })
        wx.vibrateShort()
        return;
    }
    currentContent = currentContent + value[key]
    this.setData({
        [content_key]: currentContent,
    },()=>{
        this.handleScollTop().then(()=>{
            setTimeout(() => {
                this.showText(key + 1, content_key, finished_key, value);
            }, 20);
        })
    })
},

这里有一个小细节,就是在渲染上一次文本内容之后,需要先校验一下 scroll-top 值,然后再次调用 showText 来渲染会话内容。

我们来看一下效果。

后续优化: 本质上不需要在每次 showText 之后都通过 createSelectorQuery 异步获取元素 scroll-top 并再次渲染,这无疑是性能的浪费,实际可以控制 createSelectorQuery 到 setData 设置 scroll-top 值的频率来提升性能。

四 总结

感兴趣的同学可以自己实现一个会话打字效果,其中还有很多小细节这里就不讲了。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8