后端:调用deepseek的api,所以返回数据格式和deepseek相同

{"model": "DeepSeek-R1-Distill-Qwen-1.5B", "choices": [{"index": 0, "delta": {"role": "assistant", "content": ",有什么", "tool_calls": null}, "finish_reason": null, "logprobs": null}], "usage": {"prompt_tokens": 5, "completion_tokens": 11, "total_tokens": 16}, "id": "chatcmpl-203a3024a36e4c02b02200ca47d8901e", "object": "chat.completion.chunk", "created": 1741766893}

在这里插入图片描述
前端开发的几个注意点:

  1. 将后端返回的文本转换为html展示在页面上,注意调整样式
  2. 模拟打字形式,页面随之滚动
  3. 撑开的输入框和对话内容部分样式调整
  4. 测试多种文本输入,例如含有html标签的
  5. 记录思考时间,思考内容模仿deepseek做收起
  6. 修改nginx配置,禁用缓冲和启用分块传输,确保数据实时传输

前提:

  1. 项目中安装依赖highlight.js、markdown-it
  2. 修改nginx.conf
location 你的对话请求接口 {
  proxy_pass https://127.0.0.1:10443; // 按实际的写
  proxy_http_version 1.1;
  proxy_set_header Connection '';
  proxy_buffering off; //禁用代理缓冲,使响应数据立即发送给客户端而不是先缓冲
  chunked_transfer_encoding on; //启用分块传输编码,适用于流式传输
  add_header Cache-Control no-cache; //添加响应头,告诉客户端不要缓存响应

  proxy_read_timeout 3600s;
  proxy_send_timeout 3600s;

  proxy_set_header Host $http_host;
  proxy_set_header Remote_addr $remote_addr;
}

在这里插入图片描述


这只是初步的项目,仅支持文本输入
懒得分步写了,直接贴完整代码吧


补充:最开始发对话使用的是get请求,已修改为post,见最下
4.9优化了回答的打字效果,见最下

有些地方可能写得比较繁琐
比如判断思考内容那段,只能通过think标签判断吗?
请各位多多指点,欢迎交流!!

<template>
  <div class="talk-window">
    <div class="talk-title">
      <p>{{ answerTitle }}</p>
      <el-input v-model="choicedTasks.name" placeholder="请选择任务" class="sangedianBtn" readonly>
        <template #append>
          <el-button class="ec-font icon-sangedian" @click="isShowTask = true" />
        </template>
      </el-input>
    </div>
    <div class="talk-container">
      <div class="talk-welcome" v-if="contentList.length == 0">
        <h1>{{ welcome.title }}</h1>
        <p>{{ welcome.desc }}</p>
      </div>
      <div class="talk-box" v-else :style="{ height: answerContHeight }">
        <div ref="logContainer" 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 === 'send' ? 'end' : 'start']">
                <img v-if="item.type == 'answer'" :src="welcome.icon" />
                <div :class="item.type === 'send' ? 'send-item' : 'answer-item'">
                  <div v-if="item.type == 'answer'" class="hashrate-markdown" v-html="item.message" />
                  <div v-else>{{ item.message }}</div>
                </div>
                <!-- 增加复制 -->
                <!-- <div v-if="item.type == 'answer' && !isTalking">
                  <el-tooltip centent="复制">
                    <i class="ec-font icon-ticket" @click="copyMsg(item.message)" />
                  </el-tooltip>
                </div> -->
              </div>
            </transition>
          </el-row>
        </div>
        <div style="text-align: center; margin-top: 10px">
          <el-button class="chat-add" @click="newChat">
            <i class="ec-font icon-tianjia1" />
            新建对话
          </el-button>
        </div>
      </div>
      <div class="talk-send">
        <textarea
          @keydown.enter="enterMessage"
          ref="input"
          v-model="inputMessage"
          @input="adjustInputHeight"
          placeholder="输入消息..."
          :rows="2" />
        <!-- <el-input
          v-model="inputMessage"
          :autosize="{ minRows: 2, maxRows: 5 }"
          type="textarea"
          @keyup.enter="enterMessage"
          placeholder="Please input" /> -->
        <div class="talk-btn-cont" style="text-align: right">
          <img @click="sendMessage" :src="iconImg" />
        </div>
      </div>
    </div>

    <taskDialog v-if="isShowTask" :choicedTasks="choicedTasks" v-model:isShow="isShowTask" @confirm="confirmTask" />
  </div>
