1、基本实现电子说明书页面布局及功能
2、基本实现语言大模型页面与后端数据联动
已添加4个文件
已修改4个文件
1458 ■■■■ 文件已修改
public/index.html 28 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ai.js 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/page/electronicManual/back.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/page/electronicManual/document.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/page/electronicManual/search.png 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ai/ElectronicManual.vue 826 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ai/LanguageModel.vue 517 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vue.config.js 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/index.html
@@ -223,21 +223,35 @@
    /* æ»šåŠ¨æ¡ä¼˜åŒ– start */
    ::-webkit-scrollbar{
      width:0;
      height:8px;
      height:0;
    }
    /*::-webkit-scrollbar-track{*/
      /*background: #f6f6f6;*/
      /*border-radius:20px;*/
    /*}*/
    /*::-webkit-scrollbar-thumb{*/
      /*background: #e9e9e9;*/
      /*border-radius:20px;*/
    /*}*/
    /*::-webkit-scrollbar-thumb:hover{*/
      /*background: #cfcfcf;*/
    /*}*/
    /*::-webkit-scrollbar-corner {*/
      /*background: #f6f6f6;*/
    /*}*/
    ::-webkit-scrollbar-track{
      background: #f6f6f6;
      border-radius:2px;
    background: transparent;
    border-radius:20px;
    }
    ::-webkit-scrollbar-thumb{
      background: #cdcdcd;
      border-radius:2px;
    background: transparent;
    border-radius:20px;
    }
    ::-webkit-scrollbar-thumb:hover{
      background: #747474;
    background: transparent;
    }
    ::-webkit-scrollbar-corner {
      background: #f6f6f6;
    background: transparent;
    }
    /* æ»šåŠ¨æ¡ä¼˜åŒ– end */
  </style>
src/api/ai.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
import { axios } from '@/utils/request'
import { getAction, deleteAction, putAction, postAction, httpAction } from '@/api/manage'
/*------------------------------------------------电子说明书-----------------------------------------------------*/
export const getPdfImgApi = (params) => getAction('/ai/fileImg/list', params)
export const getPdfDocumentApi = () => getAction('/ai/filePdf/filePdfList')
export const getFurtherFilterImgApi = params => putAction('/ai/filePdf/findImgList', params)
/*------------------------------------------------语言大模型-----------------------------------------------------*/
export const askToLanguageModelApi = params => fetch('/chat/test_chat', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify(params)
})
export const addNewConversationApi = params => postAction('/ai/languageModel/addLanguage', params)
export const getChatHistoryListApi = () => getAction('/ai/languageModel/languageTitles')
export const getCurrentConversationApi = params => getAction('/ai/languageModel/languageProblems', params)
export const deleteSingleChatHistoryApi = params => deleteAction('/ai/languageModel/deleteLanguage', params)
src/assets/page/electronicManual/back.png
src/assets/page/electronicManual/document.png
src/assets/page/electronicManual/search.png
src/views/ai/ElectronicManual.vue
@@ -1,48 +1,832 @@
<template>
  <div>
    ç”µå­è¯´æ˜Žä¹¦
    <a-card>
      {{answer}}
    </a-card>
    <a-input-search placeholder="input search text" enter-button @search="onSearch"/>
  <div class="page-container">
    <!--电子说明书-->
    <div class="outer-container">
      <!--左侧阅读模式缩略图区域-->
      <div class="left-spin-container">
        <a-spin :spinning="thumbnailSpinning" :delay="spinningDelayTime">
          <a-icon slot="indicator" type="loading" spin/>
        </a-spin>
        <div class="left-container">
          <div v-for="item in imgListConfig.records" :key="item.id" class="single-thumbnail-container"
               :class="[item.id===activeImageId?'single-thumbnail-active':'']" :id="'thumbnail-container-'+item.id"
               @click="activeCurrentThumbnail(item.id)">
            <!--<img src="@/assets/page/electronicManual/document.png">-->
            <div class="thumbnail-image-container">
              <a-skeleton :loading="item.loading" :avatar="{shape:'square'}" :title="false" :paragraph="false" active/>
              <img :id="'thumbnail-image-'+item.id" :data-src="getImgView(item.imgPath+item.imgEncodeName)"
                   @load="imageLoadDone(item)" :style="{opacity:item.loading?0:1}">
            </div>
            <div>-{{item.pageNumber}}-</div>
          </div>
        </div>
      </div>
      <!--右侧区域-->
      <div class="right-container">
        <div class="right-top-container">
          <div class="document-spin-container">
            <a-spin :spinning="documentSpinning" :delay="spinningDelayTime">
              <a-icon slot="indicator" type="loading" spin/>
            </a-spin>
            <!--右上文档区域-->
            <div class="document-container" @wheel="horizontalScroll">
              <div v-for="item in documentList" :key="item.id" class="single-document-container"
                   @click="activeCurrentDocument(item.id)"
                   :class="[item.id===activeDocumentId?'single-document-active':'']">
                <div><img src="@/assets/page/electronicManual/document.png"></div>
                <div class="single-document-name">
                  {{item.fileName?item.fileName.length>20?item.fileName.slice(0,20)+'..':item.fileName:'未命名说明书'}}
                </div>
              </div>
            </div>
          </div>
          <!--右上输入查询区域-->
          <div class="search-container">
            <div>您当前选择的电控系统为:xxx-xxxx-xxxxx&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;机床型号为:xxx-xx</div>
            <div class="input-container">
              <input @keydown.enter="furtherFilter" placeholder="请用一句话描述您当前遇到的问题" v-model="inputQuestion"/>
              <img src="@/assets/page/electronicManual/search.png" @click="furtherFilter"
                   :style="{cursor:!thumbnailSpinning?'pointer':'not-allowed'}">
            </div>
          </div>
        </div>
        <!--右下区域-->
        <div class="right-bottom-spin-container">
          <a-spin :spinning="largeImageSpinning" :delay="spinningDelayTime">
            <a-icon slot="indicator" type="loading" spin/>
          </a-spin>
          <!--右下阅读模式区域-->
          <div class="right-bottom-container" :class="[isFurtherFilter?'further-filter-container':'']">
            <template v-if="!isFurtherFilter">
              <div v-for="item in imgListConfig.records" :key="item.id" class="single-largeImg-container"
                   :id="'large-image-container-'+item.id">
                <a-skeleton :loading="item.loading" :avatar="{shape:'square'}" :title="false"
                            :paragraph="false" active/>
                <img :id="'large-image-'+item.id" :data-src="getImgView(item.imgPath+item.imgEncodeName)"
                     @load="imageLoadDone(item)" :style="{opacity:item.loading?0:1}">
                <!--<img src="@/assets/page/electronicManual/document.png">-->
              </div>
            </template>
            <!--右下深层过滤区域-->
            <template v-else>
              <div v-for="item in furtherFilterImgList" :key="item.id" class="single-filterImg-container"
                   @click="locateToDocument(item)">
                <!--<img src="@/assets/page/electronicManual/document.png">-->
                <a-skeleton :loading="item.loading" :avatar="{shape:'square'}" :title="false"
                            :paragraph="false" active/>
                <img :id="'filter-image-'+item.id" :data-src="getImgView(item.imgPath+item.imgEncodeName)"
                     @load="imageLoadDone(item)" :style="{opacity:item.loading?0:1}"
                >
              </div>
              <img src="@/assets/page/electronicManual/back.png" id="back-to-largeImg" @click="cancelFurtherFilter"
                   v-if="!largeImageSpinning">
            </template>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
