人工智能之web前端开发(deepSeek与文心一言结合版)

一.项目功能:

  1. 智能问答(实时聊天+流畅打字机效果+自动滚动)
  2. 停止生成(取消接口调用)、重新生成
  3. 复制功能、问答分页

二.效果展示:

人工智能之web前端开发(deepSeek与文心一言结合版)_第1张图片

三.技术分析:

  1. fetchEventSource:传统axios请求是等接口将所有数据一次性响应回来后再渲染到页面上,当数据量较大时,响应速度较慢,且无法做到实时输出。而fetchEventSource允许客户端接收来自服务器的实时更新,前端可以实时的将流式数据展示到页面上,类似于打字机的效果。

     fetchEventSource(url, {
            method: "GET",
            headers: {
              "Content-type": "application/json",
              Accept: "text/event-stream"
            },
            openWhenHidden: true,
            onopen: (e) => {
              //接口请求成功,但此时数据还未响应回来
            },
            onmessage: (event) => {
             //响应数据持续数据 
            },
            onclose: () => {
             //请求关闭
            },
            onerror: () => {
              //请求错误
            }
          })

  2. MarkdownIt :SSE响应的数据格式是markdown,无法直接展示,需要使用MarkdownIt第三方库转换成html,然后通过v-model展示到页面上。

        // 1、新建实例md:
        const md = new MarkdownIt()
        // 2.将markdown转化为html
        const htmlStr= md.render(markdownStr)

  3. Clipboard+html-to-text:复制时,需要使用html-to-text第三方库将html转化为text,然后借助Clipboard复制到粘贴板上。

    //1.在html中设置“copy”类名,并绑定data-clipboard-text
     
    
    //2.先将html转化成text,然后复制到粘贴板
    const copyFn = (copyHtmlStr) => {
         copyText.value=htmlToText(copyHtmlStr)
          const clipboard = new Clipboard(".copy")
          // 成功
          clipboard.on("success", function (e) {
            ElMessage.success("复制成功")
            e.clearSelection()
            // 释放内存
            clipboard.destroy()
          })
          // 失败
          clipboard.on("error", function (e) {
            ElMessage.error("复制失败")
            clipboard.destroy()
          })
        }

  4. scrollEvent:由于数据流式输出,页面内容持续增加,可能会溢出屏幕,因此需要在fetchEventSource接收消息onmessage的过程中,通过设置scrollTop =scrollHeight让页面实现自动滚动。

     fetchEventSource(url, {
            ...,
            onmessage: (event) => { 
             chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight
            },
            ...
          })

四:疑难点及解决方案:

1.  问题描述:当页面请求fetchEventSource已发出时,切换url到其他网站再切换回来到这个页面时,fetchEventSource会重复请求,导致这两次请求的内容重复。

     解决方案:设置openWhenHidden为true,表示当页面退至后台时仍保持连接,默认值为false

2.  问题描述:前端调用AbortController的abort()方法取消请求时,只有第一次取消生效,当重新请求时,再次点击停止按钮不生效。

    解决方案:每请求一次创建一个新的AbortController()实例,因为AbortController实例的abort()方法被设计为只能调用一次来取消请求,一旦调用了abort(),与AbortController相关的AbortSigal的aborted属性就会被设置成true,表示请求已取消,当再次调用abort()不会有任何效果。

人工智能之web前端开发(deepSeek与文心一言结合版)_第2张图片

3.  问题描述:当在fetchEventSource的onmessage中设置scrollTop =scrollHeight时,在生成问题的过程中无法向上滚动,但业务想要边生成边滚动查看。

    解决方案:监听鼠标滚轮事件,在设置scrollTop =scrollHeight时添加判断,如果鼠标滚轮滑动且未到页面底部,则不自动滚动。

const isRolling = ref(false) //鼠标滚轮是否滚动
const isBottom = ref(false) //滚动参数

// 处理鼠标滚轮事件
const moveWheel1 = ref(true)
const moveWheel2 = ref(false)
const wheelClock = ref()
const stopWheel=()=> {
      if (moveWheel2.value == true) {
        moveWheel2.value = false
        moveWheel1.value = true
      }
    }
