《通义千问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">
                    <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。效果如下:

click.gif

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

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Python OCR 文字识别

一.引言 文字识别&#xff0c;也称为光学字符识别&#xff08;Optical Character Recognition, OCR&#xff09;&#xff0c;是一种将不同形式的文档&#xff08;如扫描的纸质文档、PDF文件或数字相机拍摄的图片&#xff09;中的文字转换成可编辑和可搜索的数据的技术。随着技…

闯关leetcode——3158. Find the XOR of Numbers Which Appear Twice

大纲 题目地址内容 解题代码地址 题目 地址 https://leetcode.com/problems/find-the-xor-of-numbers-which-appear-twice/description/ 内容 You are given an array nums, where each number in the array appears either once or twice. Return the bitwise XOR of all …

深度学习中的并行策略概述:2 Data Parallelism

深度学习中的并行策略概述&#xff1a;2 Data Parallelism 数据并行&#xff08;Data Parallelism&#xff09;的核心在于将模型的数据处理过程并行化。具体来说&#xff0c;面对大规模数据批次时&#xff0c;将其拆分为较小的子批次&#xff0c;并在多个计算设备上同时进行处…

shiro权限校验demo

这里通过链式hashmap添加进去接口权限&#xff0c;用安全管理器设置过滤&#xff0c;并且设置登录跳转&#xff08;登录页面需要自己写&#xff0c;shiro不提供&#xff0c;不像springboot那样智能&#xff09; 效果如下&#xff1a; 点击add和update均跳转到如下登录页面 那么…

基于单片机的多功能智能小车(论文+源码)

1.系统整体方案 此次多功能智能小车的设计系统&#xff0c;其整个控制电路的框架如下图所示。整个系统采用STC89C52单片机为控制器其中&#xff1a;LCD液晶负责显示当前信息,蜂鸣器负责特殊情况下进行报警提醒,红外遥控模块方便用户进行远程操作小车,电机模块拟采用前驱的方式…

Log4j1.27配置日志输出级别不起效

起因&#xff1a;构建独立版本debezuim使用时&#xff0c;日志一直打印debug信息。 原因&#xff1a;包冲突问题&#xff0c;进行排包操作。 参考log4j日志级别配置完成后不生效 系统一直打印debug日志_log4j不起作用-CSDN博客 1、application.properties logging.configc…

LabVIEW如何学习FPGA开发

FPGA&#xff08;现场可编程门阵列&#xff09;开发因其高性能、低延迟的特点&#xff0c;在实时控制和高速数据处理领域具有重要地位。LabVIEW FPGA模块为开发者提供了一个图形化编程平台&#xff0c;降低了FPGA开发的门槛。本篇文章将详细介绍LabVIEW FPGA开发的学习路径&…

shell脚本定义特殊字符导致执行mysql文件错误的问题

记得有一次版本发布过程中有提供一个sh脚本用于一键执行sql文件&#xff0c;遇到一个shell脚本定义特殊字符的问题&#xff0c;sh脚本的内容类似以下内容&#xff1a; # 数据库ip地址 ip"127.0.0.1" # 数据库密码 cmdbcmdb!#$! smsm!#$!# 执行脚本文件&#xff08;参…

Jimureport h2命令执行分析记录

首先找testConnection接口&#xff0c;前面进行了jimureport-spring-boot-starter-1.5.8.jar反编译查找&#xff0c;接口找到发现请求参数是json var1是JmreportDynamicDataSourceVo类型&#xff0c;也就是如上图的dbSource&#xff0c;根据打印的结果可以知道这里是local cac…

蓝牙协议——音量控制

手机设置绝对音量 使用Ellisys查看如下&#xff1a; 使用Wireshark查看如下&#xff1a; 音量的量程是128&#xff0c;0x44的十进制是68&#xff0c;53%或54%音量的计算如下&#xff1a; 68 / 128 53.125%耳机设置绝对音量

熊军出席ACDU·中国行南京站,详解SQL管理之道