</template>

<script>
import taskDialog from '@/views/chat/task/taskDialog.vue'
import hljs from 'highlight.js'
import 'highlight.js/styles/a11y-dark.css'
import MarkdownIt from 'markdown-it'
import { VSConfig } from '/config/envConfig'

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('icon-a-xiangshang3', 'icon-a-xiangxia3')
  } else {
    document.getElementById(`think_content_${index}`).style.display = 'none'
    document.getElementById(`think_icon_${index}`).classList.replace('icon-a-xiangxia3', 'icon-a-xiangshang3')
  }
}

export default {
  props: {
    isCollapsed: {
      type: Boolean,
      default: false
    },
    activeChat: String,
    activeTitle: String,
    chat_style_setting: {
      type: Object,
      default: function () {
        return {}
      }
    },
    systemNameOption: {
      type: Object,
      default: function () {
        return {}
      }
    }
  },
  components: { taskDialog },
  data() {
    return {
      inputMessage: '',
      messages: [],
      choicedTasks: {
        uuid: '',
        name: ''
      },
      isShowTask: false,
      contentList: [],
      eventSourceChat: null,
      markdownIt: {},
      startAnwer: false,
      startTime: null,
      endTime: null,
      thinkTime: null,
      answerTitle: '',
      talkUUID: '',
      refreshHistoryFlag: false, // 是否已经生成了对话uuid,生成了的话就刷新历史列表
      msgHight: null,
      isTalking: false, //是否在对话中,处于对话中则展示休止按钮
      welcome: {
        title: '很高兴见到你!',
        desc: '我可以帮你写代码、读文件、写作各种创意内容,请把你的任务交给我吧~',
        icon: require('@/assets/image/AI.png')
      },
      store: {}
    }
  },
  watch: {
    activeTitle(val) {
      // 重命名了对话
      this.answerTitle = val
    },
    activeChat(val) {
      this.answerTitle = ''
      this.talkUUID = val || ''
      this.inputMessage = ''
      this.refreshHistoryFlag = false
      this.isTalking = false
      if (val) {
        // 滚动回到顶部
        const logContainer = this.$refs.logContainer
        if (logContainer) {
          logContainer.scrollTop = 0
        }
        this.getTalkDetail()
      } else {
        this.contentList = []
      }
      this.eventSourceChat && this.eventSourceChat.close()
    },
    chat_style_setting(val) {
      this.welcome.title = val.welcome_speech_style || this.welcome.title
      this.welcome.desc = val.description_style || this.welcome.desc
      this.welcome.icon = val.icon_image || this.welcome.icon
    }
  },
  computed: {
    iconImg() {
      // 对话中可能有输入
      if (this.isTalking) {
        return require('/src/assets/image/chat/stop.png')
      } else {
        if (
          !this.inputMessage ||
          this.inputMessage.trim() === '' ||
          this.inputMessage.split(/\r?\n/).every((line) => line.trim() === '')
        ) {
          return require('/src/assets/image/chat/unsend.png')
        } else {
          return require('/src/assets/image/chat/send.png')
        }
      }
    },
    answerContHeight() {
      // 回到初始值
      return this.msgHight == '56px' ? 'calc(100% - 140px)' : `calc(100% - 140px - ${this.msgHight} + 56px)`
    }
  },
  mounted() {
    this.store = mainStore()
    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 (__) {}
        }
        return '' // use external default escaping
      }
    })
  },
  methods: {
    getTalkDetail() {
      chat
        .historyDetail({
          uuid: this.activeChat
        })
        .then((res) => {
          this.contentList = []
          if (res.code == 0) {
            res.info.history_meta &&
              res.info.history_meta.forEach((item, index) => {
                if (item.conversation_type == 'Answer') {
                  // 增加一个历史思考记录收起吧
                  item.context = item.context
                    .replace(/<think>\n\n<\/think>/g, '')
                    .replaceAll(
                      '<think>',
                      `<div class="think-time">历史思考<i id="think_icon_${index}" onclick="hiddenThink(${index})" class="ec-font icon-a-xiangxia3"></i></div><section id="think_content_${index}">`
                    )
                    .replaceAll('</think>', '</section>')
                  item.context = this.markdownIt.render(item.context)
                }

                this.contentList.push({
                  type: item.conversation_type == 'Question' ? 'send' : 'answer',
                  message: item.context
                })
              })
            this.answerTitle = res.info.name.substring(0, 20)
          }
        })
    },
    enterMessage(event) {
      if (event.key === 'Enter' && !event.shiftKey) {
        event.preventDefault()
        this.sendMessage()
      }
    },
    sendMessage() {
      // 终止当前对话
      if (this.isTalking) {
        this.eventSourceChat && this.eventSourceChat.close()
        // 关闭后,处理正在对话的"思考中"
        let curAnswer = this.contentList[this.contentList.length - 1].message
        curAnswer = curAnswer.replaceAll('<div class="think-time">思考中……</div>', '对话中止')
        this.contentList[this.contentList.length - 1].message = curAnswer
        this.isTalking = false
        // 再获取一下历史记录
        // 暂时不获取历史记录,因为中止对话时,历史UUID可能还没返回
        // this.$emit('getHistoryList')
        return
      }

      if (
        !this.inputMessage ||
        this.inputMessage.trim() === '' ||
        this.inputMessage.split(/\r?\n/).every((line) => line.trim() === '')
      ) {
        this.inputMessage = ''
        return false
      }
      if (!this.choicedTasks.name) {
        this.$message({ message: '请选择推理任务', type: 'warning' })
        return false
      }
      // 回到初始高度
      const textarea = this.$refs.input
      textarea.style.height = '56px'
      this.msgHight = '56px'
      this.eventSourceChat && this.eventSourceChat.close()
      // let markedText = this.markdownIt.render(this.inputMessage)
      this.contentList.push({ type: 'send', message: this.inputMessage })
      this.contentList.push({ type: 'answer', message: `<div class="think-time">思考中……</div>` })
      this.answerTitle = this.answerTitle || this.contentList[0].message.substring(0, 20)
      this.scrollToBottom()
      this.initSSEChat()
    },
    initSSEChat() {
      const url = `${VSConfig.isHttps ? 'https' : 'http'}://${
        this.store.leader
      }/v1/intelligent_computing/task/chat/stream?uuid=${this.choicedTasks.uuid}&message=${encodeURIComponent(
        this.inputMessage
      )}&token=${this.store.token}&conversation_uuid=${this.talkUUID}`
      this.inputMessage = ''
      this.eventSourceChat = new EventSource(url)
      let buffer = ''
      this.startTime = null
      this.endTime = null
      this.thinkTime = null
      let len = this.contentList.length
      let index = len % 2 === 0 ? len - 1 : len
      this.isTalking = true
      this.eventSourceChat.onmessage = async (event) => {
        await this.sleep(10)
        if (event.data == '[DONE]') { // 最后一条数据
          return false
        }
        // 接收 Delta 数据
        // 我项目的会返回对话UUID,第二次发对话的时候要传参
        try {
          var { choices, created } = JSON.parse(event.data)
        } catch (e) {
          // 新对话在历史列表补充数据
          this.talkUUID = event.data
          if (!this.refreshHistoryFlag) {
            this.refreshHistoryFlag = event.data
            this.$emit('refreshHistory', this.refreshHistoryFlag)
          }
        }
        // const { choices, created } = JSON.parse(event.data)
        if (choices && choices[0].delta?.content) {
          buffer += choices[0].delta.content
          // think标签内是思考内容,单独记录思考时间
          if (choices[0].delta.content.includes('<think>')) {
            choices[0].delta.content = `<div class="think-time">思考中……</div><section id="think_content_${index}">`
            buffer = buffer.replaceAll('<think>', choices[0].delta.content)
            this.startTime = Math.floor(new Date().getTime() / 1000)
          }
          if (choices[0].delta.content.includes('</think>')) {
            // console.log("结束时间赋值的判断")
            choices[0].delta.content = `</section>`
            this.endTime = Math.floor(new Date().getTime() / 1000)
            // 获取到结束时间后,直接展示收起按钮
            this.thinkTime = this.endTime - this.startTime
            buffer = buffer
              .replaceAll(
                '<div class="think-time">思考中……</div>',
                `<div class="think-time">已深度思考(${this.thinkTime}S)<i id="think_icon_${index}" onclick="hiddenThink(${index})" class="ec-font icon-a-xiangxia3"></i></div>`
              )
              .replaceAll('</think>', choices[0].delta.content)
              .replaceAll(`<section id="think_content_${index}"></section>`, '')
          }
          let markedText = this.markdownIt.render(buffer)
          this.contentList[index] = { type: 'answer', message: markedText }
          this.scrollToBottomIfAtBottom()
        }
      }
      this.eventSourceChat.onerror = (event) => {
        console.log('错误触发===》', event)
        this.contentList[index] = { type: 'answer', message: `<div class="think-time">对话服务连接失败</div>` }
        this.eventSourceChat.close()
        this.isTalking = false
      }
      this.eventSourceChat.onclose = (event) => {
        // 关闭事件
        console.log('关闭事件--->')
        this.isTalking = false
      }
    },
    sleep(ms) {
      return new Promise((resolve) => setTimeout(resolve, ms))
    },
    scrollToBottomIfAtBottom() {
      this.$nextTick(() => {
        const logContainer = this.$refs.logContainer
        if (logContainer) {
          const threshold = 100
          const distanceToBottom = logContainer.scrollHeight - logContainer.scrollTop - logContainer.clientHeight
          if (distanceToBottom <= threshold) logContainer.scrollTop = logContainer.scrollHeight
        }
      })
    },
    scrollToBottom() {
      this.$nextTick(() => {
        const logContainer = this.$refs.logContainer
        if (logContainer) {
          logContainer.scrollTop = logContainer.scrollHeight
        }
      })
    },
    confirmTask(val) {
      this.choicedTasks = val
    },
    clearWindow() {
      this.eventSourceChat && this.eventSourceChat.close()
      this.contentList = []
      this.answerTitle = ''
      this.talkUUID = ''
      this.inputMessage = ''
      this.refreshHistoryFlag = false
      this.isTalking = false
      const textarea = this.$refs.input
      textarea.style.height = '56px'
      this.msgHight = '56px'
    },
    newChat() {
      this.clearWindow()
      this.$emit('clearChat')
    },
    adjustInputHeight(event) {
      // enter键盘按下的换行赋值为空
      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
      })
    },
    copyMsg(txt) {
      // 复制功能
      // 创建一个临时的 textarea 元素
      const textarea = document.createElement('textarea')
      textarea.value = txt.replace(/<[^>]+>/g, '') // 去掉html标签
      textarea.style.position = 'fixed'
      document.body.appendChild(textarea)
      textarea.select() // 选中文本

      try {
        document.execCommand('copy') // 执行复制
        ElMessage({
          message: '复制成功',
          type: 'success'
        })
      } catch (err) {
        ElMessage({
          message: '复制失败',
          type: 'error'
        })
      } finally {
        document.body.removeChild(textarea) // 移除临时元素
      }
    }
  },

  beforeDestroy() {
    this.eventSourceChat && this.eventSourceChat.close()
  }
}
</script>