<script>
  import { getDataAfterSearchByApi } from '@/api/AI'
  import { getPdfImgApi, getPdfDocumentApi, getFurtherFilterImgApi } from '@/api/ai'
  import { getFileAccessHttpUrl } from '@/api/manage'
  import { message } from 'ant-design-vue'
  message.config({
    maxCount: 1,
    duration: 2
  })
  export default {
    name: 'ElectronicManual',
    components: {},
    data() {
      return {
        answer:''
        rightBottomContainer: null,
        activeDocumentId: null,
        activeImageId: null,
        inputQuestion: '',
        isFurtherFilter: false,
        beforeFilterActiveDocumentId: null,
        beforeFilterActiveImageId: null,
        documentList: [],
        // documentList: [
        //   {
        //     id: 1,
        //     fileName: '机床手册1'
        //   },
        //   {
        //     id: 2,
        //     fileName: '机床手册2'
        //   },
        //   {
        //     id: 3,
        //     fileName: '机床手册3'
        //   },
        //   {
        //     id: 4,
        //     fileName: '机床手册4'
        //   },
        //   {
        //     id: 5,
        //     fileName: '机床手册5'
        //   },
        //   {
        //     id: 6,
        //     fileName: '机床手册6'
        //   },
        //   {
        //     id: 7,
        //     fileName: '机床手册7'
        //   },
        //   {
        //     id: 8,
        //     fileName: '机床手册8'
        //   },
        //   {
        //     id: 9,
        //     fileName: '机床手册9'
        //   },
        //   {
        //     id: 10,
        //     fileName: '机床手册10'
        //   }
        // ],
        imgListParams: {
          pageNo: 1,
          pageSize: 20
        },
        imgListConfig: {
          records: []
        },
        scrollToMarginConfig: {
          hasScrollToTopCount: 0,
          hasScrollToBottomCount: 0
        },
        thumbnailSpinning: false,
        documentSpinning: false,
        largeImageSpinning: false,
        spinningDelayTime: 500,
        furtherFilterImgListParams: {},
        furtherFilterImgList: []
      }
    },
    created() {
      this.getPdfDocumentByApi()
    },
    mounted() {
      this.leftContainer = document.querySelector('.left-container')
      this.rightBottomContainer = document.querySelector('.right-bottom-container')
      this.handleScrollEventSwitch(true)
    },
    methods: {
      onSearch() {
        const param = {
          'id': '683a65fd-8feb-4446-ad32-714c4785f667',
          'messages': [
            {
              'role': 'user',
              'content': '给我讲个故事?'
      /* è°ƒç”¨æŽ¥å£èŽ·å–pdf文档 */
      getPdfDocumentByApi() {
        this.documentSpinning = true
        getPdfDocumentApi()
          .then(res => {
            if (res.success) {
              this.documentList = res.result
              this.documentSpinning = false
              this.activeCurrentDocument(res.result[0].id)
            }
          ],
          'stream': false,
          'max_tokens': 500
          })
      },
      /**
       * ç‚¹å‡»æ–‡æ¡£åŽè§¦å‘
       * @param pdfFileId æ–‡æ¡£Id
       * @param furtherFilterImageId æ·±å±‚过滤模式中点击的图片Id
       */
      activeCurrentDocument(pdfFileId, furtherFilterImageId = null, furtherFilterImagePageNumber = null) {
        if (pdfFileId === this.activeDocumentId && !furtherFilterImageId) return
        if (this.isFurtherFilter) {
          this.isFurtherFilter = false
          this.handleScrollEventSwitch(true)
        }
        getDataAfterSearchByApi()
        if (!furtherFilterImageId) this.scrollToTop()
        this.imgListConfig = {}
        this.activeDocumentId = pdfFileId
        this.imgListParams.pageNo = 1
        this.resetImgListScrollConfig()
        if (furtherFilterImagePageNumber && furtherFilterImagePageNumber > this.imgListParams.pageSize) this.computeImgListPageNo(furtherFilterImagePageNumber)
        const params = Object.assign({ pdfFileId }, this.imgListParams)
        this.thumbnailSpinning = true
        this.largeImageSpinning = true
        getPdfImgApi(params)
          .then(res => {
            if (res.success) {
              this.imgListConfig = res.result
              this.imgListConfig.records = this.imgListConfig.records.map(item => {
                return {
                  ...item,
                  loading: true
                }
              })
              this.activeImageId = res.result.records[0].id
              this.$nextTick(() => {
                this.lazyLoadImgByIntersectionObserver(res.result.records, 'thumbnail-image', 4, furtherFilterImageId)
                this.lazyLoadImgByIntersectionObserver(res.result.records, 'large-image', 1, furtherFilterImageId)
              })
              this.thumbnailSpinning = false
              this.largeImageSpinning = false
              // å¦‚果点击的是深层过滤的图片则返回阅读模式并放大选中的图片
              if (furtherFilterImageId) this.$nextTick(() => this.activeCurrentThumbnail(furtherFilterImageId, true))
            }
          })
      },
      /**
       * ç‚¹å‡»ç¼©ç•¥å›¾åŽè§¦å‘在右侧放大缩略图
       * @param id ç¼©ç•¥å›¾Id
       * @param isFromFurtherFilter æ˜¯å¦é€šè¿‡æ·±å±‚过滤模式触发
       */
      activeCurrentThumbnail(id, isFromFurtherFilter = false) {
        if (id === this.activeImageId && !isFromFurtherFilter) return
        this.activeImageId = id
        if (isFromFurtherFilter) this.scrollToImagePosition('thumbnail-container', true, 'left', 0)
        this.scrollToImagePosition()
      },
      computeImgListPageNo(furtherFilterImagePageNumber) {
        const integer = Math.floor(furtherFilterImagePageNumber / this.imgListParams.pageSize)
        const remainder = furtherFilterImagePageNumber % this.imgListParams.pageSize
        if (remainder !== 0) {
          // ä¸ä¸ºæ•´æ•°
          this.imgListParams.pageNo = integer + 1
        } else {
          // ä¸ºæ•´æ•°
          this.imgListParams.pageNo = integer
        }
      },
      /* é˜…读模式下的滚动触顶底刷新 */
      lazyLoadData(event) {
        if (this.thumbnailSpinning && this.largeImageSpinning) return // åŠ è½½ä¸­æ—¶å–æ¶ˆè°ƒç”¨
        const containerScrollTop = event.target.scrollTop
        const containerClientHeight = event.target.clientHeight
        const containerScrollHeight = event.target.scrollHeight
        if (containerScrollTop + containerClientHeight >= containerScrollHeight) {
          console.log('触底')
          this.scrollToMarginConfig.hasScrollToBottomCount++
          const params = Object.assign({ pdfFileId: this.activeDocumentId }, this.imgListParams)
          params.pageNo = params.pageNo + this.scrollToMarginConfig.hasScrollToBottomCount
          this.thumbnailSpinning = true
          this.largeImageSpinning = true
          getPdfImgApi(params)
            .then(res => {
              if (res.success) {
                const newImgList = res.result.records.map(item => {
                  return {
                    ...item,
                    loading: true
                  }
                })
                this.imgListConfig.records.push(...newImgList)
                this.$nextTick(() => {
                  this.lazyLoadImgByIntersectionObserver(newImgList, 'thumbnail-image', 4)
                  this.lazyLoadImgByIntersectionObserver(newImgList, 'large-image', 1)
                })
                this.thumbnailSpinning = false
                this.largeImageSpinning = false
              }
            })
        } else if (containerScrollTop <= 0 && this.imgListConfig.records[0].pageNumber !== 1) {
          console.log('触顶')
          this.scrollToMarginConfig.hasScrollToTopCount++
          const params = Object.assign({ pdfFileId: this.activeDocumentId }, this.imgListParams)
          params.pageNo = params.pageNo - this.scrollToMarginConfig.hasScrollToTopCount
          this.thumbnailSpinning = true
          this.largeImageSpinning = true
          getPdfImgApi(params)
            .then(res => {
              if (res.success) {
                const newImgList = res.result.records.map(item => {
                  return {
                    ...item,
                    loading: true
                  }
                })
                this.imgListConfig.records.unshift(...newImgList)
                this.$nextTick(() => {
                  this.lazyLoadImgByIntersectionObserver(newImgList, 'thumbnail-image', 4)
                  this.lazyLoadImgByIntersectionObserver(newImgList, 'large-image', 1)
                })
                event.target.scrollTo({ top: 1 }) // è§£å†³è§¦é¡¶åˆ·æ–°æ•°æ®åŽæ»šåŠ¨æ¡è‡³æœ€é¡¶éƒ¨é—®é¢˜ï¼Œä½†ä¸çŸ¥é“ä¸ºä»€ä¹ˆ
                this.thumbnailSpinning = false
                this.largeImageSpinning = false
              }
            })
        }
      },
      /* å¼€å¯æ·±å±‚过滤模式 */
      furtherFilter() {
        if (this.thumbnailSpinning) return
        if (!this.inputQuestion) {
          this.$message.error('你没有输入内容哦')
          return
        }
        this.furtherFilterImgList = []
        this.isFurtherFilter = true
        // å¼€å¯æ·±å±‚过滤时取消文件选中并移除滚动刷新事件
        this.beforeFilterActiveDocumentId = this.activeDocumentId
        this.activeDocumentId = null
        this.imgListConfig = {}
        this.handleScrollEventSwitch(false)
        const params = Object.assign({}, this.furtherFilterImgListParams)
        params.pdfFileId = '1823231131943862273'
        params.pdfContent = this.inputQuestion
        this.largeImageSpinning = true
        getFurtherFilterImgApi(params)
          .then(res => {
            console.log('res', res)
            this.answer = res.result
            if (res.success) {
              this.furtherFilterImgList = res.result.map(item => {
                return {
                  ...item,
                  loading: true
                }
              })
              this.largeImageSpinning = false
              this.$nextTick(() => this.lazyLoadImgByIntersectionObserver(res.result, 'filter-image', 2))
            }
          })
      },
      /* ç‚¹å‡»æ·±å±‚过滤中的图片后定位至文件 */
      locateToDocument(record) {
        this.isFurtherFilter = false
        this.handleScrollEventSwitch(true)
        this.$nextTick(() => this.activeCurrentDocument(record.fileId, record.id, record.pageNumber))
      },
      /* ç‚¹å‡»è¿”回按钮退出深层过滤模式 */
      cancelFurtherFilter() {
        const beforeFilterActiveRecord = { fileId: this.beforeFilterActiveDocumentId, id: this.activeImageId }
        this.locateToDocument(beforeFilterActiveRecord)
        this.inputQuestion = ''
      },
      /**
       * æ»šåŠ¨è‡³å›¾ç‰‡ç›¸åº”ä½ç½®é¡¶éƒ¨
       * @param activePreId æ»šåŠ¨è‡³æŸå…ƒç´ çš„Id前缀
       * @param isParentNodeOpenPosition çˆ¶å…ƒç´ æ˜¯å¦å¼€å¯å®šä½
       * @param parentNodePreClass çˆ¶å…ƒç´ ç±»åå‰ç¼€
       * @param marginValue è¾¹è·å€¼ æ­£å€¼ä¸ºtop,负值为bottom
       */
      scrollToImagePosition(activePreId = 'large-image-container', isParentNodeOpenPosition = true, parentNodePreClass = 'rightBottom', marginValue = 25) {
        console.log(activePreId + '-' + this.activeImageId)
        const activeLargeImageContainer = document.getElementById(activePreId + '-' + this.activeImageId)
        console.log('activeLargeImageContainer', activeLargeImageContainer)
        let scrollTop
        if (isParentNodeOpenPosition) {
          console.log('父元素开启定位')
          scrollTop = activeLargeImageContainer.offsetTop - marginValue
        } else {
          console.log('父元素未开启定位')
          scrollTop = activeLargeImageContainer.offsetTop - this[parentNodePreClass + 'Container'].offsetTop - marginValue
        }
        this[parentNodePreClass + 'Container'].scrollTo({ top: scrollTop })
      },
      /**
       * é‡‡ç”¨æµè§ˆå™¨ç›‘测者API实现图片懒加载
       * @param dataList å…ƒç´ å¯¹åº”数据集合用来映射对应元素
       * @param targetPreClass ç›®æ ‡å…ƒç´ ç±»åå‰ç¼€
       * @param loadCount åŠ è½½æ•°é‡
       * @param furtherFilterImageId æ·±å±‚过滤模式中点击的图片Id
       */
      lazyLoadImgByIntersectionObserver(dataList, targetPreClass, loadCount, furtherFilterImageId = null) {
        let hasLoadImageCount = 0
        let observer = new IntersectionObserver(entries => {
          const furtherFilterImageIndex = entries.findIndex(item => item.target.id === targetPreClass + '-' + furtherFilterImageId)
          // æ·±å±‚过滤选中图片后减少数组循环次数,仅加载包含选中图片的可视图片数量
          if (furtherFilterImageIndex > 0) entries = entries.slice(furtherFilterImageIndex)
          entries = entries.slice(0, loadCount)
          entries.forEach(item => {
            if (item.isIntersecting) {
              item.target.src = item.target.dataset.src
              hasLoadImageCount++
              observer.unobserve(item.target)
            }
          })
          if (hasLoadImageCount === dataList.length) {
            observer.disconnect()
            observer = null
          }
        })
        dataList.forEach(item => observer.observe(document.getElementById(targetPreClass + '-' + item.id)))
      },
      horizontalScroll(event) {
        event.preventDefault()
        const documentContainer = document.querySelector('.document-container')
        // deltaY属性是鼠标滚动滚轮滚动一下,页面滚动的距离
        // Y是垂直方向
        // é¼ æ ‡æ»šè½®å‘前滚动一轮,deltaY=-100;向后滚动一轮,deltaY=100;
        // deltaY的内部实现肯定是监听鼠标滚轮是向前滚动还是向后滚动事件,然后更改deltaY的值为正或者负。
        // æ°´å¹³æ»šåŠ¨ä¹Ÿéœ€è¦æœ‰ç±»ä¼¼deltaY这样的状态,所以直接使用deltaY就可以了,虽然Y表示的是垂直方向的滚动。
        const deltaY = event.deltaY
        // scrollLeft属性是元素左侧的滚动距离,通过改变这个属性的值,实现水平滚动的效果。
        documentContainer.scrollLeft += deltaY
      },
      imageLoadDone(record) {
        console.log('图片加载完成')
        record.loading = false
      },
      scrollToTop() {
        console.log('触发回到顶部')
        this.leftContainer.scrollTo({ top: 0 })
        this.rightBottomContainer.scrollTo({ top: 0 })
      },
      /**
       * å›¾ç‰‡é¢„览
       * @param text å›¾ç‰‡åœ°å€
       */
      getImgView(text) {
        if (text && text.indexOf(',') > 0) {
          text = text.substring(0, text.indexOf(','))
        }
        return getFileAccessHttpUrl(text)
      },
      handleScrollEventSwitch(isAddEvent) {
        if (isAddEvent) {
          this.leftContainer.addEventListener('scroll', this.lazyLoadData)
          this.rightBottomContainer.addEventListener('scroll', this.lazyLoadData)
        } else {
          this.leftContainer.removeEventListener('scroll', this.lazyLoadData)
          this.rightBottomContainer.removeEventListener('scroll', this.lazyLoadData)
        }
      },
      resetImgListScrollConfig() {
        this.scrollToMarginConfig.hasScrollToTopCount = 0
        this.scrollToMarginConfig.hasScrollToBottomCount = 0
      }
    },
    beforeDestroy() {
      this.handleScrollEventSwitch(false)
    }
  }
</script>
<style scoped>
<style scoped lang="less">
  @main-container-background: rgba(255, 255, 255, .7);
  @container-border-radius: 12px;
  @container-padding: 10px;
  @single-history-edit-border: 3px solid #ABC0CC;
  @single-history-hover-background: #f1f1f1;
  @single-history-active-background: #e5ebed;
  @input-container-border: 3px solid #B8CAD5;
  @user-question-background: #e5ebed;
  @assistant-answer-background: #F0F5F5;
  @conversation-content-container-box-shadow: 2px 2px 10px 0px #ddd;
  @input-container-box-shadow: 2px 2px 10px 0px #ABC0CC;
  @largeImg-container-box-shadow: 0 -3px 10px 0px #ddd;
  @scrollbar-track-background: #f6f6f6;
  @scrollbar-thumb-background: #e9e9e9;
  @scrollbar-thumb-hover-background: #cfcfcf;
  @scrollbar-corner-background: #f6f6f6;
  .page-container {
    display: flex;
    justify-content: flex-end;
    -webkit-justify-content: flex-end;
    flex-direction: column;
    align-items: center;
    font-size: 18px;
    height: 100%;
    font-family: ali_r_main;
    .outer-container {
      width: 100%;
      height: 850px;
      display: flex;
      justify-content: center;
      align-items: center;
      .left-spin-container {
        width: 10%;
        height: 100%;
        background-color: @main-container-background;
        border-radius: @container-border-radius;
        margin-right: 25px;
        font-size: 16px;
        position: relative;
        .left-container {
          width: 100%;
          height: 100%;
          padding: @container-padding;
          display: flex;
          flex-direction: column;
          justify-content: space-between;
          overflow: auto;
          &::-webkit-scrollbar {
            width: 8px;
            height: 0;
          }
          .single-thumbnail-container {
            padding: @container-padding;
            cursor: pointer;
            border-radius: @container-border-radius;
            &:not(:last-child) {
              margin-bottom: 20px;
            }
            img {
              width: 100%;
              height: 189px;
            }
            & > div:last-child {
              margin-top: 10px;
              text-align: center;
            }
            .thumbnail-image-container {
              position: relative;
            }
            &:hover {
              background-color: @single-history-hover-background;
            }
            &.single-thumbnail-active {
              background-color: @single-history-active-background;
            }
            /deep/ .ant-skeleton {
              margin: 0;
              position: absolute;
              width: 100%;
              height: 189px;
              .ant-skeleton-header {
                padding: 0;
                .ant-skeleton-avatar-lg {
                  width: 100%;
                  height: 189px;
                  border-radius: @container-border-radius;
                }
              }
            }
          }
        }
      }
      .right-container {
        width: 80%;
        height: 100%;
        border-radius: @container-border-radius;
        display: flex;
        flex-direction: column;
        justify-content: space-between;
        .right-top-container {
          height: 120px;
          margin-bottom: 25px;
          display: flex;
          justify-content: center;
          .document-spin-container {
            width: 50%;
            height: 100%;
            font-size: 14px;
            background-color: @main-container-background;
            border-radius: @container-border-radius;
            margin-right: 25px;
            position: relative;
            .document-container {
              overflow-y: hidden;
              overflow-x: auto;
              display: flex;
              align-items: center;
              padding: @container-padding;
              &::-webkit-scrollbar {
                width: 0;
                height: 8px;
              }
              .single-document-container {
                width: 22%;
                height: 100%;
                cursor: pointer;
                display: flex;
                flex-direction: column;
                justify-content: space-evenly;
                align-items: center;
                flex-shrink: 0;
                border-radius: @container-border-radius;
                padding: @container-padding;
                img {
                  width: 40px;
                }
                .single-document-name {
                  width: 100%;
                  text-align: center;
                  /*overflow: hidden;*/
                  /*white-space: nowrap;*/
                  /*text-overflow: ellipsis;*/
                }
                &:not(:last-child) {
                  margin-right: 4%;
                }
                &:hover {
                  background-color: @single-history-hover-background;
                }
                &.single-document-active {
                  background-color: @single-history-active-background;
                }
              }
            }
          }
          .search-container {
            flex: 1;
            display: flex;
            flex-direction: column;
            justify-content: space-evenly;
            align-items: center;
            .input-container {
              width: 100%;
              display: flex;
              background-color: transparent;
              border: @single-history-edit-border;
              border-radius: @container-border-radius;
              padding: @container-padding;
              input {
                flex: 1;
                outline: none;
                background-color: transparent;
                letter-spacing: 1px;
                border: none;
                &::placeholder {
                  color: #9A9C9C;
                }
              }
              img {
                width: 32px;
                cursor: pointer;
              }
            }
          }
        }
        .right-bottom-spin-container {
          width: 100%;
          flex: 1;
          background-color: @main-container-background;
          border-radius: @container-border-radius;
          position: relative;
          overflow: hidden;
          .right-bottom-container {
            height: 100%;
            background-color: @main-container-background;
            border-radius: @container-border-radius;
            overflow: auto;
            text-align: center;
            display: flex;
            flex-direction: column;
            align-items: center;
            .single-largeImg-container {
              box-shadow: @largeImg-container-box-shadow;
              width: 70%;
              margin-top: 25px;
              position: relative;
            }
            &.further-filter-container {
              flex-direction: row;
              justify-content: left;
              flex-wrap: wrap;
              padding: 12px 0;
              .single-filterImg-container {
                box-shadow: @largeImg-container-box-shadow;
                width: 45%;
                margin-top: 12px;
                margin-bottom: 12px;
                cursor: pointer;
                position: relative;
                &:nth-child(odd) {
                  margin-left: 2%;
                  margin-right: 2%;
                }
              }
            }
            /deep/ .ant-skeleton {
              position: absolute;
              top: 0;
              left: 0;
              right: 0;
              bottom: 0;
              margin: 0;
              .ant-skeleton-header {
                padding: 0;
                .ant-skeleton-avatar-lg {
                  width: 100%;
                  height: 1640px;
                }
              }
            }
            img {
              width: 100%;
              height: 1640px;
            }
            #back-to-largeImg {
              width: 3%;
              height: auto;
              position: absolute;
              right: 2%;
              top: auto;
              cursor: pointer;
            }
            &::-webkit-scrollbar {
              width: 8px;
              height: 0;
            }
          }
        }
      }
      .left-container, .document-container, .right-bottom-container {
        &:hover {
          &::-webkit-scrollbar-track {
            background: @scrollbar-track-background;
          }
          &::-webkit-scrollbar-thumb {
            background: @scrollbar-thumb-background;
          }
          &::-webkit-scrollbar-thumb:hover {
            background: @scrollbar-thumb-hover-background;
          }
          &::-webkit-scrollbar-corner {
            background: @scrollbar-corner-background;
          }
        }
      }
      /deep/ .ant-spin {
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        display: flex;
        justify-content: center;
        align-items: center;
        pointer-events: none;
        z-index: 999;
        .anticon {
          font-size: 24px;
        }
      }
    }
  }
</style>
src/views/ai/LanguageModel.vue
@@ -1,11 +1,15 @@
<template>
  <div class="page-container">
    <div class="outer-container">
      <!--左侧历史会话区域-->
      <div class="left-container">
        <!--logo区域-->
        <div class="logo-container"><img src="@/assets/page/languageModel/logo.png"></div>
        <!--功能按键区域-->
        <div class="manage-history-container">
          <div @click="createNewConversation"
               :class="[isAtNewConversation?'create-history-container-active':'',isDeletingBatch?'fold-create-history-container':'expand-create-history-container']"
               :class="[isAtNewConversation?'create-history-container-active':'',isDeletingBatch?'fold-create-history-container':'expand-create-history-container',isModelResponding?'disable-expand':'']"
               class="create-new-conversation">
            <a-icon type="plus"></a-icon>
            <span v-if="!isDeletingBatch">新增会话</span>
@@ -38,7 +42,7 @@
                    </div>
                  </template>
                  <div @click="deleteBatchConversation" id="delete-batch-button"
                       :class="[checkedConversationIdList.length?'able-delete-button':'disable-delete-button']">
                       :class="[checkedConversationIdList.length&&!isModelResponding?'able-delete-button':'disable-delete-button']">
                    <a-icon type="delete"/>
                    <div>删除</div>
                  </div>
@@ -52,9 +56,18 @@
            </template>
          </div>
        </div>
        <!--历史会话列表区域-->
        <div class="chat-history-container">
          <template v-if="!chatHistoryList.length">
            <a-empty>
              <span slot="description">无历史会话</span>
            </a-empty
            >
          </template>
          <div v-for="(item,index) in chatHistoryList" :key="item.id"
               :class="[item.id===activeHistoryIndex?'single-history-active':'',item.inputVisible?'input-visible-class':'']"
               :class="[item.id===activeHistoryIndex?'single-history-active':'',item.inputVisible?'input-visible-class':'',isModelResponding?'disable-switch':'']"
               @click="switchToCurrentConversation(item,$event)"
               @mouseenter="item.iconVisible=true"
               @mouseleave="item.iconVisible=false">
@@ -65,7 +78,6 @@
              <span class="check-mark"></span>
            </label>
            <a-popover placement="top" :visible="item.deletePopVisible" trigger="click"
                       :getPopupContainer="node=>node.parentNode">
              <template slot="content">
@@ -73,13 +85,14 @@
                  <div>删除后无法恢复,是否继续删除?</div>
                  <div>
                    <button class="cancel-delete-button" @click="cancelDeleteConversation(item,$event)">取消</button>
                    <button @click="confirmDeleteConversation(item,index)" id="delete-conversation-button">删除</button>
                    <button @click.stop="confirmDeleteConversation(item,index)" id="delete-conversation-button">删除
                    </button>
                  </div>
                </div>
              </template>
              <template v-if="!item.inputVisible">
                <div class="conversation-title">{{item.title}}</div>
                <div class="icon-container hover-icon-container" v-show="!isDeletingBatch">
                <div class="conversation-title">{{item.problem.slice(0,15)}}</div>
                <div class="icon-container hover-icon-container" v-show="!isDeletingBatch&&!isModelResponding">
                  <a-icon type="edit" @click.stop="editConversationTitle(item)"/>
                  <a-icon type="delete" @click.stop="deleteConversation(item)"
                          @blur="cancelDeleteConversation(item,$event)"/>
@@ -102,28 +115,33 @@
        </div>
      </div>
      <!--右侧会话内容区域-->
      <div class="right-container">
        <!--会话内容列表区域-->
        <div class="conversation-container">
          <div v-for="item in currentConversation.messages" class="single-conversation" :id="'id'+item.index">
            <div v-if="item.role==='user'" class="user-question">
              <div class="avatar">
                <a-avatar :src="getAvatar()"/>
              </div>
              <div class="content">{{item.content}}</div>
              <div class="conversation-content">{{item.content}}</div>
            </div>
            <div v-else class="assistant-answer">
              <div class="avatar">
                <img src="@/assets/page/languageModel/ai-avatar.png"/>
              </div>
              <div class="content">{{item.content}}</div>
              <div class="conversation-content" v-html="item.content.replace(/\n/g,'<br>')"></div>
            </div>
          </div>
        </div>
        <!--提问输入区域-->
        <div class="input-container" :class="[textareaFocused?'input-container-active':'']">
          <textarea v-model="inputQuestion" :placeholder="textareaPlaceholder"
                    @keydown.enter="sendQuestion($event)" @focus="textareaFocused=true"
                    @blur="textareaFocused=false"></textarea>
          <img src="@/assets/page/languageModel/send-message.png" @click="sendQuestion($event)" v-if="!isResponding">
          <img src="@/assets/page/languageModel/send-message.png" @click="sendQuestion($event)"
               v-if="!isModelResponding">
          <a-icon type="loading" class="loading-icon" v-else/>
        </div>
      </div>
@@ -136,6 +154,16 @@
  import { mapGetters } from 'vuex'
  import { message } from 'ant-design-vue'
  import { randomUUID } from '@/utils/util'
  import {
    addNewConversationApi,
    getChatHistoryListApi,
    getCurrentConversationApi,
    deleteSingleChatHistoryApi,
    askToLanguageModelApi
  } from '@/api/ai'
  import { EventSourcePolyfill } from 'event-source-polyfill'
  import Vue from 'vue'
  import { ACCESS_TOKEN, TENANT_ID } from '@/store/mutation-types'
  message.config({
    maxCount: 1,
@@ -151,62 +179,22 @@
        checkedConversationIdList: [],
        deleteBatchPopVisible: false,
        currentConversation: {
          id: '683a65fd-8feb-4446-ad32-714c4785f667',
          messages: [
            {
              'role': 'user',
              'content': '你是谁?你是谁你是谁你是谁你是谁你是谁你是谁你是谁你是谁你是谁你是谁你是谁你是谁你是谁你是谁你是谁你是谁你是谁'
            },
            {
              'role': 'assistant',
              'content': '我是26中生涯指导师小爱'
            },
            {
              'role': 'user',
              'content': '你都能做些什么?'
            },
            {
              'role': 'assistant',
              'content': '我能站在未来视角帮助同学们做好生涯规划指导。'
            },
            {
              'role': 'user',
              'content': '今天天气如何?'
            },
            {
              'role': 'assistant',
              'content': '我是26中生涯指导师小爱'
            },
            {
              'role': 'user',
              'content': '你都能做些什么?'
            },
            {
              'role': 'assistant',
              'content': '我能站在未来视角帮助同学们做好生涯规划指导。'
            }
          ],
          stream: false,
          max_tokens: 500,
          iconVisible: false,
          inputVisible: false,
          deletePopVisible: false
          id: '',
          messages: [],
          stream: true,
          max_tokens: 500
        },
        // currentConversation: {},
        isAtNewConversation: false,
        isDeletingBatch: false,
        activeHistoryIndex: null,
        editingHistoryIndex: null,
        deletingHistoryIndex: null,
        iconVisible: false,
        inputVisible: false,
        editedConversationTitle: '',
        deletePopVisible: false,
        conversationContainer: null,
        inputQuestion: '',
        textareaPlaceholder: 'Enter发送,Shift+Enter换行',
        textareaPlaceholder: '',
        textareaFocused: false,
        isResponding: false
        isModelResponding: false
      }
    },
@@ -219,40 +207,265 @@
            document.removeEventListener('click', this.handleDocumentClick)
          }
        }
      },
      isAtNewConversation: {
        handler(val) {
          if (val) {
            this.textareaPlaceholder = '请尝试问我:你是谁?'
          } else {
            this.textareaPlaceholder = 'Enter发送,Shift+Enter换行'
          }
        },
        immediate: true
      }
    },
    created() {
      this.getChatHistoryListByApi()
    },
    mounted() {
      this.getConversationByApi()
      this.conversationContainer = document.querySelector('.conversation-container')
    },
    methods: {
      ...mapGetters(['avatar']),
      /* è°ƒç”¨æŽ¥å£èŽ·å–å½“å‰ä¼šè¯è®°å½• */
      getConversationByApi() {
        this.currentConversation.title = this.currentConversation.messages[0].content.slice(0, 15)
        this.chatHistoryList.push(this.currentConversation)
        this.activeHistoryIndex = this.chatHistoryList[0].id
        if (!this.chatHistoryList.length) this.textareaPlaceholder = '请尝试问我:你是谁?'
      getChatHistoryListByApi() {
        //  TODO è°ƒç”¨æŽ¥å£èŽ·å–åŽ†å²ä¼šè¯åˆ—è¡¨,如果历史会话不为空则跳转至第一条历史会话内容中
        getChatHistoryListApi()
          .then(res => {
            console.log('res', res)
            if (res.success && res.result && res.result.length > 0) {
              this.chatHistoryList = res.result.map(item => {
                return {
                  ...item,
                  iconVisible: false,
                  inputVisible: false,
                  deletePopVisible: false
                }
              })
              if (!this.activeHistoryIndex) this.switchToCurrentConversation(this.chatHistoryList[0])
              if (this.isDeletingBatch) this.singleHistoryCheckedChange()
            } else {
              this.chatHistoryList = []
              this.createNewConversation()
            }
          })
          .catch(err => {
            this.chatHistoryList = []
            this.createNewConversation()
            console.log('err', err)
          })
      },
      /* åˆ›å»ºä¸€ä¸ªæ–°ä¼šè¯ */
      /* ç‚¹å‡»æ–°å¢žä¼šè¯åŽè§¦å‘ */
      createNewConversation() {
        if (this.isAtNewConversation) this.$message.info('当前已是最新对话')
        if (this.isModelResponding) return // æ¨¡åž‹å›žç­”期间禁止新增会话
        this.isAtNewConversation = true
        this.currentConversation = {
          id: randomUUID(),
          title: '',
          id: '',
          messages: [],
          stream: true,
          max_tokens: 500,
          iconVisible: false,
          inputVisible: false,
          deletePopVisible: false
          max_tokens: 500
        }
        // æ–°å»ºä¼šè¯æ—¶å–消原先被选中的历史对话,更清晰告诉用户现在界面处于新建对话中
        if (this.activeHistoryIndex !== null) this.activeHistoryIndex = null
        this.textareaPlaceholder = '请尝试问我:你是谁?'
      },
      /* è°ƒç”¨æŽ¥å£èŽ·å–å½“å‰ä¼šè¯è®°å½• */
      getConversationByApi(id) {
        // TODO æ ¹æ®ç‚¹å‡»çš„历史会话ID获取对应历史会话的对话记录列表
        getCurrentConversationApi({ id })
          .then(res => {
            console.log('currentRes', res)
            if (res.success && res.result) {
              this.currentConversation.messages = res.result.map(item => {
                return {
                  id: item.id,
                  role: item.aiType === 2 ? 'user' : 'assistant',
                  content: item.aiType === 2 ? item.problem : item.answer
                }
              })
              this.scrollToConversationContainerBottom()
              this.currentConversation.id = res.result[0].parentId
              if (this.isModelResponding) this.isModelResponding = false
              if (res.result[res.result.length - 1].aiType === 2) {
                console.log('触发向模型提问', res.result)
                this.askToLanguageModel()
              }
            }
          })
      },
      addNewConversationByApi(params) {
        this.inputQuestion = ''
        // TODO è°ƒç”¨åŽç«¯æŽ¥å£ä¿å­˜å½“前问题且创建一条历史会话记录,然后再重新调用获取历史记录列表接口刷新列表
        addNewConversationApi(params)
          .then(res => {
            if (res.success) {
              switch (+params.aiType) {
                case 1:
                  this.getChatHistoryListByApi()
                  this.$message.success('会话列表记录' + res.message)
                  break
                case 2:
                  this.getConversationByApi(this.activeHistoryIndex)
                  this.$message.success('新增会话内容问题记录' + res.message)
                  break
                case 3:
                  this.getConversationByApi(this.activeHistoryIndex)
                  // this.currentConversation.messages[this.currentConversation.messages.length - 1].content += '\n' + '对话结束'
                  this.$message.success('新增会话内容答案记录' + res.message)
                  break
              }
            } else {
              this.$message.error(res.message)
            }
          })
      },
      /* å‘模型提问 */
      sendQuestion(event) {
        //监测是否按下shift键
        if (!event.shiftKey) {
          event.preventDefault()
          if (this.isModelResponding) {
            this.$message.error('请等待机器人回复后再发送哦~')
            return
          }
          if (!this.inputQuestion) {
            this.$message.error('你没有输入内容哦')
            return
          }
          const params = {
            problem: this.inputQuestion,
            aiType: ''
          }
          if (this.isAtNewConversation) {
            params.aiType = 1
            params.parentId = ''
          } else {
            params.aiType = 2
            params.parentId = this.activeHistoryIndex
          }
          this.addNewConversationByApi(params)
        }
      },
      askToLanguageModel() {
        const messages = JSON.parse(JSON.stringify(this.currentConversation))
        const answer = {
          id: '',
          role: 'assistant',
          content: ''
        }
        this.currentConversation.messages.push(answer)
        let lastElement
        this.$nextTick(() => {
          const elementArr = document.querySelectorAll('.single-conversation')
          lastElement = elementArr[elementArr.length - 1]
          console.log('elementArr', elementArr)
        })
        console.log('beforeAnswerConversation', messages)
        console.log('this.currentConversation', this.currentConversation)
        this.isModelResponding = true
        // å‘送POST请求到模型,获取响应流
        askToLanguageModelApi(messages)
          .then(async (response) => {
            if (!response.body) return
            const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
            // const decoder = new TextDecoder()
            // console.log(reader)
            let discontinuousJsonArray = []
            let isContinuous = null
            while (true) {
              const { value, done } = await reader.read()
              if (done) {
                const params = {
                  parentId: this.activeHistoryIndex,
                  answer: this.currentConversation.messages.find(item => !item.id && item.role === 'assistant').content,
                  aiType: 3
                }
                this.addNewConversationByApi(params)
                break
              }
              this.scrollToConversationContainerBottom()
              const objectArray = parsePack(value)
              // console.log('objectArray', objectArray)
              if (Array.isArray(objectArray) && objectArray.length > 0) {
                if (discontinuousJsonArray.length === 2) discontinuousJsonArray = []
                objectArray.forEach(json => {
                  if (!json.choices || json.choices.length === 0) {
                    return
                  }
                  const text = json.choices[0].delta.content
                  this.currentConversation.messages.find(item => !item.id && item.role === 'assistant').content += text
                })
                if (isContinuous) {
                  discontinuousJsonArray = []
                  isContinuous = null
                }
              }
            }
            // é€’归找到DOM下最后一个元素节点
            function parsePack(str) {
              const pattern = /data:\s*(?!\[DONE\])(\{.*?\})\s*\n/g
              const result = []
              let match
              while ((match = pattern.exec(str)) !== null) {
                const jsonStr = match[1]
                console.log('jsonStr', jsonStr)
                try {
                  const object = JSON.parse(jsonStr)
                  if (object) result.push(object)
                } catch (err) {
                  console.log('err', err)
                }
              }
              // æ­¤åˆ¤æ–­å¤„理返回的不完整的数组
              if (match = pattern.exec(str) === null) {
                isContinuous = false
                // console.log('str', str)
                // æ­¤å¤„为屏蔽返回带有ping字符串
                if (!str.includes('ping')) {
                  discontinuousJsonArray.push(str)
                  // åˆ¤æ–­æ¡ä»¶ä¸º2是由于不完整数组仅经过2次返回值就可以拼接完整,但还是不应该用数字作为判断条件,以防不止2次
                  if (discontinuousJsonArray.length === 2) {
                    // console.log('discontinuousJsonArray', discontinuousJsonArray[0], '---', discontinuousJsonArray[1])
                    const discontinuousMatch = pattern.exec(discontinuousJsonArray[0] + discontinuousJsonArray[1])
                    discontinuousJsonArray = [JSON.parse(discontinuousMatch[1])]
                    isContinuous = true
                    return discontinuousJsonArray
                  }
                }
              }
              return result
            }
          })
          .catch(error => {
            console.error('请求失败:', error)
          })
      },
      /* åˆ‡æ¢è‡³å½“前点击会话 */
      switchToCurrentConversation(record, event = {}) {
        if (record.id === this.activeHistoryIndex) return // é¿å…é‡å¤ç‚¹å‡»
        if (event.target && event.target.type === 'checkbox') return //点击多选按钮避免传递给此事件
        if (record.inputVisible) return // å½“条会话正在被修改时再次点击本条会话无反馈
        // å…³é—­ç‚¹å‡»ç¼–辑按钮后的输入框
        if (this.editingHistoryIndex !== null && this.editingHistoryIndex !== record.id) this.cancelEditConversationTitle(this.chatHistoryList.find(item => item.id === this.editingHistoryIndex))
        // å…³é—­æ‰€æœ‰ç¡®è®¤åˆ é™¤å¼¹çª—
        if (this.deletingHistoryIndex !== null) this.cancelDeleteConversation(this.chatHistoryList.find(item => item.id === this.deletingHistoryIndex))
        if (this.isAtNewConversation) this.isAtNewConversation = false // å¦‚果在创建新对话界面则将新对话界面关闭
        if (this.isModelResponding) return // æ¨¡åž‹å›žç­”期间禁止切换会话
        this.activeHistoryIndex = record.id
        this.getConversationByApi(record.id)
      },
      /* ç‚¹å‡»å…¨é€‰æŒ‰é’®åŽæ”¹å˜å‹¾é€‰åŽè§¦å‘ */
@@ -263,7 +476,7 @@
        } else {
          this.checkedConversationIdList = []
        }
        console.log('触发全选', event.target.checked)
        console.log('触发全选', this.checkedConversationIdList)
      },
      /* ç‚¹å‡»åŽ†å²è®°å½•ä¸­å¤šé€‰æ¡†æ”¹å˜å‹¾é€‰åŽè§¦å‘ */
