vue2实现类似chatgpt和deepseek的AI对话流打字机效果,实现多模型同时对话

发布于:2025-08-06 ⋅ 阅读:(18) ⋅ 点赞:(0)

实现多模型同时对话

功能特点:

1、抽离对话框成单独组件ChatBox.vue,在新增模型对比窗口时可重复利用

2、通过sse与后台实时数据流,通过定时器实现打字效果

3、适应深度思考内容输出,可点击展开与闭合

4、可配置模型参数,本地存储当前模型参数和对话记录,页面关闭时清除

5、通过是否响应<think>标签来识别是否有深度思考内容

6、通过响应的finishReason字段,识别回答是否已停止(null正常回答,stop回答结束,length超出文本)

安装插件

highlight.js、markdown-it

创建对话窗口组件ChatBox.vue

<template>
  <el-card class="box-card">
    <div slot="header" class="clearfix">
      <div class="header-item-box">
        <vxe-select v-model="modelType">
          <vxe-option
            v-for="(item, i) in modelTypeList"
            :key="i"
            :value="item.id"
            :label="item.modelName"
          ></vxe-option>
        </vxe-select>
        <div>
          <vxe-button
            @click="handleDeleteCurModel"
            type="text"
            icon="iconfont icon-zhiyuanfanhui9"
            v-if="modelIndex !== 1"
          ></vxe-button>
          <vxe-button
            @click="handleParamsConfig"
            type="text"
            icon="vxe-icon-setting"
          ></vxe-button>
        </div>
      </div>
    </div>
    <div ref="logContainer" class="talk-box">
      <div class="talk-content">
        <el-row
          v-for="(item, i) in contentList"
          :key="i"
          class="chat-assistant"
        >
          <transition name="fade">
            <div
              :class="['answer-cont', item.type === 'user' ? 'end' : 'start']"
            >
              <img v-if="item.type == 'assistant'" :src="welcome.icon" />
              <div :class="item.type === 'user' ? 'send-item' : 'answer-item'">
                <div
                  v-if="item.type == 'assistant'"
                  class="hashrate-markdown"
                  v-html="item.message"
                />
                <div v-else>{{ item.message }}</div>
              </div>
            </div>
          </transition>
        </el-row>
      </div>
    </div>
    <ModelParamConfig ref="ModelParamConfigRef"></ModelParamConfig>
  </el-card>
</template>

