《通义千问AI落地—中》:前端实现

一、前言

本文源自微博客且已获授权,请尊重版权.

     书接上文,上文中,我们介绍了通义千问AI落地的后端接口。那么,接下来我们将继续介绍前端如何调用接口以及最后的效果;首先看效果:

result.gif

     上述就是落地到本微博客以后的页面效果,由于是基于落在现有项目之上,因此什么登录注册等基本功能都省去了,言归正传,下面我们将正式介绍通义千问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"><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><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-rowv-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.topicreturn}}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.isEditif (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 = sessionthis.toBottom()return;}this.$getSessionById(session.id).then(resp => {if (resp.code === 20000) {this.activeSession = nullthis.activeSession = resp.datathis.toBottom()this.sessionList[index] = resp.datathis.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.bodythis.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() {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.uidif (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+=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();}}}
  1. handleGPTMsg 这个函数就是处理后端websocket传递过来的消息,其中,最主要的就是这里。在这里,我们设置了一个定时器,每40ms调用一次 appendQueueToContent函数,这个函数从消息队列的队头取出一个字符,把这个字符追加到页面组件上面显示,这样处理使得消息的显示更加平滑,而不是后端生成多少就一次性展示多少。
if (!this.interval){this.interval = setInterval(()=>{this.appendQueueToContent()},40)
}
  1. appendQueueToContent 这个函数就是负责从queue里面获取内容,然后追加到gptRes这个变量里面;并且将已经追加的内容从队列里面移除掉。
  2. 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-popconfirmconfirm-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)//通知父组件删除sessionthis.$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。效果如下:

click.gif

2.2.3、聊天组件

各个聊天组件如下所示,其中:

2.2.3.1、MessageInput组件
<template><div class="message-input"><div class="input-wrapper"><el-inputv-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是如何实现前后端的消息接受与发送的,敬请期待。。。。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/407131.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Java常用的网络IO模型与限流算法总结

什么是IO(Input/Output)? IO&#xff08;输入/输出&#xff09;指的是计算机系统中数据的输入和输出操作。它涉及从外部设备&#xff08;如硬盘、网络、键盘、鼠标&#xff09;读取数据&#xff08;输入&#xff09;和将数据发送到这些设备&#xff08;输出&#xff09;。IO操…

WPF如何获取DataGrid的选中行

在DataGrid中加入这一行 <MouseBindingCommand"{Binding OpenWindowCommand}"CommandParameter"{Binding ElementNameNewPlanDataGrid, PathSelectedItem}"Gesture"LeftDoubleClick" /> </DataGrid.InputBindings> 然后ViewModel中…

[每周一更]-(第111期):从零开始:如何在 CentOS 上源码编译安装 PHP 7.4

文章目录 系统信息&#xff1a;0、安装版本&#xff1a;1、下载/解压2、安装依赖3、配置autoconf4、配置参数5、编译和安装6、验证安装的插件6.1、配置php.ini6.2、配置opcache 7、错误7.1 Failed to connect to 2a03:2880:f10e:83:face:b00c:0:25de: Network is unreachable7.…

2024年最新Flink教程,从基础到就业,大家一起学习--Flink运行架构底层源码详解+实战

本文涉及到大量的底层原理知识&#xff0c;包括运行机制图解都非常详细&#xff0c;还有一些实战案例&#xff0c;所以导致本篇文章会比较长&#xff0c;内容比较多&#xff0c;由于内容太多&#xff0c;很多目录可能展示不出来&#xff0c;需要去细心的查看&#xff0c;非常适…

VS项目写完执行exe隐藏调试用的黑窗口(控制台)

在vs创建完项目&#xff0c;我们只希望运行显示界面&#xff0c;不显示控制台&#xff0c;控制台就是这样的黑色窗口&#xff0c;他可以在我们调试的时候打印一些东西来判断辅助编程。 1、首先修改为窗口模式 2、在你的main文件里最上面加入一行代码&#xff1a; #pragma comme…

【hot100篇-python刷题记录】【滑动窗口最大值】

R6-子串篇 目录 Max Sort 单调队列法&#xff1a; Max 完了&#xff0c;我好像想到python的max class Solution:def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:ret[]left,right0,kwhile right<len(nums):ret.append(max(nums[left:right]))ri…

信刻光盘摆渡系统安全合规实现跨网数据单向导入/导出

在当今信息化、数字化时代&#xff0c;各种数据传输和储存技术发展迅速&#xff0c;各安全领域行业对跨网数据交互需求日益迫切&#xff0c;数据传输的安全可靠性对于整个过程的重要性不可忽视。应如何解决网络安全与效率之间的矛盾&#xff0c;如何安全合规地实现跨网数据单向…

分支电路导体的尺寸确定和保护

