当前位置: 首页 > article >正文

vue 仿deepseek前端开发一个对话界面

后端:调用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做收起

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

有些地方可能写得比较繁琐
比如判断思考内容那段,只能通过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
          @keyup.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>
    <copyrightContent :systemNameOption="systemNameOption" />

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

<script>
import taskDialog from '@/views/chat/task/taskDialog.vue'
import copyrightContent from '@/views/chat/talking/components/copyright.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, copyrightContent },
  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.inputMessage ||
        this.inputMessage.trim() === '' ||
        this.inputMessage.split(/\r?\n/).every((line) => line.trim() === '')
      ) {
        return this.isTalking
          ? require('/src/assets/image/chat/stop.png')
          : 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>

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


http://www.kler.cn/a/582651.html

相关文章:

  • docker+ollama+flask+mysql实现本地数据库读取操作
  • unet模型在车道线检测上的应用【代码+数据集+python环境+GUI系统】
  • OpenBMC:BmcWeb 处理认证
  • 如何搭建一套行业版B2B2C商城平台(类京东/美团)?|商派BBC
  • AF3 make_fixed_size函数解读
  • PostgreSQL 数据库备份与恢复指南
  • Nanobrowser:开源AI自动化神器 OpenAI Operator替代品
  • 桂链:什么是区块链智能合约和链码?
  • 浅谈SSE爬虫
  • Flutter 学习之旅 之 flutter 不使用插件,实现简单自定义弹窗PopupDialog功能
  • 单片机FreeRTOS系统中,CPU计算的延时函数
  • es6 尚硅谷 学习
  • Qt表格美化笔记
  • React 组件测试【React Testing Library】
  • [Unity] 封装一个依赖于MonoBehaviour的计时器(上)
  • 2025年Draw.io最新版本下载安装教程,附详细图文
  • 从0到1入门Docker
  • Node-RED基础1
  • A-LOAM工程笔记(三):雷达里程计(lidar odometry)高频粗估计
  • 挤压车间降温设备