<script>
import hljs from 'highlight.js'
import 'highlight.js/styles/a11y-dark.css'
import MarkdownIt from 'markdown-it'
import { chatSubmit, chatCancel } from '@/api/evaluationServer/modelTalk.js'
import { mapGetters } from 'vuex'
import { sse } from '@/utils/sse.js'
import ModelParamConfig from '@/views/evaluationServer/modelTalk/components/ModelParamConfig.vue'
window.hiddenThink = function (index) {
  // 隐藏思考内容
  if (
    document.getElementById(`think_content_${index}`).style.display == 'none'
  ) {
    document.getElementById(`think_content_${index}`).style.display = 'block'
    document
      .getElementById(`think_icon_${index}`)
      .classList.replace('vxe-icon-arrow-up', 'vxe-icon-arrow-down')
  } else {
    document.getElementById(`think_content_${index}`).style.display = 'none'
    document
      .getElementById(`think_icon_${index}`)
      .classList.replace('vxe-icon-arrow-down', 'vxe-icon-arrow-up')
  }
}
export default {
  props: {
    modelTypeList: {
      type: Array,
      default: () => []
    },
    modelIndex: {
      type: Number,
      default: 1
    },
    /**
     * 模型窗口
     */
    modelDomIndex: {
      type: Number,
      default: 0
    }
  },
  components: { ModelParamConfig },
  computed: {
    ...mapGetters(['token'])
  },
  data() {
    return {
      modelType: '',
      inputMessage: '',
      contentList: [],
      answerTitle: '',
      thinkTime: null,
      startAnwer: false,
      startTime: null,
      endTime: null,
      typingInterval: null,
      msgHight: null,
      welcome: {
        title: '',
        desc: '',
        icon: require('@/assets/images/kubercloud-logo.png')
      },
      markdownIt: {},
      historyList: [], //记录发送和回答的纯文本 。user提问者,assistant回答者
      lastScrollHeight: 0
    }
  },
  mounted() {
    setTimeout(() => {
      this.markdownIt = MarkdownIt({
        html: true,
        linkify: true,
        highlight: function (str, lang) {
          if (lang && hljs.getLanguage(lang)) {
            try {
              return hljs.highlight(str, { language: lang }).value
            } catch (__) {
              console.log(__)
            }
          }
          return ''
        }
      })
      if (this.modelTypeList && this.modelTypeList.length) {
        this.modelType = this.modelTypeList[0].id
      }
    }, 500)
  },
  methods: {
    sleep(ms) {
      return new Promise(resolve => setTimeout(resolve, ms))
    },
    handleDeleteCurModel() {
      this.$emit('handleDeleteCurModel', this.modelIndex)
    },
    clearHistory() {
      this.contentList = []
      this.historyList = []
    },
    async sendMessage({ message }) {
      this.inputMessage = message
      const name = this.modelTypeList.find(
        item => item.id === this.modelType
      )?.name
      if (!name) return
      let params = {
        historyList: [...this.historyList],
        text: this.inputMessage,
        deployId: this.modelType,
        temperature: 1,
        maxTokens: 1024,
        topP: 1,
        seed: '',
        stopSequence: '',
        modelName: name
      }
      let modelParams = sessionStorage.getItem(
        'modelTalkParams-' + this.modelIndex
      )
      if (modelParams) {
        modelParams = JSON.parse(modelParams)
        params = {
          ...params,
          ...modelParams,
          modelName: name
        }
      }
      const res = await chatSubmit(params)
      const { code, obj } = res.data
      if (code == 1) {
        this.chatId = obj
        const thinkIngTxt = this.$t('modelTalk.tips.thinkIng') //思考中……
        this.contentList.push({ type: 'user', message: this.inputMessage })
        this.historyList.push({ role: 'user', content: this.inputMessage })
        this.contentList.push({
          type: 'assistant',
          message: `<div class="think-time">${thinkIngTxt}</div>`
        })
        this.answerTitle =
          this.answerTitle || this.contentList[0].message.substring(0, 20)
        this.scrollToBottom()
        this.lastScrollHeight = 0
        this.connectSSE(obj)
      }
    },
    // 启动连接
    connectSSE(chatId) {
      this.inputMessage = ''
      let buffer = ''
      let displayBuffer = ''
      this.startTime = null
      this.endTime = null
      this.thinkTime = null
      let len = this.contentList.length
      let index = len % 2 === 0 ? len - 1 : len
      let historylen = this.historyList.length
      let historyIndex = historylen % 2 === 0 ? historylen - 1 : historylen
      this.isTalking = true
      let anwerContent = ''
      this.connectionId = sse.connect(
        {
          url: '/api/stream/chat',
          params: {
            chatId
          }
        },
        {
          onOpen: id => {
            console.log(`连接[${id}]已建立`)
          },
          onMessage: async (data, id) => {
            await this.sleep(10)
            try {
              var { content, finishReason } = data
            } catch (e) {
              console.log('e: ', e)
            }
            if (data && content) {
              let answerCont = content
              buffer += answerCont
              anwerContent += answerCont
              this.$set(this.historyList, historyIndex, {
                role: 'assistant',
                content: anwerContent
              })
              const thinkIngTxt = this.$t('modelTalk.tips.thinkIng') //思考中……
              const deeplyPonderedTxt = this.$t('modelTalk.tips.deeplyPondered') //已深度思考
              // 单独记录时间
              if (
                answerCont.includes('<think>') ||
                answerCont.includes('</think>')
              ) {
                // 执行替换逻辑
                if (answerCont.includes('<think>')) {
                  answerCont = `<div class="think-time">${thinkIngTxt}</div><section id="think_content_${index}">`
                  buffer = buffer.replaceAll('<think>', answerCont)
                  this.startTime = Math.floor(new Date().getTime() / 1000)
                }

                if (answerCont.includes('</think>')) {
                  answerCont = `</section>`
                  this.endTime = Math.floor(new Date().getTime() / 1000)
                  // 获取到结束直接后,直接展示收起按钮
                  this.thinkTime = this.endTime - this.startTime
                  buffer = buffer
                    .replaceAll(
                      `<div class="think-time">${thinkIngTxt}</div>`,
                      `<div class="think-time">${deeplyPonderedTxt}(${this.thinkTime}S)<i id="think_icon_${index}" onclick="hiddenThink(${index})" class="vxe-icon-arrow-down"></i></div>`
                    )
                    .replaceAll('</think>', answerCont)
                    .replaceAll(
                      `<section id="think_content_${index}"></section>`,
                      ''
                    )
                }
                // 避免闪动 直接修改数据,这里不需要打字效果
                displayBuffer = buffer // 同步displayBuffer避免断层
                this.$set(this.contentList, index, {
                  type: 'assistant',
                  message: this.markdownIt.render(buffer)
                })
                this.scrollToBottomIfAtBottom()
              } else {
                // 逐字效果
                if (!this.typingInterval) {
                  this.typingInterval = setInterval(() => {
                    if (displayBuffer.length < buffer.length) {
                      const remaining = buffer.length - displayBuffer.length
                      // 暂定一次性加3个字符
                      const addChars = buffer.substr(
                        displayBuffer.length,
                        Math.min(3, remaining)
                      )
                      displayBuffer += addChars
                      let markedText = this.markdownIt.render(displayBuffer)
                      this.$set(this.contentList, index, {
                        type: 'assistant',
                        message: markedText
                      })
                      this.scrollToBottomIfAtBottom()
                    } else {
                      clearInterval(this.typingInterval)
                      this.typingInterval = null
                    }
                  }, 40)
                }
              }
            } else {
              if (['stop', 'length'].includes(finishReason)) {
                this.scrollToBottomIfAtBottom()
                this.isTalking = false
                this.$emit('handleModelAnswerEnd', {
                  modelIndex: this.modelIndex,
                  contentList: this.contentList,
                  finishReason: finishReason
                })
              }
            }
          },
          onError: (err, id) => {
            console.error(`连接[${id}]错误:`, err)
          },
          onFinalError: (err, id) => {
            console.log(`连接[${id}]已失败`)
          }
        }
      )
    },
    disconnectSSE() {
      sse.close()
    },
    async handleModelStop() {
      const res = await chatCancel({ chatId: this.chatId })
      const { code } = res.data
      if (code == 1) {
        this.handleCleanOptionAndData()
      }
    },
    handleCleanOptionAndData() {
      this.disconnectSSE()
      this.isTalking = false
      setTimeout(() => {
        //清除强制停止的对话记录
        this.historyList = this.historyList.slice(0, -2)
      }, 100)
    },
    scrollToBottom() {
      this.$nextTick(() => {
        const logContainer = document.querySelectorAll(
          `.chat-content-box .el-card__body`
        )[this.modelDomIndex]
        if (logContainer) {
          logContainer.scrollTop = logContainer.scrollHeight
        }
      })
    },
    scrollToBottomIfAtBottom() {
      this.$nextTick(() => {
        const logContainer = document.querySelectorAll(
          `.chat-content-box .el-card__body`
        )[this.modelDomIndex]
        if (!logContainer) return
        const threshold = 100
        const distanceToBottom =
          logContainer.scrollHeight -
          logContainer.scrollTop -
          logContainer.clientHeight
        // 获取上次滚动位置
        const lastScrollHeight = this.lastScrollHeight || 0
        // 计算新增内容高度
        const deltaHeight = logContainer.scrollHeight - lastScrollHeight
        // 如果新增内容高度超过阈值50%,强制滚动
        if (deltaHeight > threshold / 2) {
          logContainer.scrollTop = logContainer.scrollHeight
        }
        // 否则正常滚动逻辑
        else if (distanceToBottom <= threshold) {
          logContainer.scrollTop = logContainer.scrollHeight
        }
        // 更新上次滚动位置记录
        this.lastScrollHeight = logContainer.scrollHeight
      })
      /* logContainer.scrollTo({
        top: logContainer.scrollHeight,
        behavior: 'smooth'
      }) */
    },
    handleParamsConfig() {
      const modelRow = this.modelTypeList.find(
        item => item.id === this.modelType
      )
      this.$refs.ModelParamConfigRef.handleShow({
        ...modelRow,
        modelIndex: this.modelIndex
      })
    }
  }
}
</script>

