实现chatgpt聊天机器人打字机效果

思路:markdown-it和highlight是为了解决打字机过程中,写代码时,没有高亮效果的,同时还解决了,在打字过程中,滚动条没有随内容而自动滚动的问题

<template>
  <div class="wd1200">
    <div class="expand">
      <div class="shadow mt10">
        <div class="pad2030 flex between border-b">
          <div class="flex-se">
            <div class="f22 mr20">智能咨询</div>
          </div>
        </div>
        <div ref="scrollable" @scroll="handleScroll" class="pad2030 bfa scroll msg-container border-b f14 scrollbar">
          <div class="flex-st mb10">
            <Avatar name="数据库智能助手小墨" avatar="https://oss-emcsprod-public.modb.pro/image/avatar/401097_1676426340830.jpeg" class="wd50 mr10"></Avatar>
            <div>
              <div class="mt5 c6">数据库智能助手小墨</div>
              <div class="msg-box pad10 bg-active mt10">
                <div>Hi,我是数据库智能助手小墨,您可以向我描述问题。紧急问题请联系墨天轮小助手:modb666。</div>
                <img class="block mt8" src="https://js-cdn.modb.cc/image/modb666.jpg" width="80px" alt="暂无图片">
              </div>
            </div>
          </div>
          <div class="mb10" v-for="(item, idx) in historyList" :key="idx">
            <div class="vr" v-if="item.question">
              <div class="msg-box border-own pad10 bg-blue cf p-wrap">{{ item.question }}</div>
            </div>
            <div class="flex-st" v-if="item.answer">
              <Avatar name="数据库智能助手小墨" avatar="https://oss-emcsprod-public.modb.pro/image/avatar/401097_1676426340830.jpeg" class="wd50 mr10"></Avatar>
              <div>
                <div class="mt5 mb10">
                  <span class="c6 mr10">数据库智能助手小墨</span>
                </div>
                <div class="msg-box pad10 bg-active markdown-body" v-html="item.answer"></div>
              </div>
            </div>
          </div>
          <div class="mb10" v-if="currentText || btnLoading">
            <div class="flex-st">
              <Avatar name="数据库智能助手小墨" avatar="https://oss-emcsprod-public.modb.pro/image/avatar/401097_1676426340830.jpeg" class="wd50 mr10"></Avatar>
              <div>
                <div class="mt5 mb10">
                  <span class="c6 mr10">数据库智能助手小墨</span>
                </div>
                <div>
                  <div v-if="currentText" class="msg-box pad10 bg-active markdown-body" v-html="markdownText"></div>
                  <div v-else class="msg-box pad10 bg-active markdown-body">正在努力思考,请耐心等待...</div>
                </div>
              </div>
            </div>
          </div>
        </div>
        <div class="pad2030">
          <el-input
            class="no-border"
            v-model.trim="consultInfo.question"
            :rows="3"
            type="textarea"
            @keyup.enter.native="submitConsult"
            :disabled="btnLoading"
            placeholder="请清晰描述一下您的问题,如:有哪些课程"/>
          <div class="vr mt20">
            <div class="emcs-btn bold" @click="submitConsult">发送留言</div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