12月21日&#xff0c;2024 ACDU中国行在南京圆满收官&#xff0c;本次活动分为三个篇章——回顾历史、立足当下、展望未来&#xff0c;为线上线下与会观众呈现了一场跨越时空的技术盛宴&#xff0c;吸引了众多业内人士的关注。云和恩墨副总经理熊军出席此次活动并发表了主题演讲…

提高保养效率:4S店预约系统的设计与开发

3.1可行性分析 开发者在进行开发系统之前&#xff0c;都需要进行可行性分析&#xff0c;保证该系统能够被成功开发出来。 3.1.1技术可行性 开发该4S店预约保养系统所采用的技术是vue和MYSQL数据库。计算机专业的学生在学校期间已经比较系统的学习了很多编程方面的知识&#xff…

简单了解函数递归

函数递归 一 了解函数递归二 深入理解函数递归的思想三 函数递归的优缺点 一 了解函数递归 首先&#xff0c;我们通过一个简单的代码来理解函数递归。 #include<stdio.h> int Func() {return Func(n1); } int main() {int n 5;Func(n);return 0; }这个就是函数递归&am…

重温设计模式--设计模式七大原则

文章目录 1、开闭原则&#xff08;Open - Closed Principle&#xff0c;OCP&#xff09;定义&#xff1a;示例&#xff1a;好处&#xff1a; 2、里氏替换原则&#xff08;Liskov Substitution Principle&#xff0c;LSP&#xff09;定义&#xff1a;示例&#xff1a;好处&#…

GiliSoft AI Toolkit v10.1

Gilisoft AI Toolkit是一个综合性的软件包&#xff0c;为企业和个人提供了一个集成人工智能技术到其工作流程中的解决方案。该软件包包括了多种与人工智能相关的工具&#xff0c;如聊天机器人、光学字符识别(OCR)、文本到语音(TTS)和自动语音识别(ASR)软件。它的目的是通过各种…

四种自动化测试模型实例及优缺点详解

一、线性测试 1.概念&#xff1a; 通过录制或编写对应应用程序的操作步骤产生的线性脚本。单纯的来模拟用户完整的操作场景。 &#xff08;操作&#xff0c;重复操作&#xff0c;数据&#xff09;都混合在一起。 2.优点&#xff1a; 每个脚本相对独立&#xff0c;且不产生…

git自己模拟多人协作

目录 一、项目克隆 二、多人协作 1.创建林冲仓库 2.协作处理 3.冲突处理 三、分支推送协作 1.创建develop分支 2.发现git push无法把develop推送到远程 ​编辑 3.本地的分支推送到远程分支 四、分支拉取协作 五、远程分支的删除 远程仓库用的gitee 一、项目克隆 …

数据结构---------二叉树前序遍历中序遍历后序遍历

以下是用C语言实现二叉树的前序遍历、中序遍历和后序遍历的代码示例&#xff0c;包括递归和非递归&#xff08;借助栈实现&#xff09;两种方式&#xff1a; 1. 二叉树节点结构体定义 #include <stdio.h> #include <stdlib.h>// 二叉树节点结构体 typedef struct…

多智能体/多机器人网络中的图论法

一、引言 1、网络科学至今受到广泛关注的原因&#xff1a; &#xff08;1&#xff09;大量的学科&#xff08;尤其生物及材料科学&#xff09;需要对元素间相互作用在多层级系统中所扮演的角色有更深层次的理解&#xff1b; &#xff08;2&#xff09;科技的发展促进了综合网…

电脑ip地址会变化吗?电脑ip地址如何固定

在数字化时代&#xff0c;IP地址作为网络设备的唯一标识符&#xff0c;对于网络通信至关重要。然而&#xff0c;许多用户可能会发现&#xff0c;自己的电脑IP地址并非一成不变&#xff0c;而是会随着时间的推移或网络环境的变化而发生变化。这种变化有时会给用户带来困扰&#…