<style lang="scss" scoped>
.box-card {
  flex: 1;
  margin-bottom: 5px;
  height: 100%;
  display: flex;
  flex-direction: column;
  .header-item-box {
    display: flex;
    justify-content: space-between;
    align-items: center;
  }
  .chat-text-box {
    overflow: hidden;
    overflow-y: auto;
  }
  ::v-deep .el-card__body {
    padding: 20px;
    flex: 1;
    overflow-y: auto;
    .talk-box {
      .talk-content {
        background-color: #fff;
        color: #324659;
        overflow-y: auto;
        box-sizing: border-box;
        padding: 0px 20px;
        .chat-assistant {
          display: flex;
          margin-bottom: 10px;
          .send-item {
            max-width: 60%;
            word-break: break-all;
            padding: 10px;
            background: #eef6ff;
            border-radius: 10px;
            color: #000000;
            white-space: pre-wrap;
            font-size: 13px;
          }
          .answer-item {
            line-height: 30px;
            color: #324659;
          }
        }
        .answer-cont {
          position: relative;
          display: flex;
          width: 100%;
          > img {
            width: 32px;
            height: 32px;
            margin-right: 10px;
          }
          &.end {
            justify-content: flex-end;
          }
          &.start {
            justify-content: flex-start;
          }
        }
      }
      .chat-sse {
        min-height: 100px;
        max-height: 460px;
      }
      .chat-message {
        height: calc(100vh - 276px);
      }
      .thinking-bubble {
        height: calc(100vh - 296px);
      }
    }
    .chat-add {
      width: 111px;
      height: 33px;
      background: #dbeafe;
      border-radius: 6px !important;
      font-size: 14px !important;
      border: 0px;
      color: #516ffe !important;
      &:hover {
        background: #ebf0f7;
      }
      .icon-tianjia1 {
        margin-right: 10px;
        font-size: 14px;
      }
    }
    .talk-btn-cont {
      text-align: right;
      height: 30px;
      margin-top: 5px;
    }
  }
}
</style>

