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

一、前言

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

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

上述就是落地到本微博客以后的页面效果,由于是基于落在现有项目之上,因此什么登录注册等基本功能都省去了,言归正传,下面我们将正式介绍通义千问AI落地的前端实现。

二、前端实现

2.1、前端依赖

前端所需依赖基本如下(本项目前端是基于Nuxtjs的,这样又益与SSE,所以有nuxt相关依赖):

json 复制代码
    "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管理和右侧的消息管理,内容如下:

html 复制代码
<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>

我们来着重介绍一下以下三个函数:

javascript 复制代码
 /**
         * 处理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();
                }
            }
        }
  1. handleGPTMsg 这个函数就是处理后端websocket传递过来的消息,其中,最主要的就是这里。在这里,我们设置了一个定时器,每40ms调用一次 appendQueueToContent函数,这个函数从消息队列的队头取出一个字符,把这个字符追加到页面组件上面显示,这样处理使得消息的显示更加平滑,而不是后端生成多少就一次性展示多少。
javascript 复制代码
if (!this.interval){
    this.interval = setInterval(()=>{
        this.appendQueueToContent()
    },40)
}
  1. appendQueueToContent 这个函数就是负责从queue里面获取内容,然后追加到gptRes这个变量里面;并且将已经追加的内容从队列里面移除掉。
  2. handleLastMsgQueue 由于前后端处理消息的速度是不一样的,当后端发送生成结束标记(即 !$$---END---$$! )后,前端就需要以此为根据,删除定时器,否则我们没办法知道queue消息队列什么时候为空、什么时候该清楚定时器。那么,此时清除定时器,我们就需要用一个while函数来处理queue里面剩下的内容,handleLastMsgQueue 函数就是干这个的。

2.2.2、session管理组件

这个组件没有什么隐晦难懂的知识,直接贴代码:

html 复制代码
<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组件
html 复制代码
<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组件
html 复制代码
<!-- 整个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组件
html 复制代码
<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 函数
javascript 复制代码
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是如何实现前后端的消息接受与发送的,敬请期待。。。。

相关推荐
SpikeKing12 分钟前
LLM - 使用 LLaMA-Factory 微调大模型 环境配置与训练推理 教程 (1)
人工智能·llm·大语言模型·llama·环境配置·llamafactory·训练框架
黄焖鸡能干四碗41 分钟前
信息化运维方案,实施方案,开发方案,信息中心安全运维资料(软件资料word)
大数据·人工智能·软件需求·设计规范·规格说明书
42 分钟前
开源竞争-数据驱动成长-11/05-大专生的思考
人工智能·笔记·学习·算法·机器学习
ctrey_1 小时前
2024-11-4 学习人工智能的Day21 openCV(3)
人工智能·opencv·学习
攻城狮_Dream1 小时前
“探索未来医疗:生成式人工智能在医疗领域的革命性应用“
人工智能·设计·医疗·毕业
学习前端的小z1 小时前
【AIGC】如何通过ChatGPT轻松制作个性化GPTs应用
人工智能·chatgpt·aigc
埃菲尔铁塔_CV算法2 小时前
人工智能图像算法:开启视觉新时代的钥匙
人工智能·算法
EasyCVR2 小时前
EHOME视频平台EasyCVR视频融合平台使用OBS进行RTMP推流,WebRTC播放出现抖动、卡顿如何解决?
人工智能·算法·ffmpeg·音视频·webrtc·监控视频接入
打羽毛球吗️2 小时前
机器学习中的两种主要思路:数据驱动与模型驱动
人工智能·机器学习
光芒再现dev2 小时前
已解决,部署GPTSoVITS报错‘AsyncRequest‘ object has no attribute ‘_json_response_data‘
运维·python·gpt·语言模型·自然语言处理