@@ -298,6 +511,7 @@
      /* ç‚¹å‡»çº¢è‰²å›¾æ ‡æ‰¹é‡åˆ é™¤æŒ‰é’®åŽè§¦å‘ */
      deleteBatchConversation() {
        if (!this.checkedConversationIdList.length) return
        if (this.isModelResponding) return // æ¨¡åž‹å›žç­”期间禁止删除会话
        this.deleteBatchPopVisible = !this.deleteBatchPopVisible
      },
@@ -326,23 +540,6 @@
        this.checkedConversationIdList = []
      },
      /* åˆ‡æ¢è‡³å½“前点击会话 */
      switchToCurrentConversation(record, event = {}) {
        if (event.target && event.target.type === 'checkbox') return //点击多选按钮避免传递给此事件
        if (record.inputVisible) return // å½“条会话正在被修改时再次点击本条会话无反馈
        // å…³é—­ç‚¹å‡»ç¼–辑按钮后的输入框
        if (this.editingHistoryIndex !== null && this.editingHistoryIndex !== record.id) this.cancelEditConversationTitle(this.chatHistoryList.find(item => item.id === this.editingHistoryIndex))
        // å…³é—­æ‰€æœ‰ç¡®è®¤åˆ é™¤å¼¹çª—
        if (this.deletingHistoryIndex !== null) this.cancelDeleteConversation(this.chatHistoryList.find(item => item.id === this.deletingHistoryIndex))
        this.activeHistoryIndex = record.id
        this.currentConversation = this.chatHistoryList.find(item => item.id === record.id)
        this.isAtNewConversation = false
        // åˆ‡æ¢å¯¹è¯æ—¶æ›´æ”¹æé—®è¾“入框提示文字
        if (record.messages.length > 0) this.textareaPlaceholder = 'Enter发送,Shift+Enter换行'
      },
      /* ç‚¹å‡»ç¼–辑会话标题按钮时触发 */
      editConversationTitle(record) {
        // ä»…开启最后一次点击编辑按钮后的输入框
@@ -352,7 +549,7 @@
        this.editingHistoryIndex = record.id
        record.inputVisible = true
        this.editedConversationTitle = record.title
        this.editedConversationTitle = record.problem
        this.$nextTick(() => document.getElementById('edit-input').focus())
      },