创建主页面index.vue

<template>
  <div class="x-container-wrapper chat-page-box">
    <div class="chat-content-box">
      <template v-for="(item, i) in chatBoxs">
        <ChatBox
          :key="item.id"
          v-if="item.show"
          :ref="el => setChatBoxRef(el, item.id)"
          :modelTypeList="modelTypeList"
          :modelIndex="item.id"
          :modelDomIndex="getModelDomIndex(i)"
          @handleDeleteCurModel="handleDeleteCurModel"
          @handleModelAnswerEnd="handleModelAnswerEnd"
        ></ChatBox>
      </template>
    </div>
    <div class="middle-option-box">
      <vxe-button
        type="text"
        icon="iconfont icon-qingchu"
        :disabled="hasAnsweringStatus"
        @click="handelAllHistoryAnswer"
      ></vxe-button>
      <vxe-button
        type="text"
        icon="vxe-icon-square-plus-square"
        :disabled="hasAnsweringStatus"
        style="font-size: 24px"
        @click="handleAddModel"
      ></vxe-button>
    </div>

    <div class="bottom-send-box">
      <div class="talk-send">
        <textarea
          @keydown="handleKeydown"
          ref="input"
          v-model="inputMessage"
          @input="adjustInputHeight"
          :placeholder="$t('modelTalk.placeholder.sendMessage')"
          :rows="2"
        />
        <div class="talk-btn-cont" style="text-align: right; font-size: 18px">
          <!-- 发送消息 -->
          <vxe-button
            v-if="!hasAnsweringStatus"
            type="text"
            @click="sendMessage"
            :disabled="sendBtnDisabled"
            icon="vxe-icon-send-fill"
            style="font-size: 24px"
          ></vxe-button>
          <!-- 停止回答 -->
          <vxe-button
            v-else
            type="text"
            @click="handleModelStop"
            icon="vxe-icon-radio-checked"
            style="font-size: 24px"
          ></vxe-button>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import 'highlight.js/styles/a11y-dark.css'
