一、前言
本文源自微博客且已获授权,请尊重版权.
书接上文,上文中,我们介绍了
通义千问AI落地
的后端接口。那么,接下来我们将继续介绍前端如何调用接口以及最后的效果;首先看效果:
上述就是落地到本微博客以后的页面效果,由于是基于落在现有项目之上,因此什么登录注册等基本功能都省去了,言归正传,下面我们将正式介绍通义千问AI落地的前端实现。
二、前端实现
2.1、前端依赖
前端所需依赖基本如下(
本项目前端是基于Nuxtjs的,这样又益与SSE,所以有nuxt相关依赖
):
"dependencies":{"@nuxtjs/axios":"^5.13.6","dayjs":"^1.11.12","element-ui":"^2.15.1","highlight.js":"^11.9.0",//代码高亮组件"mavon-editor":"^2.10.4",//富文本展示"nuxt":"^2.0.0","@stomp/stompjs":"^6.0.0",// "ws":"^7.0.0"//websocket}
2.2、页面布局
如上动图所示,前端项目主要是左右分布。其中,左侧负责管理各个session会话,包括激活会话、展示会话、删除会话等;
右侧则主要负责消息处理,包括消息收发、处理GPT生成的消息等。由于使用组件化开发,因此各个组件比较多,接下来的内容将采用
总-分
结构介绍。
2.2.1、主聊天页面
主聊天页面聚合了左侧的session管理和右侧的消息管理,内容如下:
<template><!-- 最外层页面于窗口同宽,使聊天面板居中 --><divclass="home-view"><!-- 整个聊天面板 --><divclass="chat-panel"><!-- 左侧的会话列表 --><divclass="session-panel hidden-sm-and-down"><divclass="title">ChatGPT助手</div><divclass="description">构建你的AI助手</div><divclass="session-list"><SessionItemv-for="(session, index) in sessionList":key="session.id+index":active="session.id === activeSession.id":session="sessionList[index]"class="session"@click.native="sessionSwitch(session,index)"@delete="deleteSession"></SessionItem></div><divclass="button-wrapper"><divclass="new-session"><el-button@click="createSession"><el-icon:size="15"class="el-icon-circle-plus-outline"></el-icon>
新的聊天
</el-button></div></div></div><!-- 右侧的消息记录 --><divclass="message-panel"><!-- 会话名称 --><divclass="header"><divclass="front"><divv-if="!isEdit"class="title"><el-inputstyle="font-size: 20px"v-model="activeSession.topic"@keyup.enter.native="editTopic()"></el-input></div><divv-elseclass="title"style="margin-top: 6px;"@dblclick="editTopic()">
{{ activeSession.topic }}
</div><divclass="description">与ChatGPT的 {{ activeSession?.messageSize ?? 0 }} 条对话</div></div><!-- 尾部的编辑按钮 --><divclass="rear"><iv-if="isEdit"@click="editTopic"class="el-icon-edit rear-icon"></i><iv-else@click="editTopic"class="el-icon-check rear-icon"></i></div></div><el-divider></el-divider><divclass="message-list"id="messageListId"><!-- 过渡效果 --><transition-groupname="list"><message-rowv-for="(message, index) in activeSession.messages":key="message.id+`${index}`":message="message"></message-row></transition-group></div><divclass="toBottom"v-if="!this.isScrolledToBottom"><el-tooltipclass="item"effect="light"content="直达最新"placement="top-center"><el-buttonclass="el-icon-bottom bottom-icon"@click="toBottom"></el-button></el-tooltip></div><!-- 监听发送事件 --><MessageInput@send="sendMessage":isSend="isSend"></MessageInput></div></div></div></template><script>import MessageInput from'@/components/gpt/MessageInput'import MessageRow from'@/components/gpt/MessageRow'import SessionItem from"@/components/gpt/SessionItem";import{Client}from"@stomp/stompjs";import dayjs from"dayjs";import{scrollToBottom}from'@/utils/CommonUtil'exportdefault{name:'gpt',layout:'gpt',middleware:'auth',//权限中间件,要求用户登录以后才能使用components:{
MessageInput, MessageRow, SessionItem
},created(){this.loadChart();},mounted(){this.handShake()this.$nextTick(()=>{this.messageListEl = document.getElementById('messageListId');if(this.messageListEl){this.messageListEl.addEventListener('scroll',this.onScroll);}});},beforeUnmount(){this.closeClient();},beforeDestroy(){if(this.messageListEl){this.messageListEl.removeEventListener('scroll',this.onScroll);}},watch:{activeSession(newVal){if(newVal){//确保dom加载完毕this.$nextTick(()=>{this.toBottom();});}},},data(){return{sessionList:[],activeSession:{topic:'',messageSize:0},isEdit:true,isSend:false,client:null,gptRes:{content:''},userInfo:null,activeTopic:null,//消息计数msgCount:false,isScrolledToBottom:true,messageListEl:null,msgQueue:[],//收到的消息队列;先将后端返回的消息收集起来放在一个队列里面,一点点追加到页面显示,这样可以使消息显示更加平滑interval:null,lineCount:5}},methods:{asyncloadChart(){//查询历史对话const queryArr ={query:{userId:this.userInfo.uid
},pageNum:1,pageSize:7};let res =awaitthis.$querySession(queryArr);if(res.code ===20000){if(res.data.length >0){this.activeSession = res.data[0]
res.data.forEach(item=>this.sessionList.push(item))this.activeTopic =this.activeSession.topic
return}}let session ={topic:"新建的聊天",userId:this.userInfo.uid,}let resp =awaitthis.$createSession(session)if(resp.code ===20000){
session.id = resp.data.id
}
session.updateDate =this.now()
session.createDate =this.now()
session.messages =[]this.sessionList.push(session)this.activeSession =this.sessionList[0]this.activeTopic =this.activeSession.topic
},editTopic(){this.isEdit =!this.isEdit
if(this.isEdit){if(this.activeTopic===this.activeSession.topic)returnthis.$updateSession(this.activeSession).then(()=>{this.activeSession.updateDate =this.now()this.activeTopic =this.activeSession.topic
})}},deleteSession(session){let index =this.sessionList.findIndex((value)=>{return value.id === session.id
})this.sessionList.splice(index,1)if(this.sessionList.length >0){this.activeSession =this.sessionList[0]return}this.createSession()},sessionSwitch(session,index){if(!session)returnif(session.messages && session.messages.length >0){this.activeSession =nullthis.activeSession = session
this.toBottom()return;}this.$getSessionById(session.id).then(resp=>{if(resp.code ===20000){this.activeSession =nullthis.activeSession = resp.data
this.toBottom()this.sessionList[index]= resp.data
this.sessionList[index].messageSize = session.messageSize
}})},createSession(){let time =this.now()let chat ={id: time.replaceAll(" ",""),createDate: time,updateDate: time,messageSize:0,topic:"新建的聊天",messages:[]}this.activeSession = chat
//从聊天列表头部插入新建的元素this.sessionList.unshift(chat)this.createChatMessage(chat)},asynccreateChatMessage(chat){let resp =awaitthis.$createSession(chat)if(resp.code ===20000){this.activeSession.id = resp.data.id
}},//socket握手handShake(){this.client =newClient({//连接地址要加上项目跟地址brokerURL:`${process.env.socketURI}`,onConnect:()=>{this.isSend =true// 连接成功后订阅ChatGPT回复地址this.client.subscribe('/user/queue/gpt',(message)=>{let msg = message.body
this.handleGPTMsg(msg)})}})// 发起连接this.client.activate()},/**
* 处理GPT返回的消息
* @param msg
*/handleGPTMsg(msg){if(msg && msg !=='!$$---END---$$!'){this.msgQueue.push(msg)//设置定时器,每40毫秒取出一个队列元素,追加到页面显示,不要一下子把后端返回的内容全部追加到页面;使消息平滑显示if(!this.interval){this.interval =setInterval(()=>{this.appendQueueToContent()},40)}if(this.msgCount){this.activeSession.messageSize+=1this.msgCount =false}return;}if(msg ==='!$$---END---$$!'){clearTimeout(this.interval)this.interval =null//清理掉定时器以后,需要处理队列里面剩余的消息内容this.handleLastMsgQueue()}},/**
* 处理队列里面剩余的消息
*/handleLastMsgQueue(){while(this.msgQueue.length>0){this.appendQueueToContent()}this.isSend =true},/**
* 将消息队列里面的消息取出一个字符追加到显示content
*/appendQueueToContent(){if(this.msgQueue.length <=0){return}// 如果当前字符串还有字符未处理const currentItem =this.msgQueue[0];if(currentItem){// 取出当前字符串的第一个字符const char = currentItem[0];//不能频繁调用 到底部 函数if(this.lineCount %5===0){this.toBottom()}this.lineCount++this.gptRes.content += char;// 移除已处理的字符this.msgQueue[0]= currentItem.slice(1);// 如果当前字符串为空,则从队列中移除if(this.msgQueue[0].length ===0){this.msgQueue.shift();}}},sendMessage(msg){this.buildMsg('user', msg)let chatMessage ={content: msg,role:'user',sessionId:this.activeSession.id
}try{this.client.publish({destination:'/ws/chat/send',body:JSON.stringify(chatMessage)})}catch(e){
console.log("socket connection error:{}", e)this.handShake()return}this.isSend =falsethis.gptRes ={role:'assistant',content:'',createDate:this.now()}this.activeSession.messages.push(this.gptRes)this.toBottom()this.msgCount =truethis.activeSession.messageSize+=1},toBottom(){scrollToBottom('messageListId')},buildMsg(_role, msg){let message ={role: _role,content: msg,createDate:this.now()}this.activeSession.messages.push(message)},closeClient(){try{this.client.deactivate()this.client =null}catch(e){
console.log(e)}},now(){returndayjs().format('YYYY-MM-DD HH:mm:ss');},onScroll(event){this.isScrolledToBottom = event.target.scrollHeight - event.target.scrollTop <=(event.target.clientHeight +305);},},asyncasyncData({store, redirect}){const userId = store.state.userInfo && store.state.userInfo.uid
if(typeof userId =='undefined'|| userId ==null|| Object.is(userId,'null')){returnredirect("/");}return{userInfo: store.state.userInfo
}},}</script><stylelang="scss"scoped>.home-view{display: flex;justify-content:center;margin-top: -80px;.chat-panel{display: flex;border-radius: 20px;background-color: white;box-shadow: 0 0 20px 20px rgba(black, 0.05);margin-top: 70px;margin-right: 75px;.session-panel{width: 300px;border-top-left-radius: 20px;border-bottom-left-radius: 20px;padding: 5px 10px 20px 10px;position: relative;border-right: 1px solid rgba(black, 0.07);background-color:rgb(231, 248, 255);/* 标题 */.title{margin-top: 20px;font-size: 20px;}/* 描述*/.description{color:rgba(black, 0.7);font-size: 14px;margin-top: 10px;}.session-list{.session{/* 每个会话之间留一些间距 */margin-top: 20px;}}.button-wrapper{/* session-panel是相对布局,这边的button-wrapper是相对它绝对布局 */position: absolute;bottom: 20px;left: 0;display: flex;/* 让内部的按钮显示在右侧 */justify-content: flex-end;/* 宽度和session-panel一样宽*/width: 100%;/* 按钮于右侧边界留一些距离 */.new-session{margin-right: 20px;}}}/* 右侧消息记录面板*/.message-panel{width: 750px;position: relative;.header{text-align: left;padding: 5px 20px 0 20px;display: flex;/* 会话名称和编辑按钮在水平方向上分布左右两边 */justify-content: space-between;/* 前部的标题和消息条数 */.front{.title{color:rgba(black, 0.7);font-size: 20px;::v-deep{.el-input__inner{padding: 0 !important;}}}.description{margin-top: 10px;color:rgba(black, 0.5);}}/* 尾部的编辑和取消编辑按钮 */.rear{display: flex;align-items: center;.rear-icon{font-size: 20px;font-weight: bold;}}}.message-list{height: 560px;padding: 15px;
// 消息条数太多时,溢出部分滚动
overflow-y: scroll;// 当切换聊天会话时,消息记录也随之切换的过渡效果
.list-enter-active,
.list-leave-active{transition: all 0.5s ease;}.list-enter-from,
.list-leave-to{opacity: 0;transform:translateX(30px);}}::v-deep{.el-divider--horizontal{margin: 14px 0;}}}}}::v-deep{.mcb-main{padding-top: 10px;}.mcb-footer{display: none;}}.message-input{padding: 20px;border-top: 1px solid rgba(black, 0.07);border-left: 1px solid rgba(black, 0.07);border-right: 1px solid rgba(black, 0.07);border-top-right-radius: 5px;border-top-left-radius: 5px;}.button-wrapper{display: flex;justify-content: flex-end;margin-top: 20px;}.toBottom{display: inline;background-color: transparent;position: absolute;z-index: 999;text-align: center;width: 100%;bottom: 175px;}.bottom-icon{align-items: center;background: #fff;border: 1px solid rgba(0,0,0,.08);border-radius: 50%;bottom: 0;box-shadow: 0 2px 8px 0 rgb(0 0 0 / 10%);box-sizing: border-box;cursor: pointer;display: flex;font-size: 20px;height: 40px;justify-content: center;position: absolute;right: 50%;width: 40px;z-index: 999;}.bottom-icon:hover{color: #5dbdf5;cursor: pointer;border: 1px solid #5dbdf5;}</style>
我们来着重介绍一下以下三个函数:
/**
* 处理GPT返回的消息
* @param msg
*/handleGPTMsg(msg){if(msg && msg !=='!$$---END---$$!'){this.msgQueue.push(msg)//设置定时器,每40毫秒取出一个队列元素,追加到页面显示,不要一下子把后端返回的内容全部追加到页面;使消息平滑显示if(!this.interval){this.interval =setInterval(()=>{this.appendQueueToContent()},40)}if(this.msgCount){this.activeSession.messageSize+=1this.msgCount =false}return;}if(msg ==='!$$---END---$$!'){clearTimeout(this.interval)this.interval =null//清理掉定时器以后,需要处理队列里面剩余的消息内容this.handleLastMsgQueue()}},/**
* 处理队列里面剩余的消息
*/handleLastMsgQueue(){while(this.msgQueue.length>0){this.appendQueueToContent()}this.isSend =true},/**
* 将消息队列里面的消息取出一个字符追加到显示content
*/appendQueueToContent(){if(this.msgQueue.length <=0){return}// 如果当前字符串还有字符未处理const currentItem =this.msgQueue[0];if(currentItem){// 取出当前字符串的第一个字符const char = currentItem[0];//不能频繁调用 到底部 函数if(this.lineCount %5===0){this.toBottom()}this.lineCount++this.gptRes.content += char;// 移除已处理的字符this.msgQueue[0]= currentItem.slice(1);// 如果当前字符串为空,则从队列中移除if(this.msgQueue[0].length ===0){this.msgQueue.shift();}}}
handleGPTMsg
这个函数就是处理后端websocket传递过来的消息,其中,最主要的就是这里。在这里,我们设置了一个定时器,每40ms调用一次appendQueueToContent
函数,这个函数从消息队列的队头取出一个字符,把这个字符追加到页面组件上面显示,这样处理使得消息的显示更加平滑,而不是后端生成多少就一次性展示多少。
if(!this.interval){this.interval =setInterval(()=>{this.appendQueueToContent()},40)}
appendQueueToContent
这个函数就是负责从queue里面获取内容,然后追加到gptRes这个变量里面;并且将已经追加的内容从队列里面移除掉。handleLastMsgQueue
由于前后端处理消息的速度是不一样的,当后端发送生成结束标记(即!$$---END---$$!
)后,前端就需要以此为根据,删除定时器,否则我们没办法知道queue消息队列什么时候为空、什么时候该清楚定时器。那么,此时清除定时器,我们就需要用一个while函数来处理queue里面剩下的内容,handleLastMsgQueue
函数就是干这个的。
2.2.2、session管理组件
这个组件没有什么隐晦难懂的知识,直接贴代码:
<template><div:class="['session-item', active ? 'active' : '']"><divclass="name">{{ session.topic }}</div><divclass="count-time"><divclass="count">{{ session?.messageSize ?? 0 }}条对话</div><divclass="time">{{ session.updateDate }}</div></div><!-- 当鼠标放在会话上时会弹出遮罩 --><divclass="mask"></div><divclass="btn-wrapper"@click.stop="$emit('click')"><el-popconfirmconfirm-button-text='好的'cancel-button-text='不用了'icon="el-icon-circle-close"icon-color="red"@click.prevent="deleteSession(session)"title="是否确认永久删除该聊天会话?"@confirm="deleteSession(session)"><el-iconslot="reference":size="15"class="el-icon-circle-close"></el-icon></el-popconfirm></div></div></template><script>exportdefault{props:{session:{type: Object,required:true},active:{type: Boolean,default:false}},data(){return{ChatSession:{}}},methods:{deleteSession(session){//请求后台删除接口this.$deleteSession(session.id)//通知父组件删除sessionthis.$emit('delete', session)}}}</script><stylelang="scss"scoped>.session-item{padding: 12px;background-color: white;border-radius: 10px;width: 91%;/* 当鼠标放在会话上时改变鼠标的样式,暗示用户可以点击。目前还没做拖动的效果,以后会做。 */cursor: grab;position: relative;overflow: hidden;.name{font-size: 14px;font-weight: 700;width: 200px;color:rgba(black, 0.8);text-align: left;}.count-time{margin-top: 10px;font-size: 10px;color:rgba(black, 0.5);/* 让消息数量和最近更新时间显示水平显示 */display: flex;/* 让消息数量和最近更新时间分布在水平方向的两端 */justify-content: space-between;}/* 当处于激活状态时增加蓝色描边 */&.active{transition: all 0.12s linear;border: 2px solid #1d93ab;}&:hover{/* 遮罩入场,从最左侧滑进去,渐渐变得不透明 */.mask{opacity: 1;left: 0;}.btn-wrapper{&:hover{cursor: pointer;}/* 按钮入场,从最右侧滑进去,渐渐变得不透明 */opacity: 1;right: 20px;}}.mask{transition: all 0.2s ease-out;position: absolute;background-color:rgba(black, 0.05);width: 100%;height: 100%;top: 0;left: -100%;opacity: 0;}/* 删除按钮样式的逻辑和mask类似 */.btn-wrapper{color:rgba(black, 0.5);transition: all 0.2s ease-out;position: absolute;top: 10px;right: -20px;z-index: 10;opacity: 0;.edit{margin-right: 5px;};
.el-icon-circle-close{display: inline-block;width: 25px;height: 25px;color: red;}}}</style>
上述代码只有一个地方稍稍注意,那就是
<div class="btn-wrapper" @click.stop="$emit('click')">
这里, 在这个div中,我们必须阻止
click
点击事件,防止我们点击div里面的删除元素时,把点击事件传递给div,激活当前session。效果如下:
2.2.3、聊天组件
各个聊天组件如下所示,其中:
2.2.3.1、MessageInput组件
<template><divclass="message-input"><divclass="input-wrapper"><el-inputv-model="message":autosize="false":rows="3"class="input"resize="none"type="textarea"@keydown.native="sendMessage"autofocus="autofocus"></el-input><divclass="button-wrapper"><el-buttonicon="el-icon-position"type="primary"@click="send":disabled="!isSend">
发送
</el-button></div></div></div></template><script>exportdefault{props:{isSend:{type: Boolean,default:false}},data(){return{message:""};},methods:{sendMessage(e){//shift + enter 换行if(!e.shiftKey && e.keyCode ===13){if((this.message +"").trim()===''||this.message.length <=0){return;}// 阻止默认行为,避免换行
e.preventDefault();this.send();}},send(){if(this.isSend){this.$emit('send',this.message);this.message ='';}}}}</script><stylelang="scss"scoped>.message-input{padding: 20px;border-top: 1px solid rgba(black, 0.07);border-left: 1px solid rgba(black, 0.07);border-right: 1px solid rgba(black, 0.07);border-top-right-radius: 5px;border-top-left-radius: 5px;}.button-wrapper{display: flex;justify-content: flex-end;margin-top: 20px;}</style>
2.2.3.2、MessageRow组件
<!-- 整个div是用来调整内部消息的位置,每条消息占的空间都是一整行,然后根据right还是left来调整内部的消息是靠右边还是靠左边 --><template><div:class="['message-row', message.role === 'user' ? 'right' : 'left']"><!-- 消息展示,分为上下,上面是头像,下面是消息 --><divclass="row"><!-- 头像, --><divclass="avatar-wrapper"><el-avatarv-if="message.role === 'user'":src="$store.state.userInfo?$store.state.userInfo.imageUrl:require('@/assets/gpt.png')"class="avatar"shape="square"/><el-avatarv-else:src="require('@/assets/logo.png')"class="avatar"shape="square"/></div><!-- 发送的消息或者回复的消息 --><divclass="message"><!-- 预览模式,用来展示markdown格式的消息 --><client-only><mavon-editorv-if="message.content":class="message.role":style="{
backgroundColor: message.role === 'user' ? 'rgb(231, 248, 255)' : '#f7e8f1e3',
zIndex: 1,
minWidth: '5px',
fontSize:'15px',
}"default-open="preview":subfield='false':toolbarsFlag="false":ishljs="true"ref="md"v-model="message.content":editable="false"/><TextLoadingv-else></TextLoading><!-- 如果消息的内容为空则显示加载动画 --></client-only></div></div></div></template><script>import'@/assets/css/md/github-markdown.css'import TextLoading from'./TextLoading'exportdefault{components:{
TextLoading
},props:{message:{type: Object,default:null}},data(){return{Editor:"",}},created(){}}</script><stylelang="scss"scoped>.message-row{display: flex;&.right{
// 消息显示在右侧
justify-content: flex-end;.row{// 头像也要靠右侧
.avatar-wrapper{display: flex;justify-content: flex-end;}// 用户回复的消息和ChatGPT回复的消息背景颜色做区分
.message{background-color:rgb(231, 248, 255);}}}// 默认靠左边显示
.row{.avatar-wrapper{.avatar{box-shadow: 20px 20px 20px 3px rgba(0, 0, 0, 0.03);margin-bottom: 10px;max-width: 40px;max-height: 40px;background: #d4d6dcdb !important;}}.message{font-size: 15px;padding: 1.5px;
// 限制消息展示的最大宽度
max-width: 500px;
// 圆润一点
border-radius: 7px;
// 给消息框加一些描边,看起来更加实一些,要不然太扁了轻飘飘的。
border: 1px solid rgba(black, 0.1);
// 增加一些阴影看起来更加立体
box-shadow: 20px 20px 20px 1px rgba(0, 0, 0, 0.01);margin-bottom: 5px;}}}.left{text-align: left;.message{background-color:rgba(247, 232, 241, 0.89);}}// 调整markdown组件的一些样式,deep可以修改组件内的样式,正常情况是scoped只能修改本组件的样式。
::v-deep{.v-note-wrapper .v-note-panel .v-note-show .v-show-content, .v-note-wrapper .v-note-panel .v-note-show .v-show-content-html{padding: 9px 10px 0 15px;}.markdown-body{min-height: 0;flex-grow: 1;.v-show-content{background-color: transparent !important;}}}</style>
2.2.3.3、TextLoading组件
<template><divclass="loading"><!-- 三个 div 三个黑点 --><div></div><div></div><div></div></div></template><stylelang="scss"scoped>.loading{
// 三个黑点水平展示
display: flex;
// 三个黑点均匀分布在54px中
justify-content: space-around;color: #000;width: 54px;padding: 15px;div{background-color: currentColor;border: 0 solid currentColor;width: 5px;height: 5px;
// 变成黑色圆点
border-radius: 100%;
// 播放我们下面定义的动画,每次动画持续0.7s且循环播放。
animation: ball-beat 0.7s -0.15s infinite linear;}div:nth-child(2n-1){
// 慢0.5秒
animation-delay: -0.5s;}}
// 动画定义
@keyframes ball-beat{// 关键帧定义,在50%的时候是颜色变透明,且缩小。
50%{opacity: 0.2;transform:scale(0.75);}// 在100%时是回到正常状态,浏览器会自动在这两个关键帧间平滑过渡。
100%{opacity: 1;transform:scale(1);}}</style>
2.2.3.4、scrollToBottom 函数
exportfunctionscrollToBottom(elementId){const container = document.getElementById(elementId);if(!container){return}// 头部const start = container.scrollTop;//底部-头部const change = container.scrollHeight - start;const duration =1000;// 动画持续时间,单位毫秒let startTime =null;constanimateScroll=(timestamp)=>{if(!startTime) startTime = timestamp;const progress = timestamp - startTime;const run =easeInOutQuad(progress, start, change, duration);
container.scrollTop = Math.floor(run);if(progress < duration){requestAnimationFrame(animateScroll);}};// 二次贝塞尔曲线缓动函数functioneaseInOutQuad(t, b, c, d){
t /= d /2;if(t <1)return c /2* t * t + b;
t--;return-c /2*(t *(t -2)-1)+ b;}requestAnimationFrame(animateScroll);}
三、总结
通义千问AI落地前端实现大致如上,在下篇中,我们将继续介绍Websocket是如何实现前后端的消息接受与发送的,敬请期待。。。。
版权归原作者 写完bug就找女朋友 所有, 如有侵权,请联系我们删除。