const moveWheel=()=> {
      if (moveWheel1.value == true) {
        isRolling.value = true
        moveWheel1.value = false
        moveWheel2.value = true
        //这里写开始滚动时调用的方法
        wheelClock.value = setTimeout(stopWheel, 200)
      } else {
        clearTimeout(wheelClock.value)
        wheelClock.value = setTimeout(stopWheel, 150)
      }
    }
const sendFn=()=>{
     fetchEventSource(url, {
        ...,
        onmessage: (event) => { 
            if (isRolling.value === false) {
              chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight
              isRolling.value = false
            }
            if (isBottom.value) {
              isRolling.value = false
              chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight
            }
        },
        ...
      })
}

4.  问题描述:SSE响应回来的数据中表格样式未生效。

     解决方案:MarkdownIt第三方库将markdown转换成html时,部分样式会丢失,需使用github-markdown-css添加样式。

npm i github-markdown-css
import "github-markdown-css"

五.完整代码示例:

   index.vue:



   index.ts:

import "./index.scss"
import { defineComponent, ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue"
import { fetchEventSource } from "@microsoft/fetch-event-source"
import { ElMessageBox, ElMessage } from "element-plus"
import MarkdownIt from "markdown-it"
import "github-markdown-css"
import Clipboard from "clipboard"
import { htmlToText } from "html-to-text"
import type { ChatItem } from "../../../types/chat"
export default defineComponent({
  components: { },
  setup() {
    const inputValue = ref("")
    const preInputValue = ref("") //上一次查询输入内容,用于重新生成使用
    const isLoading = ref(false) //节流loading
    const loadingStatus = ref(false) //加载状态显示loading
    const chatList = ref([])
    const contentItems = ref("") //当前正在输出的数据流
    const chatContainerRef = ref()
    const isRegenerate = ref(false) //是否重新生成
    const controller = ref()
    const signal = ref()
    const copyText = ref("") //复制的文字
    const copyIndex = ref()
    const scrollTopShow = ref(false)
    const scrollBottomShow = ref(false)
    const isRolling = ref(false) //鼠标滚轮是否滚动
    const isBottom = ref(false) //滚动参数
    onMounted(() => {
      initFn()
      chatContainerRef.value.addEventListener("wheel", moveWheel)
      window.addEventListener("message", function (event) {
        // 处理接收到的消息
        if (event.data && event.data.message) {
          inputValue.value = event.data.message
          sendFn()
        }
      })
    })
    // 处理鼠标滚轮事件
    const moveWheel1 = ref(true)
    const moveWheel2 = ref(false)
    const wheelClock = ref()
    function stopWheel() {
      if (moveWheel2.value == true) {
        // console.log("滚轮停止了")
        // isRolling.value = false
        moveWheel2.value = false
        moveWheel1.value = true
      }
    }
    function moveWheel() {
      if (moveWheel1.value == true) {
        // console.log("滚动了")
        isRolling.value = true
        moveWheel1.value = false
        moveWheel2.value = true
        //这里写开始滚动时调用的方法
        wheelClock.value = setTimeout(stopWheel, 200)
      } else {
        clearTimeout(wheelClock.value)
        wheelClock.value = setTimeout(stopWheel, 150)
      }
    }

    //初始化
    const initFn = () => {
      chatList.value = []
    }
    //上一页
    const preFn = (index: number, answerIndex: number) => {
      if (isLoading.value) return ElMessage.error("正在生成内容,请勿切换。")
      if (answerIndex === 0) {
        chatList.value[index].answerIndex = chatList.value[index].message.length - 1
      } else {
        chatList.value[index].answerIndex = chatList.value[index].answerIndex - 1
      }
    }
    //下一页
    const nextFn = (index: number, answerIndex: number) => {
      if (isLoading.value) return ElMessage.error("正在生成内容,请勿切换。")
      if (answerIndex === chatList.value[index].message.length - 1) {
        chatList.value[index].answerIndex = 0
      } else {
        chatList.value[index].answerIndex = chatList.value[index].answerIndex + 1
      }
    }
    // 1、新建实例md:
    const md = new MarkdownIt()
    const currentHTML = computed(() => {
      // 先判断存不存在,因为一开始currentPost有可能是undefined,在没有拿回数据的时候。
      if (contentItems.value) {
        if (contentItems.value.includes("")) {
          const arr = contentItems.value.split("")
          const thinkStr = `
          

师爷模型深度思考中...

${arr[0]}
` return thinkStr + md.render(arr[1]) } else { const thinkStr = `

师爷模型深度思考中...

${contentItems.value}
` return thinkStr } } }) //发送问题调用接口 const sendFn = () => { showList.value = false controller.value = new AbortController() signal.value = controller.value.signal //先判断inputStr有没有值,isRegenerate表示是否重新生成 const inputStr = isRegenerate.value ? preInputValue.value : inputValue.value if (!inputStr) return ElMessage.error("请输入要查询的问题。") if (isLoading.value) return ElMessage.error("正在生成内容,请稍后。") isLoading.value = true if (!isRegenerate.value) { //第一次生成 chatList.value.push({ type: "user", message: [inputStr], answerIndex: 0, isLoading: false, reportFlag: null }) } loadingStatus.value = true const url = `/recheck-web/open/shiye/chat?message=${inputStr}` fetchEventSource(url, { method: "GET", headers: { "Content-type": "application/json", Accept: "text/event-stream" }, signal: signal.value, openWhenHidden: true, // params: JSON.stringify({ message: inputStr }), onopen: (e) => { if (e.status === 500) return ElMessage.error("服务器忙,请稍后再试。") if (isRegenerate.value) { //重新生成 chatList.value[chatList.value.length - 1].message.push("") chatList.value[chatList.value.length - 1].answerIndex = chatList.value[chatList.value.length - 1].message.length - 1 } else { chatList.value.push({ type: "ai", message: [], answerIndex: 0, isLoading: true }) preInputValue.value = inputValue.value } chatList.value[chatList.value.length - 1].isLoading = true inputValue.value = "" isLoading.value = true loadingStatus.value = false }, onmessage: (event) => { const data = JSON.parse(event.data) const newItem = data ? data.content : "" contentItems.value = contentItems.value + newItem if (data.status !== "end") { } else { if (isRegenerate.value) { //重新生成 chatList.value[chatList.value.length - 1].message[ chatList.value[chatList.value.length - 1].message.length - 1 ] = currentHTML.value + "" } else { //第一次生成 chatList.value[chatList.value.length - 1].message.push(currentHTML.value + "") } if (data.type) { chatList.value[chatList.value.length - 1].reportFlag = data.type } } nextTick(() => { if (isRolling.value === false) { chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight isRolling.value = false } if (isBottom.value) { isRolling.value = false chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight } }) }, onclose: () => { isLoading.value = false loadingStatus.value = false chatList.value[chatList.value.length - 1].isLoading = false contentItems.value = "" }, onerror: () => { isLoading.value = false loadingStatus.value = false chatList.value[chatList.value.length - 1].isLoading = false contentItems.value = "" } }) } //停止生成 const stopFn = () => { isLoading.value = false loadingStatus.value = false controller.value.abort() //chatList最后一项 const lastChatItem = chatList.value[chatList.value.length - 1] if (isRegenerate.value) { // lastChatItem.message[lastChatItem.message.length - 1] = md.render(contentItems.value + "\n" + "\n" + "停止生成") lastChatItem.message[lastChatItem.message.length - 1] = currentHTML.value + "
停止生成
" } else { // lastChatItem.message.push(md.render(contentItems.value + "\n" + "\n" + "停止生成")) lastChatItem.message.push(currentHTML.value + "
停止生成
") } contentItems.value = "" lastChatItem.isLoading = false } //重新生成 const regenerateFn = () => { isRegenerate.value = true sendFn() } //发送 const inputBlurFn = (event: any) => { if (!event.ctrlKey) { // 如果没有按下组合键ctrl,则会阻止默认事件 event.preventDefault() isRegenerate.value = false sendFn() } else { // 如果同时按下ctrl+回车键,则会换行 inputValue.value += "\n" } } //复制功能 const copyFn = (index: number, answerIndex: number) => { copyIndex.value = index copyText.value = htmlToText(chatList.value[index].message[answerIndex]) const clipboard = new Clipboard(".copy") // 成功 clipboard.on("success", function (e) { ElMessage.success("复制成功") e.clearSelection() // 释放内存 clipboard.destroy() }) // 失败 clipboard.on("error", function (e) { ElMessage.error("复制失败") clipboard.destroy() }) } //试问 const askFn = (question: string) => { inputValue.value = question isRegenerate.value = false sendFn() } //滚动事件 const scrollEvent = (e: any) => { //如果滚动到底部,显示向上滚动按钮 //如果滚动到顶部,显示向下滚动按钮 const scrollTop = e.target.scrollTop const scrollHeight = e.target.scrollHeight const offsetHeight = Math.ceil(e.target.getBoundingClientRect().height) const currentHeight = scrollTop + offsetHeight if (currentHeight >= scrollHeight) { scrollTopShow.value = true isBottom.value = true } else { isBottom.value = false scrollTopShow.value = false } if (scrollHeight > offsetHeight) { scrollBottomShow.value = true } else { scrollBottomShow.value = false } } //向上滚动 const scrollTopFn = () => { chatContainerRef.value.scrollTop = 0 } //向下滚动 const scrollBottomFn = () => { chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight + 250 } //下载尽调报告 const downloadReport = (index: number, answerIndex: number) => { const downStr = chatList.value[index].message[answerIndex] const arr = downStr.split("
") const blob = new Blob([arr[1]], { type: "text/plain" }) const link = document.createElement("a") link.href = URL.createObjectURL(blob) link.download = "尽调报告.docx" link.click() } const generateReport = (question: string) => { inputValue.value = question isRegenerate.value = false sendFn() } const viewDialogRef = ref() const viewFn = () => { viewDialogRef.value.dialogVisible = true } watch( () => chatList.value, () => { if (chatList.value && chatList.value.length > 0) { chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight + 250 } }, { deep: true } ) onUnmounted(() => { if (isLoading.value) { isLoading.value = false loadingStatus.value = false controller.value.abort() } }) return { inputValue, isLoading, chatList, preFn, nextFn, sendFn, contentItems, stopFn, preInputValue, currentHTML, chatContainerRef, regenerateFn, inputBlurFn, copyFn, copyText, askFn, loadingStatus, copyIndex, downloadReport, generateReport, showList, scrollEvent, scrollTopFn, scrollBottomFn, scrollTopShow, scrollBottomShow, viewDialogRef, viewFn } } })

   index.scss:

.report-page {
  height: 100%;
  width: 1201px;
  margin: 20px calc((100% - 1201px) / 2 - 35px) 20px calc((100% - 1201px) / 2 + 35px);
  .chat-container::-webkit-scrollbar {
    width: 0; /* 对于垂直滚动条 */
  }
  .chat-container {
    height: 85vh;
    overflow-y: auto;
    padding-bottom: 170px;
    margin-top: 0px;
    box-sizing: border-box;
    .chat-list-container {
      margin-top: 30px;
      .chat-item {
        display: flex;
        margin-bottom: 20px;
        .avatar {
          width: 40px;
          height: 40px;
        }
        .question {
          display: flex;
          margin-bottom: 30px;
          margin-left: 70px;
          margin-right: 10px;
          .message {
            padding: 12px 10px 10px;
            border-radius: 14px 0px 14px 14px;
            background: linear-gradient(128deg, #4672ff -1.27%, #7daafc 109.62%);
            color: #fff;
          }
          .avatar {
            margin-left: 20px;
          }
        }
        .answer-container {
          margin-right: 70px;
          margin-bottom: 30px;
          .answer {
            display: flex;
            .avatar-page {
              width: 70px;
              position: relative;
              .avatar {
                width: 60px;
                height: 60px;
                margin-right: 10px;
              }

              .page-container {
                position: absolute;
                top: 60px;
                left: 3px;
                color: #000;
                font-family: "PingFang SC";
                font-size: 14px;
                font-style: normal;
                font-weight: 400;
                line-height: 24px;
                .pre-page {
                  margin-right: 1px;
                  cursor: pointer;
                }
                .next-page {
                  margin-left: 1px;
                  cursor: pointer;
                }
              }
            }
            .answer-message {
              background-color: #fff;
              padding: 20px;
              border-radius: 0 14px 14px 14px;
              min-width: 500px;
              .download-btn {
                color: #333;
                text-align: center;
                font-family: "PingFang SC";
                font-size: 14px;
                font-style: normal;
                font-weight: 400;
                line-height: 14px;
                background-color: #f2f3f8;
                height: 34px;
                margin-top: 20px;
                border-radius: 6px;
                border-color: transparent;
                .icon-img {
                  width: 17px;
                  height: 17px;
                  margin-right: 5px;
                }
                &:hover {
                  background: linear-gradient(128deg, #4672ff -1.27%, #7daafc 109.62%);
                  color: #fff;
                }
              }
            }
          }
          .btn-container {
            margin-left: 80px;
            margin-top: 18px;
            text-align: left;
            display: flex;
            justify-content: space-between;
            .opt-container {
              .stop-btn,
              .regenerate-btn {
                cursor: pointer;
                color: #57f;
                font-family: "PingFang SC";
                font-size: 14px;
                font-style: normal;
                font-weight: 500;
                line-height: 24px;
              }
            }
            .tool-container {
              background-color: #fff;
              padding: 6px 10px 8px;
              height: 40px;
              border-radius: 20px;
              min-width: 70px;
              text-align: center;
              .copy {
                width: 28px;
                height: 28px;
                cursor: pointer;
              }
              .copy-acive {
                color: #5577ff;
              }
            }
          }
        }
      }
    }
    .user {
      flex-direction: row-reverse;
    }
    .loading-status {
      display: flex;
      .avatar {
        width: 60px;
        height: 60px;
        margin-right: 20px;
      }
      .think {
        height: 52px;
        line-height: 52px;
        background-color: #fff;
        text-align: center;
        border-radius: 0 14px 14px 14px;
        width: 100px;
        color: #999;
      }
      .loading-img {
        width: 40px;
        height: 40px;
      }
    }
    .scroll-container {
      width: 38px;
      height: 38px;
      background-color: #fff;
      border-radius: 19px;
      display: flex;
      justify-content: center;
      align-items: center;
      position: fixed;
      left: calc((100% - 1201px) / 2 + 175px + 1061px);
    }
    .scroll-top {
      bottom: 200px;
    }
    .scroll-bottom {
      top: 53px;
    }
  }
  .input-container {
    position: fixed;
    left: calc((100% - 1061px) / 2 + 35px);
    bottom: 5%;
    width: 1061px;
    .stop-container {
      cursor: pointer;
      width: 104px;
      height: 36px;
      background-color: #fff;
      color: #5863ff;
      line-height: 36px;
      text-align: center;
      border-radius: 18px;
      position: absolute;
      top: -50px;
      font-size: 14px;
      font-style: normal;
      font-weight: 400;
      .stop-img {
        width: 22px;
        height: 22px;
        vertical-align: middle;
        margin-right: 3px;
      }
    }
    .input {
      .el-textarea__inner {
        padding: 15px;
        border-radius: 14px;
        box-shadow: 14px 27px 45px 0px rgba(112, 144, 176, 0.2);
      }
      .el-textarea__inner::-webkit-scrollbar {
        width: 6px;
        height: 6px;
      }
      .el-textarea__inner::-webkit-scrollbar-thumb {
        border-radius: 3px;
        -moz-border-radius: 3px;
        -webkit-border-radius: 3px;
        background-color: #c3c3c3;
      }
      .el-textarea__inner::-webkit-scrollbar-track {
        background-color: transparent;
      }
    }
    .icon-container {
      position: absolute;
      right: 10px;
      bottom: 35px;
      z-index: 999;
      .loading-icon {
        width: 40px;
        height: 40px;
      }
      .send-icon {
        width: 35px;
        height: 35px;
      }
    }
  }
}
.markdown-body {
  box-sizing: border-box;
  max-width: 1021px !important;
  hr {
    display: none !important;
  }
}

你可能感兴趣的:(前端,chatgpt,文心一言,人工智能)