import { listModels } from '@/api/evaluationServer/modelTalk.js'
import ChatBox from '@/views/evaluationServer/modelTalk/components/ChatBox.vue'
import * as notify from '@/utils/notify'
export default {
  components: { ChatBox },
  data() {
    return {
      modelType: '',
      modelTypeList: [],
      inputMessage: '',
      eventSourceChat: null,
      answerTitle: '',
      thinkTime: null,
      startAnwer: false,
      startTime: null,
      endTime: null,
      typingInterval: null,
      msgHight: null,
      chatBoxs: [
        { id: 1, content: '', show: true, isAnswerIng: false },
        { id: 2, content: '', show: false, isAnswerIng: false },
        { id: 3, content: '', show: false, isAnswerIng: false }
      ]
    }
  },
  mounted() {
    this.getModelList()
  },
  methods: {
    async getModelList() {
      const params = { offset: 0, limit: '10000' }
      const res = await listModels(params)
      const { code, rows } = res.data
      if (code == 1) {
        this.modelTypeList = rows
        if (rows && rows.length) {
          this.modelType = rows[0].id
        }
      }
    },
    /* 清除提问和回答记录 */
    handelAllHistoryAnswer() {
      this.chatBoxs.forEach(item => {
        item.content = ''
        const ref = this.$refs[`ChatBoxRef${item.id}`]
        if (ref && ref.clearHistory) {
          ref.clearHistory()
        }
      })
      notify.success(this.$t('modelTalk.tips.cleanrecorded'))
    },
    /* 增加模型窗口,最多三个 */
    handleAddModel() {
      const hasUnShow = this.chatBoxs.some(item => !item.show)
      if (hasUnShow) {
        const unShowRow = this.chatBoxs.filter(item => !item.show)
        if (unShowRow && unShowRow.length) {
          const index = this.chatBoxs.findIndex(
            item => item.id === unShowRow[0].id
          )
          this.chatBoxs[index].show = true
        }
      } else {
        notify.warning(this.$t('modelTalk.tips.maxModelNum3'))
      }
    },
    /* 获取当前模型窗口位于第几个dom */
    getModelDomIndex(i) {
      if (!i) return i
      const hasShowModels = this.chatBoxs.filter(res => res.show)
      const hasShowLength = hasShowModels.length
      if (hasShowLength === 3) return i
      if (hasShowLength === 2) return 1
    },
    // enter键盘按下的换行赋值为空
    adjustInputHeight(event) {
      if (event.key === 'Enter' && !event.shiftKey) {
        this.inputMessage = ''
        event.preventDefault()
        return
      }

      this.$nextTick(() => {
        const textarea = this.$refs.input
        textarea.style.height = 'auto'
        // 最高200px
        textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px'
        this.msgHight = textarea.style.height
      })
    },
    /* 如果按下Enter键 */
    handleKeydown(event) {
      if (event.isComposing) {
        return
      }
      if (event.key === 'Enter') {
        //Enter+shift 换行
        if (event.shiftKey) {
          return
        } else {
          // 按Enter,阻止默认行为,发送
          event.preventDefault()
          this.sendMessage()
        }
      }
    },
    /* 主动停止模型回答 */
    handleModelStop() {
      this.chatBoxs.forEach(item => {
        if (!item.isAnswerIng) return
        const ref = this.$refs[`ChatBoxRef${item.id}`]
        if (ref?.handleModelStop) {
          ref.handleModelStop().finally(() => {
            this.handleModelAnswerEnd({
              modelIndex: item.id,
              finishReason: 'stop'
            })
          })
        }
      })
    },
    /* 处理模型回答结束,更改回答状态 */
    handleModelAnswerEnd(data) {
      const { modelIndex, finishReason } = data
      //stop正常响应结束,length输出长度达到限制
      if (['stop', 'length'].includes(finishReason)) {
        this.$set(this.chatBoxs[modelIndex - 1], 'isAnswerIng', false)
      }
    },
    setChatBoxRef(el, id) {
      if (el) {
        this.$refs[`ChatBoxRef${id}`] = el
      } else {
        delete this.$refs[`ChatBoxRef${id}`]
      }
    },
    /* 发送消息 */
    sendMessage() {
      if (!this.inputMessage.trim()) return
      if (!this.modelTypeList.length || !this.modelType) {
        //请选择模型
        notify.warning(this.$t('modelTalk.tips.seleModel'))
        return
      }
      this.$nextTick(() => {
        this.chatBoxs.forEach((item, i) => {
          const ref = this.$refs[`ChatBoxRef${item.id}`]
          if (ref && ref.sendMessage) {
            this.chatBoxs[i].isAnswerIng = true
            ref.sendMessage({
              message: this.inputMessage,
              modelIndex: item.id
            })
          }
        })
        this.inputMessage = ''
      })
    },
    /* 删除当前对话模型 */
    handleDeleteCurModel(index) {
      this.chatBoxs[index - 1].show = false
      if (sessionStorage.getItem(`modelTalkParams-${index}`)) {
        sessionStorage.removeItem(`modelTalkParams-${index}`)
      }
    }
  },
  //清除sessionStorage中存储的模型参数
  beforeDestroy() {
    this.chatBoxs.forEach(item => {
      sessionStorage.removeItem(`modelTalkParams-${item.id}`)
    })
  },
  computed: {
    //输入框文本不为空,且不是回答中的状态:发送按钮可用
    sendBtnDisabled() {
      return !this.inputMessage.trim()
    },
    //存在回答中的状态
    hasAnsweringStatus() {
      return this.chatBoxs.some(item => item.show && item.isAnswerIng)
    }
  }
}
</script>