本文旨在确定为分支电路负载供电的导体的尺寸和保护。 支路额定电流 NEC 第 210 条规定了分支电路导体尺寸和过流保护的一般要求。 允许额定电流或过流保护装置的设置确定了分支电路额定值 (210.18)。电路的安培额定值取决于保护导体的断路器或保险丝的额定值&#xff0c;而…

江协科技STM32学习- P5 GPIO输出

&#x1f680;write in front&#x1f680; &#x1f50e;大家好&#xff0c;我是黄桃罐头&#xff0c;希望你看完之后&#xff0c;能对你有所帮助&#xff0c;不足请指正&#xff01;共同学习交流 &#x1f381;欢迎各位→点赞&#x1f44d; 收藏⭐️ 留言&#x1f4dd;​…

车载T-Box通信稳定性弱网测试方案

作者介绍 T-Box&#xff08;Telematics Box&#xff0c;车载终端&#xff09;是一种安装在汽车上的控制器&#xff0c;用于实现车辆的远程监控、数据采集、通信和控制等功能。T-Box是连接汽车与外部世界的关键节点之一&#xff0c;在汽车网联中扮演着重要的角色。通过T-Box&…

二分图总结

二分图总结 前言二分图总结二分图基本概念什么是二分图&#xff1f;二分图图的性质染色法判断是否有奇环 二分图匹配算法匹配概念匈牙利算法二分图最小点覆盖2&#xff0c;3号边1&#xff0c;4号边小结 二分图最小边覆盖二分图最小路径覆盖二分图最大独立集 前言 这篇文章是作者…

conda加速下载

目录 一、镜像源设置 1、查看当前的下载源&#xff08;初始&#xff09; 2、修改国内源 二、附录 1、还原默认源 2、移除指定源 3、EBUG:urllib3.connectionpool:Starting new HTTPS connection (1): repo.anaconda.com:443的解决方法 4、可以指定网址安装 一、镜像源设…

答题小程序的轮播图管理与接入获取展示实现

实现了答题小程序的轮播图管理&#xff0c;包括上传图片、设置轮播图、操作上下线等功能&#xff0c;可用于管理各类答题小程序的轮播图。 轮播图前端接入代码 答题小程序内使用以下代码接入轮播图&#xff1a; WXML&#xff1a; <view style"width: 100%"> …

最少钱学习并构建大模型ollama-llama3 8B

学习大模型时可能面临一些困难&#xff0c;这些困难可能包括&#xff1a; 计算资源限制&#xff1a;训练大模型通常需要大量的计算资源&#xff0c;包括CPU、GPU等。如果设备资源有限&#xff0c;可能会导致训练时间长、效率低下或无法完成训练。 内存限制&#xff1a;大模型通…

AI绘画SD必学技能—从零开始训练你的专属Lora 模型!StableDiffusion模型训练保姆级教程建议收藏!

大家好&#xff0c;我是画画的小强 接触AI绘画的小伙伴&#xff0c;一定听过Lora。 Lora模型全称是&#xff1a;Low-Rank Adaptation of Large Language Models&#xff0c;可以理解为Stable-Diffusion中的一个插件&#xff0c;在生成图片时&#xff0c;Lora模型会与大模型结…

数学建模比赛(国赛)水奖攻略

之前很多同学私聊问我&#xff0c;学校要求参加数模比赛&#xff0c;但是不擅长建模编程&#xff0c;但又不想浪费这个时间该怎么办呢&#xff0c;今天就来给大家讲一下大家都非常感兴趣的内容——数学建模水奖攻略。分享一下博主直接参加比赛时候的经验。 一、选题技巧 有一句…

【Python】链式、嵌套调用、递归、函数栈帧、参数默认值和关键字参数

链式调用 用一个函数的返回值&#xff0c;作为另一个函数的参数 def isOdd(num): if num % 2 0: return False return True def add(x, y): return x y print(isOdd(add(3,4)))""" 运行结果"""这里就是先算出 add 的值&#xff0c;然后…

使用ftl文件导出时,多层嵌套循环

核心点 //针对集合1进行循环 <#list priceDetail as pd>//对集合1中包含的集合2进行存在和判空 判断<#if pd.detail ?exists && pd.detail ?size!0> //对集合2进行循环<#list pd.detail as d>...</#list></#if></#list> 模版…

wincc报警如何通过短信发送给手机

单位使用WINCC上位机监控现场&#xff0c;需要把报警信息发送到指定手机上&#xff0c;能否实现&#xff1f;通过巨控GRMOPC系列远程智能控制终端&#xff0c;简单配置即可实现wincc报警短信传送到手机。配置过程无需任何通讯程序&#xff0c;也不要写任何触发脚本。 GRMOPC模…

【数据结构】归并排序

1、介绍 归并排序&#xff08;merge sort&#xff09;是一种基于分治策略的排序算法&#xff0c;包含“划分”和“合并”阶段。 划分阶段&#xff1a;通过递归不断地将数组从中点处分开&#xff0c;将长数组的排序问题转换为短数组的排序问题。 合并阶段&#xff1a;当子数组…