@@ -365,42 +562,49 @@
        }
        // ä»…可使用最后一次点击删除按钮的功能
        if (this.deletingHistoryIndex !== null && this.deletingHistoryIndex !== record.id) this.cancelDeleteConversation(this.chatHistoryList.find(item => item.id === this.deletingHistoryIndex))
        if (this.isModelResponding) return // æ¨¡åž‹å›žç­”期间禁止删除会话
        record.deletePopVisible = !record.deletePopVisible
        this.deletingHistoryIndex = record.id
      },
      /* ç¡®è®¤ç¼–辑会话标题 */
      confirmEditConversationTitle(record, event) {
        // TODO è°ƒç”¨ç¼–辑会话接口并重新获取历史会话数据
        record.title = this.editedConversationTitle
        this.cancelEditConversationTitle(record, event)
      },
      /* ç¡®è®¤åˆ é™¤ä¼šè¯æ—¶è§¦å‘ */
      confirmDeleteConversation(record, index) {
        this.chatHistoryList = this.chatHistoryList.filter(item => item.id !== this.deletingHistoryIndex)
        if (this.chatHistoryList.length > 0) {
          // åˆ¤æ–­å½“前会话是不是要删除的会话
          if (this.activeHistoryIndex === record.id) {
            if (this.chatHistoryList[index]) {
              console.log('删除非最后一条')
              this.currentConversation = this.chatHistoryList[index]
              this.activeHistoryIndex = this.chatHistoryList[index].id
            } else {
              console.log('删除最后一条')
              this.currentConversation = this.chatHistoryList[this.chatHistoryList.length - 1]
              this.activeHistoryIndex = this.chatHistoryList[this.chatHistoryList.length - 1].id
        deleteSingleChatHistoryApi({ id: record.id })
          .then(res => {
            if (res.success) {
              if (this.chatHistoryList.length !== 1) {
                // åˆ¤æ–­å½“前会话是不是要删除的会话
                console.log('record', record)
                console.log('activeHistoryIndex', this.activeHistoryIndex)
                if (this.activeHistoryIndex === record.id) {
                  if (index !== 0) {
                    console.log('删除非第一条记录')
                    this.switchToCurrentConversation(this.chatHistoryList[index - 1])
                  } else {
                    console.log('删除第一条记录')
                    this.switchToCurrentConversation(this.chatHistoryList[index + 1])
                  }
                }
              } else {
                console.log('删除最后一条记录')
                this.activeHistoryIndex = null
              }
              record.deletePopVisible = false
              this.deletingHistoryIndex = null
              this.getChatHistoryListByApi()
              this.$message.success(res.message)
            }
          }
        } else {
          console.log('删除前只有一条')
          this.activeHistoryIndex = null
          this.createNewConversation()
        }
        record.deletePopVisible = false
        this.deletingHistoryIndex = null
        this.$message.success('删除成功!')
          })
          .catch(err => {
            this.$message.error(err.message)
          })
      },
      /* å–消编辑会话标题时触发 */
@@ -414,7 +618,7 @@
      /* å–消删除会话时触发 */
      cancelDeleteConversation(record, event) {
        // å¤±åŽ»ç„¦ç‚¹äº‹ä»¶æ—¶è‹¥ç‚¹å‡»çš„å…ƒç´ æ˜¯ç¡®è®¤åˆ é™¤æŒ‰é’®åˆ™ä¸è¿›è¡Œå¤±åŽ»ç„¦ç‚¹äº‹ä»¶ï¼Œç›´æŽ¥è¿›å…¥ç¡®è®¤åˆ é™¤äº‹ä»¶
        if (event.relatedTarget && event.relatedTarget.id === 'delete-conversation-button') return
        if (event && event.relatedTarget && event.relatedTarget.id === 'delete-conversation-button') return
        record.deletePopVisible = false
        this.deletingHistoryIndex = null
      },
@@ -424,55 +628,20 @@
        return getFileAccessHttpUrl(this.avatar())
      },
      /* å‘模型提问 */
      sendQuestion(e) {
        //监测是否按下shift键
        if (!e.shiftKey) {
          e.preventDefault()
          if (this.isResponding) {
            this.$message.error('请等待机器人回复后再发送哦~')
            return
          }
          if (!this.inputQuestion) {
            this.$message.error('你没有输入内容哦')
            return
          }
          const newQuestion = {
            role: 'user',
            content: this.inputQuestion
          }
          this.currentConversation.messages.push(newQuestion)
          // å½“新建对话时需先提问再讲对话加入到历史记录中
          if (this.currentConversation.messages.length === 1) {
            this.currentConversation.title = newQuestion.content.slice(0, 15)
            this.chatHistoryList.unshift(this.currentConversation)
            if (this.isDeletingBatch) this.singleHistoryCheckedChange()
            this.switchToCurrentConversation(this.currentConversation)
          }
          this.isResponding = true
          const response = {
            role: 'assistant',
            content: '这个问题我也不太清楚'
          }
          setTimeout(() => {
            this.currentConversation.messages.push(response)
            this.inputQuestion = ''
            this.isResponding = false
            this.$nextTick(() => {
              this.conversationContainer.scrollTo({ top: 9999999999999999999999999999, behavior: 'smooth' })
            })
          }, 1000)
        }
      scrollToConversationContainerBottom(scrollBehavior = 'auto') {
        this.$nextTick(() => {
          this.conversationContainer.scrollTo({
            top: this.conversationContainer.scrollHeight,
            behavior: scrollBehavior
          })
        })
      }
    }
  }