<style lang="scss">
// 尝试使用 @import 替代 @use 引入文件
@import '~@/assets/css/styles/chat-box-markdown.scss';
</style>
<style lang="scss" scoped>
.chat-page-box {
  display: flex;
  flex-direction: column;
  .chat-content-box {
    flex: 1;
    overflow: hidden;
    padding-top: 10px;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
    gap: 10px;
  }
}
.middle-option-box {
  height: 30px;
  line-height: 30px;
  margin-top: 10px;
  ::v-deep .vxe-button {
    .iconfont {
      font-size: 24px !important;
    }
  }
}
.bottom-send-box {
  width: 100%;
  min-height: 124px;
  padding: 10px 0;
  .talk-send {
    height: 100%;
    background: #f1f2f7;
    border-radius: 10px;
    border: 1px solid #e9e9eb;
    padding: 5px 10px;
    img {
      cursor: pointer;
    }

    textarea {
      width: 100%;
      padding: 10px;
      resize: none;
      overflow: auto;
      // min-height: 48px;
      height: 60px !important;
      line-height: 1.5;
      box-sizing: border-box;
      font-family: inherit;
      border: 0px;
      background: #f1f2f7;
    }
    textarea:focus {
      outline: none !important;
    }
  }
}
</style>

样式scss

.hashrate-markdown {
    font-size: 14px;
  }
  .hashrate-markdown ol,
  .hashrate-markdown ul {
    padding-left: 2em;
  }
  .hashrate-markdown pre {
    border-radius: 6px;
    line-height: 1.45;
    overflow: auto;
    display: block;
    overflow-x: auto;
    background: #2c2c36;
    color: rgb(248, 248, 242);
    padding: 16px 8px;
  }
  .hashrate-markdown h1,
  .hashrate-markdown h2,
  .hashrate-markdown h3 {
    // font-size: 1em;
  }
  .hashrate-markdown h4,
  .hashrate-markdown h5,
  .hashrate-markdown h6 {
    font-weight: 600;
    line-height: 1.7777;
    margin: 0.57142857em 0;
  }
  .hashrate-markdown li {
    margin: 0.5em 0;
  }
  .hashrate-markdown strong {
    font-weight: 600;
  }
  .hashrate-markdown p {
    white-space: pre-wrap;
    word-break: break-word;
    line-height: 24px;
    color: #324659;
    font-size: 14px;
  }
  .hashrate-markdown hr {
    background-color: #e8eaf2;
    border: 0;
    box-sizing: content-box;
    height: 1px;
    margin: 12px 0;
    min-width: 10px;
    overflow: hidden;
    padding: 0;
  }
  .hashrate-markdown table {
    border-collapse: collapse;
    border-spacing: 0;
    display: block;
    max-width: 100%;
    overflow: auto;
    width: max-content;
  }
  .hashrate-markdown table tr {
    border-top: 1px solid #e8eaf2;
  }
  .hashrate-markdown table td,
  .hashrate-markdown table th {
    border: 1px solid #e8eaf2;
    padding: 6px 13px;
  }
  .hashrate-markdown table th {
    background-color: #f3f2ff;
    font-weight: 600;
  }
  .hashrate-markdown section {
    margin-inline-start: 0px;
    border-left: 2px solid #e5e5e5;
    padding-left: 10px;
    color: #718096;
    margin-bottom: 5px;
    font-size: 12px;
    p {
      color: #718096;
      font-size: 12px;
      margin: 8px 0;
    }
  }
  
  .think-time {
    height: 36px;
    background: #f1f2f7;
    border-radius: 10px;
    line-height: 36px;
    font-size: 12px;
    display: inline-flex;
    padding: 0px 15px;
    margin-bottom: 20px;
    color: #1e1e1e;
    >i{
      line-height: 36px;
      margin-left: 5px;
    }
  }
  