<style scoped lang="scss">
.talk-window {
  height: 100%;
  transition: margin 0.2s ease;
  position: relative;
}
.talk-container {
  height: calc(100% - 58px);
  position: relative;
}
.talk-welcome {
  text-align: center;
  // margin-bottom: 25px;
  padding: 10% 20% 25px;
  box-sizing: border-box;
  h1 {
    margin-bottom: 30px;
    font-size: 21px;
  }
  p {
    color: #8f9aad;
  }
}
.messages {
  padding: 20px;
  overflow-y: auto;
}

.message {
  display: flex;
  margin: 12px 0;
}

.message.user {
  justify-content: flex-end;
}

.bubble {
  max-width: 70%;
  padding: 12px 16px;
  border-radius: 12px;
  background: #fff;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.message.user .bubble {
  background: #e8f4ff;
}

.time {
  font-size: 12px;
  color: #666;
  margin-top: 4px;
}

.talk-send {
  background: #f1f2f7;
  border-radius: 10px;
  border: 1px solid #e9e9eb;
  padding: 5px 10px;

  margin: 0px 20%;
  img {
    cursor: pointer;
  }

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

input {
  flex: 1;
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 8px;
}

.talk-title {
  height: 56px;
  line-height: 56px;
  p {
    color: #000000;
    font-size: 15px;
    font-weight: 550;
    text-align: center;
  }
  .sangedianBtn {
    width: 225px;
    height: 32px;
    position: absolute;
    top: 15px;
    right: 30px;
  }
}

.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;
}
.msg-row {
  margin-bottom: 10px;
}
.talk-box {
  height: calc(100% - 140px);
  .talk-content {
    background-color: #fff;
    color: #324659;
    overflow-y: auto;
    height: calc(100% - 50px);
    box-sizing: border-box;
    padding: 0px 20%;
    // &:hover {
    //   overflow-y: auto;
    // }
    .chat-assistant {
      display: flex;
      margin-bottom: 10px;
      .answer-item {
        line-height: 30px;
        color: #324659;
      }
    }
    .answer-cont {
      position: relative;
      display: flex;
      width: 100%;
      > img {
        width: 30px;
        height: 30px;
        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>

<style lang="scss">
@use './markdown.scss';
</style>

markdown.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;
}

最终页面:
在这里插入图片描述

3.26更新:接口换为post请求

网上常用的是@microsoft/fetch-event-source这个库
试了下有出错后陷入循环的问题
而我项目接口报错时会出现非对话流的返回,对话流错误时会返回非message类型推流,所以自行封装了EventSource发送post请求

继续优化:优化回答的显示形式,加强打字效果模拟

之前用的是

let markedText = this.markdownIt.render(buffer)
this.contentList[index] = { type: 'answer', message: markedText }

上面这段代码的逻辑是:
每次接收到新的 SSE 片段,就把所有内容存到 buffer,把整个 buffer 渲染成 HTML,替换掉原先的 message,整个 HTML 一次性展示。
这就导致就算设置了sleep,最终页面都会一次性更新,没有“打字”那种一字一顿的逐步更新感
增加定时器,控制字符更新节奏
优化代码改到下面了

sse.js:

// 从git上找了一个项目 在此基础上另外修改封装
/**
 * sse.js - A flexible EventSource polyfill/replacement.
 * https://github.com/mpetazzoni/sse.js
 *
 */


/**
 * @type SSE
 * @param {string} url
 * @param {SSEOptions} options
 * @return {SSE}
 */
var SSE = function (url, options) {
  if (!(this instanceof SSE)) {
    return new SSE(url, options);
  }

  /** @type {string} */
  this.url = url;

  options = options || {};
  this.headers = options.headers || {};
  this.payload = options.payload !== undefined ? options.payload : '';
  this.method = options.method || (this.payload && 'POST' || 'GET');
  this.withCredentials = !!options.withCredentials;
  this.debug = !!options.debug;

  /** @type {string} */
  this.FIELD_SEPARATOR = ':';

  /** @type { {[key: string]: [EventListener]} } */
  this.listeners = {};

  /** @type {XMLHttpRequest} */
  this.xhr = null;
  /** @type {number} */
  this.readyState = SSE.INITIALIZING;
  /** @type {number} */
  this.progress = 0;
  /** @type {string} */
  this.chunk = '';
  /** @type {string} */
  this.lastEventId = '';
  /**
   * @type AddEventListener
   */
  this.addEventListener = function (type, listener) {
    if (this.listeners[type] === undefined) {
      this.listeners[type] = [];
    }

    if (this.listeners[type].indexOf(listener) === -1) {
      this.listeners[type].push(listener);
    }
  };

  /**
   * @type RemoveEventListener
   */
  this.removeEventListener = function (type, listener) {
    if (this.listeners[type] === undefined) {
      return;
    }

    const filtered = [];
    this.listeners[type].forEach(function (element) {
      if (element !== listener) {
        filtered.push(element);
      }
    });
    if (filtered.length === 0) {
      delete this.listeners[type];
    } else {
      this.listeners[type] = filtered;
    }
  };

  /**
   * @type DispatchEvent
   */
  this.dispatchEvent = function (e) {
    if (!e) {
      return true;
    }

    if (this.debug) {
      console.debug(e);
    }

    e.source = this;

    const onHandler = 'on' + e.type;
    if (this.hasOwnProperty(onHandler)) {
      this[onHandler].call(this, e);
      if (e.defaultPrevented) {
        return false;
      }
    }

    if (this.listeners[e.type]) {
      return this.listeners[e.type].every(function (callback) {
        callback(e);
        return !e.defaultPrevented;
      });
    }

    return true;
  };

  /** @private */
  this._markClosed = function () {
    this.xhr = null;
    this.progress = 0;
    this.chunk = '';
    this._setReadyState(SSE.CLOSED);
  };

  /** @private */
  this._setReadyState = function (state) {
    const event = new CustomEvent('readystatechange');
    event.readyState = state;
    this.readyState = state;
    this.dispatchEvent(event);
  };

  this._onStreamFailure = function (e) {
    const event = new CustomEvent('error');
    event.responseCode = e.currentTarget.status;
    event.data = e.currentTarget.response;
    this.dispatchEvent(event);
    this._markClosed();
  }

  this._onStreamAbort = function () {
    this.dispatchEvent(new CustomEvent('abort'));
    this._markClosed();
  }

  /** @private */
  this._onStreamProgress = function (e) {
    if (!this.xhr) {
      return;
    }

    if (this.xhr.status < 200 || this.xhr.status >= 300) {
      this._onStreamFailure(e);
      return;
    }

    const data = this.xhr.responseText.substring(this.progress);
    this.progress += data.length;

    const parts = (this.chunk + data).split(/(\r\n\r\n|\r\r|\n\n)/g);

    /*
     * We assume that the last chunk can be incomplete because of buffering or other network effects,
     * so we always save the last part to merge it with the next incoming packet
     */
    const lastPart = parts.pop();
    parts.forEach(function (part) {
      if (part.trim().length > 0) {
        this.dispatchEvent(this._parseEventChunk(part));
      }
    }.bind(this));
    this.chunk = lastPart;
  };

  /** @private */
  this._onStreamLoaded = function (e) {
    this._onStreamProgress(e);

    // Parse the last chunk.
    this.dispatchEvent(this._parseEventChunk(this.chunk));
    this.chunk = '';

    this._markClosed();

    // 手动触发 close 事件
    if (typeof this.onclose === 'function') {
      this.onclose(e);
    }
  };

  /**
   * Parse a received SSE event chunk into a constructed event object.
   *
   * Reference: https://html.spec.whatwg.org/multipage/server-sent-events.html#dispatchMessage
   */
  this._parseEventChunk = function (chunk) {
    if (!chunk || chunk.length === 0) {
      return null;
    }

    if (this.debug) {
      console.debug(chunk);
    }

    const e = { 'id': null, 'retry': null, 'data': null, 'event': null };
    chunk.split(/\n|\r\n|\r/).forEach(function (line) {
      const index = line.indexOf(this.FIELD_SEPARATOR);
      let field, value;
      if (index > 0) {
        // only first whitespace should be trimmed
        const skip = (line[index + 1] === ' ') ? 2 : 1;
        field = line.substring(0, index);
        value = line.substring(index + skip);
      } else if (index < 0) {
        // Interpret the entire line as the field name, and use the empty string as the field value
        field = line;
        value = '';
      } else {
        // A colon is the first character. This is a comment; ignore it.
        return;
      }

      if (!(field in e)) {
        return;
      }

      // consecutive 'data' is concatenated with newlines
      if (field === 'data' && e[field] !== null) {
        e['data'] += "\n" + value;
      } else {
        e[field] = value;
      }
    }.bind(this));

    if (e.id !== null) {
      this.lastEventId = e.id;
    }

    if (e.event) {
      const event = new CustomEvent(e.event || 'message');
      event.id = e.id;
      event.data = e.data || '';
      event.lastEventId = this.lastEventId;
      return event;
    } else {
      // 考虑一种情况,单纯后端报错;
      // 要与正常对话结束返回的JSON区分,且要与alert类型(报错类型)后的返回区分
      try {
        const { data } = JSON.parse(chunk)
        if (data && data.status.code) {
          // 后端报错
          const errorEvent = new CustomEvent('alert');
          errorEvent.id = e.id;
          errorEvent.data = JSON.stringify(data.status);
          errorEvent.lastEventId = this.lastEventId;
          return errorEvent
        }
      } catch (e) {
      }


    }
  };

  this._onReadyStateChange = function () {
    if (!this.xhr) {
      return;
    }

    if (this.xhr.readyState === XMLHttpRequest.HEADERS_RECEIVED) {
      const headers = {};
      const headerPairs = this.xhr.getAllResponseHeaders().trim().split('\r\n');
      for (const headerPair of headerPairs) {
        const [key, ...valueParts] = headerPair.split(':');
        const value = valueParts.join(':').trim();
        // Ensure the header value is always an array
        headers[key.trim().toLowerCase()] = headers[key.trim().toLowerCase()] || [];
        headers[key.trim().toLowerCase()].push(value);
      }

      const event = new CustomEvent('open');
      event.responseCode = this.xhr.status;
      event.headers = headers;
      this.dispatchEvent(event);
      this._setReadyState(SSE.OPEN);
    }
  };

  //这里绑定方法,确保 `removeEventListener` 能正确移除事件
  this._onStreamProgress = this._onStreamProgress.bind(this);
  this._onStreamLoaded = this._onStreamLoaded.bind(this);
  this._onReadyStateChange = this._onReadyStateChange.bind(this);
  this._onStreamFailure = this._onStreamFailure.bind(this);
  this._onStreamAbort = this._onStreamAbort.bind(this);
  /**
   * starts the streaming
   * @type Stream
   * @return {void}
   */
  this.stream = function () {
    if (this.xhr) {
      // Already connected.
      return;
    }

    this._setReadyState(SSE.CONNECTING);

    this.xhr = new XMLHttpRequest();
    this.xhr.addEventListener('progress', this._onStreamProgress);
    this.xhr.addEventListener('load', this._onStreamLoaded);
    this.xhr.addEventListener('readystatechange', this._onReadyStateChange);
    this.xhr.addEventListener('error', this._onStreamFailure);
    this.xhr.addEventListener('abort', this._onStreamAbort);
    this.xhr.open(this.method, this.url);
    for (let header in this.headers) {
      this.xhr.setRequestHeader(header, this.headers[header]);
    }
    if (this.lastEventId.length > 0) {
      this.xhr.setRequestHeader("Last-Event-ID", this.lastEventId);
    }
    this.xhr.withCredentials = this.withCredentials;
    this.xhr.send(this.payload);
  };

  /**
   * closes the stream
   * @type Close
   * @return {void}
   */
  this.close = function () {
    if (this.readyState === SSE.CLOSED) {
      return;
    }
    // 移除事件监听器
    this.xhr.removeEventListener('progress', this._onStreamProgress);
    this.xhr.removeEventListener('load', this._onStreamLoaded);
    this.xhr.removeEventListener('readystatechange', this._onReadyStateChange);
    this.xhr.removeEventListener('error', this._onStreamFailure);
    this.xhr.removeEventListener('abort', this._onStreamAbort);

    // 终止请求
    try {
      this.xhr.abort();
    } catch (e) {
      console.warn('XHR abort error:', e);
    }
    this.xhr = null;
    // 标记关闭状态
    this._markClosed();
  };
  // 补充
  this.destroy = function () {
    this.close();
    this.listeners = {}; // 彻底销毁事件监听器
    this._onStreamProgress = null;
    this._onStreamLoaded = null;
    this._onReadyStateChange = null;
    this._onStreamFailure = null;
    this._onStreamAbort = null;
  };
  if (options.start === undefined || options.start) {
    this.stream();
  }
};
/** @type {number} */
SSE.INITIALIZING = -1;
/** @type {number} */
SSE.CONNECTING = 0;
/** @type {number} */
SSE.OPEN = 1;
/** @type {number} */
SSE.CLOSED = 2;


// Export our SSE module for npm.js
if (typeof exports !== 'undefined') {
  exports.SSE = SSE;
}

// Export as an ECMAScript module
export { SSE };

使用:


    postSSEChat() {
      // 修改为post类型
      this.eventSourceChat = new SSE(
        `${VSConfig.isHttps ? 'https' : 'http'}://${this.store.leader}/v1/intelligent_computing/task/chat/stream`,
        {
          method: 'POST',
          headers: {
            'Content-Type': 'text/event-stream',
            Authorization: `Bearer ${this.store.token}`
          },
          payload: JSON.stringify({
            message: this.inputMessage,
            token: this.store.token,
            conversation_uuid: this.talkUUID,
            uuid: this.choicedTasks.uuid
          })
        }
      )
      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
      this.isTalking = true
      this.eventSourceChat.onmessage = async (event) => {
        if (event.data == '[DONE]') {
          this.clearTypingInterval()
          return false
        }
        await this.sleep(10)
        // 接收 Delta 数据
        // 最后一条是UUID,第二次发对话的时候要传参
        try {
          var { choices, created } = JSON.parse(event.data)
        } catch (e) {
          // 新对话在历史列表补充数据
          this.talkUUID = event.data
          if (!this.refreshHistoryFlag) {
            this.refreshHistoryFlag = event.data
            this.$emit('refreshHistory', this.refreshHistoryFlag)
          }
        }
        if (choices && choices[0].delta?.content) {
          let answerCont = choices[0].delta.content
          buffer += answerCont
          // 单独记录时间
          if (answerCont.includes('<think>') || answerCont.includes('</think>')) {
            // 思考时间这段不需要有打字效果
            // 执行替换逻辑
            if (answerCont.includes('<think>')) {
              answerCont = `<div class="think-time">思考中……</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">思考中……</div>',
                  `<div class="think-time">已深度思考(${this.thinkTime}S)<i id="think_icon_${index}" onclick="hiddenThink(${index})" class="ec-font icon-a-xiangxia3"></i></div>`
                )
                .replaceAll('</think>', answerCont)
                .replaceAll(`<section id="think_content_${index}"></section>`, '')
            }

            // 避免闪动 直接修改数据,这里不需要打字效果
            displayBuffer = buffer // 同步displayBuffer避免断层
            this.contentList[index] = { type: 'answer', 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
                  // displayBuffer += buffer[displayBuffer.length]
                  let markedText = this.markdownIt.render(displayBuffer)
                  this.contentList[index] = { type: 'answer', message: markedText }
                  this.scrollToBottomIfAtBottom()
                } else {
                  clearInterval(this.typingInterval)
                  this.typingInterval = null
                }
              }, 40)
            }
          }
        }
      }
      // 如果是报错,后端会返回alert
      this.eventSourceChat.onalert = (e) => {
        this.eventSourceChat.close()
        this.errorFunc(index, e)
      }
      this.eventSourceChat.onerror = (e) => {
        this.eventSourceChat.close()
        this.isTalking = false
      }
      this.eventSourceChat.onabort = (e) => {
        console.log('AAAAAAAAA---------')
        this.clearTypingInterval()
        this.isTalking = false
      }
      this.eventSourceChat.onclose = (e) => {
        console.log('onclose---------')
        this.clearTypingInterval()
        this.isTalking = false
      }
    },
    // 记得项目多处都要清掉计时器
    clearTypingInterval() {
      clearInterval(this.typingInterval)
      this.typingInterval = null
    },
Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