一、前言
本文源自微博客且已获授权,请尊重版权.
书接上文,上文中,我们介绍了通义千问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> <!-- 最外层页面于窗口同宽,使聊天面板居中 --> <div class="home-view"> <!-- 整个聊天面板 --> <div class="chat-panel"> <!-- 左侧的会话列表 --> <div class="session-panel hidden-sm-and-down"> <div class="title">ChatGPT助手</div> <div class="description">构建你的AI助手</div> <div class="session-list"> <SessionItem v-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> <div class="button-wrapper"> <div class="new-session"> <el-button @click="createSession"> <el-icon :size="15" class="el-icon-circle-plus-outline"></el-icon> 新的聊天 </el-button> </div> </div> </div> <!-- 右侧的消息记录 --> <div class="message-panel"> <!-- 会话名称 --> <div class="header"> <div class="front"> <div v-if="!isEdit" class="title"> <el-input style="font-size: 20px" v-model="activeSession.topic" @keyup.enter.native="editTopic()" ></el-input> </div> <div v-else class="title" style="margin-top: 6px;" @dblclick="editTopic()"> {{ activeSession.topic }} </div> <div class="description">与ChatGPT的 {{ activeSession?.messageSize ?? 0 }} 条对话</div> </div> <!-- 尾部的编辑按钮 --> <div class="rear"> <i v-if="isEdit" @click="editTopic" class="el-icon-edit rear-icon"></i> <i v-else @click="editTopic" class="el-icon-check rear-icon"></i> </div> </div> <el-divider></el-divider> <div class="message-list" id="messageListId"> <!-- 过渡效果 --> <transition-group name="list"> <message-row v-for="(message, index) in activeSession.messages" :key="message.id+`${index}`" :message="message" ></message-row> </transition-group> </div> <div class="toBottom" v-if="!this.isScrolledToBottom"> <el-tooltip class="item" effect="light" content="直达最新" placement="top-center"> <el-button class="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'export default { 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: { async loadChart() { //查询历史对话 const queryArr = { query: { userId: this.userInfo.uid }, pageNum: 1, pageSize: 7 }; let res = await this.$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 = await this.$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) return this.$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) return if (session.messages && session.messages.length > 0) { this.activeSession = null this.activeSession = session this.toBottom() return; } this.$getSessionById(session.id).then(resp => { if (resp.code === 20000) { this.activeSession = null this.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) }, async createChatMessage(chat) { let resp = await this.$createSession(chat) if (resp.code === 20000) { this.activeSession.id = resp.data.id } }, //socket握手 handShake() { this.client = new Client({ //连接地址要加上项目跟地址 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+=1 this.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 = false this.gptRes = { role: 'assistant', content: '', createDate: this.now() } this.activeSession.messages.push(this.gptRes) this.toBottom() this.msgCount = true this.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() { return dayjs().format('YYYY-MM-DD HH:mm:ss'); }, onScroll(event) { this.isScrolledToBottom = event.target.scrollHeight - event.target.scrollTop <= (event.target.clientHeight + 305); }, }, async asyncData({store, redirect}) { const userId = store.state.userInfo && store.state.userInfo.uid if (typeof userId == 'undefined' || userId == null || Object.is(userId, 'null')) { return redirect("/"); } return { userInfo: store.state.userInfo } },}</script><style lang="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+=1 this.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' : '']"> <div class="name">{{ session.topic }}</div> <div class="count-time"> <div class="count">{{ session?.messageSize ?? 0 }}条对话</div> <div class="time">{{ session.updateDate }}</div> </div> <!-- 当鼠标放在会话上时会弹出遮罩 --> <div class="mask"></div> <div class="btn-wrapper" @click.stop="$emit('click')"> <el-popconfirm confirm-button-text='好的' cancel-button-text='不用了' icon="el-icon-circle-close" icon-color="red" @click.prevent="deleteSession(session)" title="是否确认永久删除该聊天会话?" @confirm="deleteSession(session)" > <el-icon slot="reference" :size="15" class="el-icon-circle-close"></el-icon> </el-popconfirm> </div> </div></template><script>export default { props: { session: { type: Object, required: true }, active: { type: Boolean, default: false } }, data() { return { ChatSession: {} } }, methods: { deleteSession(session) { //请求后台删除接口 this.$deleteSession(session.id) //通知父组件删除session this.$emit('delete', session) } }}</script><style lang="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> <div class="message-input"> <div class="input-wrapper"> <el-input v-model="message" :autosize="false" :rows="3" class="input" resize="none" type="textarea" @keydown.native="sendMessage" autofocus="autofocus" > </el-input> <div class="button-wrapper"> <el-button icon="el-icon-position" type="primary" @click="send" :disabled="!isSend"> 发送 </el-button> </div> </div> </div></template><script>export default { 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><style lang="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']"> <!-- 消息展示,分为上下,上面是头像,下面是消息 --> <div class="row"> <!-- 头像, --> <div class="avatar-wrapper"> <el-avatar v-if="message.role === 'user'" :src="$store.state.userInfo?$store.state.userInfo.imageUrl:require('@/assets/gpt.png')" class="avatar" shape="square"/> <el-avatar v-else :src="require('@/assets/logo.png')" class="avatar" shape="square"/> </div> <!-- 发送的消息或者回复的消息 --> <div class="message"> <!-- 预览模式,用来展示markdown格式的消息 --> <client-only> <mavon-editor v-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"/> <TextLoading v-else></TextLoading> <!-- 如果消息的内容为空则显示加载动画 --> </client-only> </div> </div> </div></template><script>import '@/assets/css/md/github-markdown.css'import TextLoading from './TextLoading'export default { components: { TextLoading }, props: { message: { type: Object, default: null } }, data() { return { Editor: "", } }, created(){ }}</script><style lang="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> <div class="loading"> <!-- 三个 div 三个黑点 --> <div></div> <div></div> <div></div> </div></template><style lang="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 函数
export function scrollToBottom(elementId) { const container = document.getElementById(elementId); if (!container) { return } // 头部 const start = container.scrollTop; //底部-头部 const change = container.scrollHeight - start; const duration = 1000; // 动画持续时间,单位毫秒 let startTime = null; const animateScroll = (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); } }; // 二次贝塞尔曲线缓动函数 function easeInOutQuad(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是如何实现前后端的消息接受与发送的,敬请期待。。。。