封装sse.js

import { fetchEventSource } from '@microsoft/fetch-event-source'
import { getToken } from '@/utils/auth' // 假设从auth工具获取token

class SSEService {
  constructor() {
    this.connections = new Map() // 存储所有连接 { connectionId: { controller, config } }
    this.DEFAULT_ID_PREFIX = 'sse_conn_'
    this.MAX_RETRIES = 3 // 最大自动重试次数
    this.BASE_RETRY_DELAY = 1000 // 基础重试延迟(ms)

    // 默认请求头(可通过setDefaultHeaders动态更新)
    this.DEFAULT_HEADERS = {
      'Content-Type': 'application/json',
      'X-Auth-Token': getToken() || ''
    }
  }

  /**
   * 创建SSE连接
   * @param {Object} config - 连接配置
   * @param {String} config.url - 接口地址
   * @param {'GET'|'POST'} [config.method='GET'] - 请求方法
   * @param {Object} [config.params={}] - 请求参数
   * @param {Object} [config.headers={}] - 自定义请求头
   * @param {String} [config.connectionId] - 自定义连接ID
   * @param {Object} handlers - 事件处理器
   * @returns {String} connectionId
   */
  connect(config = {}, handlers = {}) {
    const connectionId = config.connectionId || this._generateConnectionId()
    this.close(connectionId)

    // 合并headers(自定义优先)
    const headers = {
      ...this.DEFAULT_HEADERS,
      ...(config.headers || {}),
      'X-Auth-Token': getToken() || '' // 确保token最新
    }

    // 构建请求配置
    const requestConfig = {
      method: config.method || 'GET',
      headers,
      signal: this._createController(connectionId),
      openWhenHidden: true // 页面隐藏时保持连接
    }

    // 处理URL和参数
    const requestUrl = this._buildUrl(
      config.url,
      config.params,
      requestConfig.method
    )
    if (requestConfig.method === 'POST') {
      requestConfig.body = JSON.stringify(config.params || {})
    }

    // 存储连接信息
    this.connections.set(connectionId, {
      config: { ...config, connectionId },
      controller: requestConfig.signal.controller,
      retryCount: 0
    })

    // 发起连接
    this._establishConnection(requestUrl, requestConfig, connectionId, handlers)
    return connectionId
  }