<script>
  import { mapGetters } from 'vuex'
  import { loginmixin } from '@/mixins'
  import { cbSuccess, isMobile } from '@/utils'
  import { getMoaiApi, saveMoaiApi } from '@/apis'
  import MarkdownIt from 'markdown-it'
  import mdKatex from '@traptitech/markdown-it-katex'
  import mila from 'markdown-it-link-attributes'
  import hljs from 'highlight.js'

  function highlightBlock(str, lang) {
    return `<pre class="code-block-wrapper"><div class="code-block-header"></div><code class="hljs code-block-body ${lang}">${str}</code></pre>`
  }

  const mdi = new MarkdownIt({
    html: false,
    linkify: true,
    highlight(code, language) {
      const validLang = Boolean(language && hljs.getLanguage(language));
      if (validLang) {
        const lang = language || '';
        return highlightBlock(hljs.highlight(code, { language: lang }).value, lang);
      }
      return highlightBlock(hljs.highlightAuto(code).value, '');
    },
  })

  mdi.use(mila, { attrs: { target: '_blank', rel: 'noopener' } })
  mdi.use(mdKatex, { blockClass: 'katexmath-block rounded-md p-[10px]', errorColor: ' #cc0000' })


  export default {
    name: 'consult',
    head: {
      title: '智能咨询',
    },
    data () {
      return {
        consultInfo: {
          question: '',
        },
        records: [],
        historyList: [],
        currentText: '',
        mdText: '',
        currentQuestion: '',
        btnLoading: false,
        isAutoScroll: true,
        total: 0,
        threshold: 100,
        // 是否正在进行上啦加载调用
        isFetching: false,
        // 是否正在进行问题回答
        isReadText: false,
        params: {
          pageNum: 1,
          pageSize: 5
        },

        // 记录上一次滚动的高度
        lashScrollTop: 0,
      }
    },
    computed: {
      ...mapGetters(['isSys']),
      markdownText() {
        const value = this.currentText || '';
        this.mdText = mdi.render(value)
        return mdi.render(value);
      }
    },
    mixins: [loginmixin],
    methods: {
      async fetchData () {
        let { data } = await getMoaiApi(this.params)
        let _historyList = data.operateCallBackObj.list
        for (const item of _historyList) {
          item.answer = mdi.render(item.answer)
        }
        this.historyList = _historyList
        this.total = data.operateCallBackObj.total
      },
      async submitConsult(){
        this.scrollToBottom();
        this.isReadText = true; // 开始发送请求相当于正在进行打印
        if (!this.$checkLogin()) return
        let _consultInfo = JSON.parse(JSON.stringify(this.consultInfo))
        if(this.btnLoading || !_consultInfo.question) return
        this.btnLoading = true
        let myInfo = {
          action_id: 'my',
          question: _consultInfo.question
        }
        this.currentQuestion = _consultInfo.question
        this.historyList.push(myInfo)
        this.consultInfo.question = ''
        let { data } = await saveMoaiApi(_consultInfo)
        let _this = this
        if(!data.success && data.operateMessage == '发布频率太快了,先休息一下吧'){
          let operateCallBackObj = {
            answer: '发布频率太快了,先休息一下吧',
            question: "_consultInfo.question"
          }
          data.operateCallBackObj = operateCallBackObj
          _this.readWriter(data, 100)
        }else{
          cbSuccess(data, _ => {
            _this.readWriter(data, 100)
          })
        }
      },
      async readWriter(data, delay) {
        let _this = this
        let _consult = data.operateCallBackObj
        let _text = _consult.answer
        for (let i = 0; i < _text.length; i++) {
          _this.currentText += _text[i];
          this.isReadText = true; // 正在进行问题回答
          if (this.isAutoScroll) { // 只有在自动滚动模式下,才自动滚动
            await this.scrollToBottom();
          }
          await this.sleep(delay);
          if(i == _text.length - 1) {
            _this.currentText = ''
            this.btnLoading = false
            let _actions = {
              answer: _this.mdText
            }
            _this.historyList.push(_actions) 
            this.isReadText = false; // 问题回答结束
          }
        }
      },
      sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
      },
      async scrollToBottom() {
        await this.$nextTick();
        // 判断当前对话框是否产生滚动条
        const scrollable = this.$refs.scrollable
        if(scrollable.scrollHeight <= scrollable.clientHeight) {
          if (this.total > this.historyList.length) {
            await this.fetchMoreData();
          }
        }
        if (this.$refs.scrollable) this.$refs.scrollable.scrollTop = this.$refs.scrollable.scrollHeight;
      },
      handleScroll() { // 新的滚动处理方法
        const scrollable = this.$refs.scrollable;

        const scrollTop = scrollable.scrollTop;

        // 处理上拉加载逻辑
        if (scrollTop <= 240 && !this.isReadText) {
          // 判断滚轮是往下滑动 如果是往下滑动不做处理
          if (scrollTop < this.lashScrollTop && this.total > this.historyList.length) {
            this.fetchMoreData();
          }
        }
        
        this.lashScrollTop = scrollTop;

        const distanceToBottom = scrollable.scrollHeight - scrollable.scrollTop - scrollable.clientHeight;
        if (distanceToBottom <= this.threshold) {
          this.isAutoScroll = true; // 滚动到底部,启动自动滚动
        } else {
          this.isAutoScroll = false; // 用户手动滚动,停止自动滚动
        }
      },

      // 上拉加载函数
      async fetchMoreData() {
        if (this.isFetching) return // 利用变量控制  防止重复触发上拉加载

        if (this.total === this.historyList.length) return 
        this.isFetching = true; // 开始进行上拉加载
        // 这里写上拉加载接口  接口回调完成之后记得重置状态
        this.params.pageNum++
        let { data } = await getMoaiApi(this.params) // 模拟请求状态
        let _historyList = data.operateCallBackObj.list
        for (const item of _historyList) {
          item.answer = mdi.render(item.answer)
        }
        this.historyList.unshift(..._historyList)
        if (this.params.pageNum > 2) {
          this.$refs.scrollable.scrollTop = 200;
        }
        this.isFetching = false; // 上拉加载结束
      }, 

      submitItem(val){
        this.consultInfo.question = val
        this.submitConsult()
      }
    },
    async mounted(){
      this.isMobile = isMobile()
      await this.fetchData()
      this.scrollToBottom()
    }
  }
</script>
<style lang="stylus" scoped>
.wd1200{
  min-width 1200px
  box-sizing border-box
  margin 0 auto
}
.msg-box {
  max-width: 700px;
  display: inline-block;
  border-radius: 0px 16px 16px 16px;
  text-align: left;
}
.border-own {
  border-radius: 16px 16px 0 16px
}
.bg-blue{
  background-color: #4285f4
}
.bg-active {
  background-color: rgba(66,133,244,.08);
}
.border-own {
  border-radius: 16px 16px 0px 16px;
}
.msg-container {
  height: 60vh;
}
.scroll {
  overflow-y: auto;
  -ms-overflow-style: none;
  scrollbar-width: none;
}
.markdown-body{
  background-color rgba(66,133,244,0.08)
}
@media screen and (max-width 768px) {
  .wd1200{
    margin 0 auto
    min-width 0px
    max-width: 100%
  }
  .shadow{
    margin-top 0
  }
}
</style>
  • 11
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值