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

一、前言

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

书接上文,上文中,我们介绍了通义千问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();
                }
            }
        }
  1. handleGPTMsg 这个函数就是处理后端websocket传递过来的消息,其中,最主要的就是这里。在这里,我们设置了一个定时器,每40ms调用一次 appendQueueToContent函数,这个函数从消息队列的队头取出一个字符,把这个字符追加到页面组件上面显示,这样处理使得消息的显示更加平滑,而不是后端生成多少就一次性展示多少。

    if (!this.interval){

    this.interval = setInterval(()=>{

    this.appendQueueToContent()

    },40)

    }

  2. appendQueueToContent 这个函数就是负责从queue里面获取内容,然后追加到gptRes这个变量里面;并且将已经追加的内容从队列里面移除掉。

  3. 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是如何实现前后端的消息接受与发送的,敬请期待。。。。

相关推荐
鹿子沐5 分钟前
LLamaFactory模型导出量化
人工智能·语言模型
skywalk81638 分钟前
尝试Auto-coder.chat使用星河社区AIStudio部署的几个大模型:文心4.5-21b、Deepseek r1 70b、llama 3.1 8b
linux·服务器·人工智能·大模型·aistudio
鹿子沐11 分钟前
LlamaFactory微调效果与vllm部署效果不一致
人工智能·llama
jump68014 分钟前
url输入到网页展示会发生什么?
前端
诸葛韩信17 分钟前
我们需要了解的Web Workers
前端
brzhang22 分钟前
我觉得可以试试 TOON —— 一个为 LLM 而生的极致压缩数据格式
前端·后端·架构
Akamai中国34 分钟前
AI 边缘计算:决胜未来
人工智能·云计算·边缘计算·云服务
陈增林37 分钟前
基于PyQt5的AI文档处理工具
人工智能
yivifu42 分钟前
JavaScript Selection API详解
java·前端·javascript
这儿有一堆花43 分钟前
告别 Class 组件:拥抱 React Hooks 带来的函数式新范式
前端·javascript·react.js