  /**
   * 实际建立连接(含自动重试逻辑)
   */
  async _establishConnection(url, config, connectionId, handlers) {
    const connection = this.connections.get(connectionId)

    try {
      await fetchEventSource(url, {
        ...config,
        onopen: async response => {
          if (response.ok) {
            connection.retryCount = 0
            handlers.onOpen?.(connectionId)
          } else {
            throw new Error(`SSE连接失败: ${response.status}`)
          }
        },
        onmessage: msg => {
          try {
            const data = msg.data ? JSON.parse(msg.data) : null
            handlers.onMessage?.(data, connectionId)
          } catch (err) {
            handlers.onError?.(err, connectionId)
          }
        },
        onerror: err => {
          if (connection.retryCount < this.MAX_RETRIES) {
            const delay =
              this.BASE_RETRY_DELAY * Math.pow(2, connection.retryCount)
            setTimeout(() => {
              connection.retryCount++
              this._establishConnection(url, config, connectionId, handlers)
            }, delay)
          } else {
            handlers.onFinalError?.(err, connectionId)
            this.close(connectionId)
          }
          throw err // 阻止库默认的重试逻辑
        }
      })
    } catch (err) {
      console.error(`[SSE ${connectionId}] 连接异常:`, err)
    }
  }

  /**
   * 关闭指定连接
   */
  close(connectionId) {
    const conn = this.connections.get(connectionId)
    if (conn?.controller) {
      conn.controller.abort()
      this.connections.delete(connectionId)
    }
  }

  /**
   * 关闭所有连接
   */
  closeAll() {
    this.connections.forEach(conn => conn.controller?.abort())
    this.connections.clear()
  }

  /**
   * 更新默认请求头
   */
  setDefaultHeaders(headers) {
    this.DEFAULT_HEADERS = { ...this.DEFAULT_HEADERS, ...headers }
  }

  // -------------------- 工具方法 --------------------
  _generateConnectionId() {
    return `${this.DEFAULT_ID_PREFIX}${Date.now()}_${Math.random()
      .toString(36)
      .slice(2, 7)}`
  }

  _buildUrl(baseUrl, params = {}, method) {
    const url = new URL(baseUrl, window.location.origin)
    if (method === 'GET' && params) {
      Object.entries(params).forEach(([key, value]) => {
        if (value !== undefined) url.searchParams.set(key, value)
      })
    }
    return url.toString()
  }

  _createController(connectionId) {
    const controller = new AbortController()
    const conn = this.connections.get(connectionId)
    if (conn) conn.controller = controller
    return controller.signal
  }
}

export const sse = new SSEService()

 sse响应数据格式

{"content":"<think>","reasoningContent":null,"created":1754036279,"finishReason":null,"modelName":"DS-1.5B","deployId":null,"id":"8b593756-2aeb-416e-9839-645abb8dfcef"}
{"content":"我是","reasoningContent":null,"created":1754036279,"finishReason":null,"modelName":"DS-1.5B","deployId":null,"id":"8b593756-2aeb-416e-9839-645abb8dfcef"}
{"content":"Deep","reasoningContent":null,"created":1754036279,"finishReason":null,"modelName":"DS-1.5B","deployId":null,"id":"8b593756-2aeb-416e-9839-645abb8dfcef"}
{"content":"</think>","reasoningContent":null,"created":1754036930,"finishReason":null,"modelName":"DS-1.5B","deployId":null,"id":"0548d17e-72b5-4219-857d-054155d59096"}
{"content":"我可以","reasoningContent":null,"created":1754036930,"finishReason":null,"modelName":"DS-1.5B","deployId":null,"id":"0548d17e-72b5-4219-857d-054155d59096"}
{"content":"理解","reasoningContent":null,"created":1754036930,"finishReason":null,"modelName":"DS-1.5B","deployId":null,"id":"0548d17e-72b5-4219-857d-054155d59096"}
{"content":"","reasoningContent":null,"created":1754036930,"finishReason":"stop","modelName":"DS-1.5B","deployId":null,"id":"0548d17e-72b5-4219-857d-054155d59096"}

ModelParamConfig组件属于参数配置表单,可根据实际需求开发

最终效果


网站公告

今日签到

点亮在社区的每一天
去签到