</script>
<style scoped lang="less">
  @main-container-background: rgba(255, 255, 255, .8);
  @main-container-background: rgba(255, 255, 255, .7);
  @container-border-radius: 12px;
  @container-padding: 10px;
  @single-history-edit-border: 3px solid #ABC0CC;
@@ -564,6 +733,13 @@
                margin-right: 10px;
              }
            }
            &.disable-expand {
              cursor: not-allowed;
              &:hover {
                box-shadow: none;
              }
            }
          }
          .delete-batch-container {
@@ -633,7 +809,14 @@
          flex: 1;
          overflow: auto;
          & > div {
          .ant-empty {
            height: 100%;
            display: flex;
            flex-direction: column;
            justify-content: center;
          }
          & > div:not(.ant-empty) {
            border: 3px solid transparent;
            border-radius: 10px;
            padding: 10px 20px;
@@ -714,6 +897,14 @@
                }
              }
            }
            &.disable-switch {
              cursor: not-allowed;
              &:not(.single-history-active):hover {
                background-color: transparent;
              }
            }
          }
        }
@@ -866,16 +1057,20 @@
              &.user-question {
                align-items: flex-end;
                .content {
                .conversation-content {
                  background-color: @user-question-background;
                  text-align: justify;
                  text-align-last: left;
                }
              }
              &.assistant-answer {
                align-items: flex-start;
                .content {
                .conversation-content {
                  background-color: @assistant-answer-background;
                  text-align: justify;
                  text-align-last: left;
                }
              }
@@ -889,7 +1084,7 @@
                }
              }
              .content {
              .conversation-content {
                max-width: 80%;
                box-shadow: @conversation-content-container-box-shadow;
                border-radius: @container-border-radius;
vue.config.js
@@ -1,5 +1,5 @@
const path = require('path')
const CompressionPlugin = require("compression-webpack-plugin")
const CompressionPlugin = require('compression-webpack-plugin')
function resolve(dir) {
  return path.join(__dirname, dir)
@@ -42,11 +42,11 @@
    //生产环境,开启js\css压缩
    if (process.env.NODE_ENV === 'production') {
        config.plugin('compressionPlugin').use(new CompressionPlugin({
          test: /\.(js|css|less)$/, // åŒ¹é…æ–‡ä»¶å
          threshold: 10240, // å¯¹è¶…过10k的数据压缩
          deleteOriginalAssets: false // ä¸åˆ é™¤æºæ–‡ä»¶
        }))
      config.plugin('compressionPlugin').use(new CompressionPlugin({
        test: /\.(js|css|less)$/, // åŒ¹é…æ–‡ä»¶å
        threshold: 10240, // å¯¹è¶…过10k的数据压缩
        deleteOriginalAssets: false // ä¸åˆ é™¤æºæ–‡ä»¶
      }))
    }
    // é…ç½® webpack è¯†åˆ« markdown ä¸ºæ™®é€šçš„æ–‡ä»¶
@@ -62,9 +62,9 @@
      .rule('vxe')
      .test(/\.js$/)
      .include
        .add(resolve('node_modules/vxe-table'))
        .add(resolve('node_modules/vxe-table-plugin-antd'))
        .end()
      .add(resolve('node_modules/vxe-table'))
      .add(resolve('node_modules/vxe-table-plugin-antd'))
      .end()
      .use()
      .loader('babel-loader')
      .end()
@@ -78,26 +78,26 @@
          /* less å˜é‡è¦†ç›–,用于自定义 ant design ä¸»é¢˜ */
          'primary-color': '#1890FF',
          'link-color': '#1890FF',
          'border-radius-base': '4px',
          'border-radius-base': '4px'
        },
        javascriptEnabled: true,
        javascriptEnabled: true
      },
      postcss:{
        plugins:[
      postcss: {
        plugins: [
          require('postcss-px-to-viewport')({
            unitToConvert: "px",
            unitToConvert: 'px',
            viewportWidth: 1920,
            unitPrecision: 3,
            propList: [
              "*"
              '*'
            ],
            viewportUnit: "vw",
            fontViewportUnit: "vw",
            viewportUnit: 'vw',
            fontViewportUnit: 'vw',
            selectorBlackList: [],
            minPixelValue: 0,
            mediaQuery: false,
            replace: true,
            exclude: /(\/|\\)(node_modules)(\/|\\)/,
            exclude: /(\/|\\)(node_modules)(\/|\\)/
          })
        ]
      }
@@ -115,15 +115,16 @@
    // headers: {
    //     'Access-Control-Allow-Origin': '*',
    // },
    compress: false,
    proxy: {
     /* '/api': {
        target: 'https://mock.ihx.me/mock/5baf3052f7da7e07e04a5116/antd-pro', //mock API接口系统
        ws: false,
        changeOrigin: true,
        pathRewrite: {
          '/jeecg-boot': ''  //默认所有请求都加了jeecg-boot前缀,需要去掉
        }
      },*/
      /* '/api': {
         target: 'https://mock.ihx.me/mock/5baf3052f7da7e07e04a5116/antd-pro', //mock API接口系统
         ws: false,
         changeOrigin: true,
         pathRewrite: {
           '/jeecg-boot': ''  //默认所有请求都加了jeecg-boot前缀,需要去掉
         }
       },*/
      /* æ³¨æ„ï¼šjeecgboot前端做了改造,此处不需要配置跨域和后台接口(只需要改.env相关配置文件即可)
          issues/3462 å¾ˆå¤šäººæ­¤å¤„做了配置,导致刷新前端404问题,请一定注意*/
      '/jeecg-boot': {
@@ -131,6 +132,11 @@
        ws: false,
        changeOrigin: true
      },
      '/chat': {
        target: 'https://836u458t54.vicp.fun',
        ws: false,
        changeOrigin: true
      }
    }
  },