干翻全岛蛙蛙 发表于 2025-3-12 13:28:10

webscoket 渲染AI返回流式 markdown 格式数据

背景

        去年的时候做了一个对接ai 模块的业务,当时因为那个模块说的返回的都是文章纯文本,所以没有分析markdown,今年来了就弄一个PC H5的ai版本 本文章重要就写 写这次ai的心路进程。
重要就三个问题
1. 对话框
2. websocket 发消息/接消息
3. markdown 的渲染
OK 开始
重要是写在vue里面的  去年的是写在blade模板里面的,但是思路都大致差不多
页面结构这个样子
https://i-blog.csdnimg.cn/direct/4c222d9887754a7bbb981609be008dcc.png
问答完这样 
https://i-blog.csdnimg.cn/direct/f0060799c9e94aa4a045cf251ec90ace.png
和市面上的AI 问答基本上差不多(测试环境 请忽略思考用时)
渲染地区分为思考的地区 和 正式回答的地区,因为之前的时候是在php里面写的blade模板,所以利用的innerHtml 现在是在vue里面所以利用v-html 关于这俩后面还有个问题,后面再提,
基础页面都很好处理 重要就是textarea 的问题 下面是处理好的textarea结构
<div :class="['footer', focusText ? 'borderi' : '']">
<div class="chat-input">
    <span>{{ message }}</span>
    <textarea
      ref="textarea"
      v-model="message"
      inputmode="text"
      placeholder="试试输入关键词引导AI生成点评 shift + enter 换行"
      rows="1"
      @keydown.enter.exact.prevent="sendMessage"
      @keydown.shift.enter.prevent="insertLineBreak"
      @blur="blurTeaxarea"
      @focus="focusTeaxarea"
    />
    <div class="uplaod" @click="sendMessage">
      <img
      :style="{ cursor: message ? 'pointer' : 'not-allowed' }"
      :src="
          message
            ? require('@/assets/images/ai-submit-active.png')
            : require('@/assets/images/ai-submit.png')
      "
      alt=""
      >
    </div>
</div>
</div>
js部门
sendMessage() {
if (this.isComposing) return // 中文输入法组合期间不发送

if (this.message == '' && !this.fileList.length) {
    this.$message.warning('请输入后提问')
    return
}
// 判断wss 的连接状态
this.$refs.textarea.blur()
this.focusText = false
this.state = 2
this.startChat()
},
insertLineBreak(event) {
if (this.message.trim() == '') {
    this.$message.warning('请输入后再换行')
    event && event.preventDefault()
    return
}
document.execCommand('insertLineBreak') // 更好的换行兼容性
this.updateMessage()
},

this.$refs.textarea.addEventListener(
'compositionstart',
this.handleComposition
)
this.$refs.textarea.addEventListener(
'compositionend',
this.handleComposition
)
// 中文输入法状态处理
handleComposition(e) {
this.isComposing = e.type !== 'compositionend'
},
insertLineBreak 重要是处理huan
css样式 原来UI图是渐变的边框但是和border-radius 冲突了,这里就不写怎么实现的了
.footer {
      margin: 10px 0;
      border: 1px solid #f0f1f4;
      border-radius: 10px;
      overflow: hidden;
      padding: 6px 10px;
      .chat-input {
      position: relative;
      display: flex;
      align-items: flex-end;
      background: #ffffff;
      max-height: 200px;
      span {
          display: block;
          min-height: 42px;
          white-space: pre-wrap;
          word-wrap: break-word;
          visibility: hidden;
          padding: 10px;
          width: calc(100% - 62px);
          box-sizing: border-box;
          font-size: 14px;
          line-height: 1.5;
      }
      textarea {
          font-size: 14px;
          width: calc(100% - 62px);
          background: #ffffff;
          padding: 10px;
          border: none;
          box-sizing: border-box;
          resize: none;
          overflow-y: auto;
          scrollbar-width: none; /* 隐藏滚动条(Firefox) */
          -ms-overflow-style: none; /* 隐藏滚动条(IE 10+) */
          resize: none;
          height: 100%;
          position: absolute;
          left: 0;
          top: 0;
          line-height: 1.5;
          &::-webkit-scrollbar {
            display: none;
          }
      }
      textarea::placeholder {
          color: #999;
          text-align: left;
          // text-align: center; /* 水平居中 */
          vertical-align: middle; /* 垂直居中 */
      }
      .uplaod {
          // width: 42px;
          height: 42px;
          z-index: 1;
          background: #fff;
          display: flex;
          align-items: center;
          cursor: pointer;
      }
      img {
          width: 60px;
          height: 36px;
      }
      }
    }
    .borderi {
      border-color: #ff9327;
      // border-image: linear-gradient(0deg, #FF9327 0%, #FF5627 100%) 1;
      border-radius: 10px;
      // border-radius: 20px;
      // background-clip: padding-box, border-box;
      // background-origin: padding-box, border-box;
      // background-image: linear-gradient(to right, #222, #222),
      //   linear-gradient(90deg, #8f41e9, #578aef);
    }  关于websocket ,当初想的是想用sse的但是 小程序不支持sse所以后面都选websocket了,
initWebsocket(msg) {
// 初始化 WebSocket 连接
this.websocket = new WebSocket(process.env.VUE_APP_WEBSOCKETBASE_URL)
// 连接成功建立的回调方法
const cb = () => {
    this.websocket.send(JSON.stringify(msg))
}
this.websocket.addEventListener('open', () => {
    let content
    if (this.sessionId) {
      const obj = {}
      obj.sessionId = this.sessionId
      obj.token = getToken()
      content = obj
    } else {
      content = {
      sessionId: null,
      token:getToken()
      }
    }
    const msgToken = { type: 'auth', content: content }
    this.websocket.send(JSON.stringify(msgToken))
    this.callbackWebSocket(cb)
})
},
callbackWebSocket(cb) {
// 收到消息事件
this.websocket.onmessage = (msg) => {
    // 如果返回对象里面有结束标志的话 就把对象存起来
    const res = JSON.parse(msg.data)
    // console.log("msg", msg);

    if (res.type == 'connected') {
      // 开启会话,返回websocketid
      if (cb) {
      cb()
      }
      this.qaData.socketId = res.content
      this.socketId = res.content
      //
    } else if (res.type == 'new_session') {
      // 创建会话,返回会话id、title
      //
      // if (res.content.length > 19) {
      const content = JSON.parse(res.content)
      this.qaData.sessionId = content.sessionId
      this.sessionId = content.sessionId
      this.qaData.id = content.id
      // }
    } else if (res.type == 'finish' || res.type == 'stop') {
      // 有可能超时
      this.$set(this.qaData.data.answer, 'id', res.content)
      // 停止或回答完
      this.doingAnswer = false
      this.$set(this.qaData.data.answer, 'status', 2)
      const loading = document.querySelector('#loading')
      if (loading && loading.parentNode) {
      loading.parentNode.removeChild(loading)
      }
      this.scrollTop = false
      this.scrolltoBottom()
    } else if (res.type == 'answer') {
      // 答案
      if (res.thinking == 1) {
      this.$set(this.qaData.data.answer, 'thinkFlag', true)

      // 计算时间
      this.thinkingTime = +new Date()
      this.thinking = true
      this.$forceUpdate()
      } else if (res.thinking == 3) {
      // 计算时间
      const thinkingTime = Math.ceil(
          (+new Date() - this.thinkingTime) / 1000
      )
      this.$set(
          this.qaData.data.answer,
          'thinkingTime',
          thinkingTime
      )
      this.thinking = false
      console.log(2)
      } else if (res.thinking == 2) {
      // 正在思考中
      if (!this.qaData.data) return
      if (res.content.length) {
          const thinkContent =
            this.qaData.data.answer.thinkContent || ''
          this.$set(
            this.qaData.data.answer,
            'thinkContentCopy',
            thinkContent + res.content
          )
          this.$set(
            this.qaData.data.answer,
            'thinkContent',
            thinkContent + res.content
          )
          this.$forceUpdate()
          this.scrolltoBottom()
      }
      } else {
      if (!res.content) return
      const answerCopy =
          this.qaData.data.answer.answerCopy || ''
      this.$set(
          this.qaData.data.answer,
          'answerCopy',
          answerCopy + res.content
      )

      this.streamBuffer += res.content + ''
      const elImg = '!(/img/loading.gif)'
      this.rawMarkdown += this.streamBuffer
      console.log('this.rawMarkdown', this.rawMarkdown)

      const mdSring = this.md.render(this.rawMarkdown + elImg)
      this.$set(this.qaData.data.answer, 'answer', mdSring)
      this.$forceUpdate()

      this.streamBuffer = ''

      // 需要the-main 滚动到底部
      console.log(4)
      this.scrolltoBottom()
      }
    } else if (res.type == 'error') {
      console.log('error', res, this.indexUse)
      this.doingAnswer = false
      this.thinking = false
    } else if (res.type == 'timeout') {
      // 超时
      const answer = this.qaData.data.answer.answer || ''
      this.$set(
      this.qaData.data.answer,
      'answer',
      answer + res.content
      )
      this.$set(this.qaData.data.answer, 'status', 3)
      this.$refs['a' + this.indexUse].innerHTML = res.content || ''
      this.doingAnswer = false
      this.streamBuffer = ''
      this.rawMarkdown = ''
      this.scrolltoBottom()
    }
}
// 连接关闭事件
this.websocket.onclose = function () {
    console.log('Socket已关闭')
    this.doingAnswer = false
}
// 发生了错误事件
this.websocket.onerror = function () {
    // alert('Socket发生了错误')
}
},
startChat() {
const msg = { type: 'chat', content: q }
if (typeof WebSocket == 'undefined') {
    console.log('遗憾:您的浏览器不支持WebSocket')
    this.$message.warning('遗憾:您的浏览器不支持WebSocket')
} else {
    if (this.websocket && this.websocket.readyState == 1) {
      // 已经连接 了
      console.log('===', msg)

      this.websocket.send(JSON.stringify(msg))
    } else {
      this.initWebsocket(msg)
    }
}
}  如果有seesionId的话 就可以继承毗连websockt利用,如果状态不是毗连状态的话 就去重新毗连,对返回的数据类型 举行归纳重新渲染数据 ,我是把他存在了一个qaData里面 ,qaData的数据结构如下
this.qaData = {
socketId: '',
sessionId: item.sessionId,
id: item.id,
listIndex: index + 1,
data: {
    question: item.question,
    imageList,
    answer: {
      id: item.answerList && item.answerList.id,
      status: item.status,
      answer:
      item.answerList && this.md.render(item.answerList.answer),
      answerCopy: item.answerList && item.answerList.answer,
      thinkContent: item.answerList && item.answerList.think,
      thinkContentCopy: item.answerList && item.answerList.think,
      thumbsUp: item.answerList ? item.answerList.thumbsUp : null,
      thinkingTime:
      item.answerList &&
      item.answerList.thinkTime &&
      Math.ceil(item.answerList.thinkTime / 1000),
      thinkFlag: item.answerList && item.answerList.thinkTime,
    },
},  这样可以兼顾回显,新建和回显都用同一套数据结构,比较方便
第三点markdown的渲染
先说上面innerHtml和v-html  因为最开始的时候是利用的innerHtml 然后这个版本是利用的v-html,最开始写的时候 我没有直接改变v-html 绑定的值而是利用innerHtml改变的,然后就出现了渲染中如果往上滚动的话 markdown格式就是丢失,我查了之后才发现 如果改变v-html 的innerHtml的话 就会有渲染问题,
我利用了markdown-it 插件 但是 直接引入有问题,这个项目没支持.mjs 的引入,所以利用dist/index.js 或者直接把min.js 复制一份放到项目标静态资源里面 ,我看了两种方式的打包后的结果巨细基本都是一样的。
在利用Markdown-it之前先要做些设置特殊处理一些 样式,例如表格,毗连。。。
initMd() {
this.md = markdownit()
// 自定义代码块渲染器
this.md.renderer.rules.code_block = function (
    tokens,
    idx,
    options,
    env,
    self
) {
    const token = tokens
    const content = token.content
    const lang = token.info ? token.info.trim() : ''

    return `<div class="code-block"><pre><code class="language-${lang}" style="white-space: pre-wrap;">${content}</code></pre></div>`
}

this.md.renderer.rules.table_open = function () {
    return '<div class="table-wrapper" style="overflow:auto;"><table border>'
}

this.md.renderer.rules.table_close = function () {
    return '</table></div>'
}
this.md.renderer.rules.link_open = function (
    tokens,
    idx,
    options,
    env,
    self
) {
    // 获取链接的 href 和 title 属性
    const hrefIndex = tokens.attrIndex('href')
    const href = hrefIndex > -1 ? tokens.attrs : null
    const titleIndex = tokens.attrIndex('title')
    const title = titleIndex > -1 ? tokens.attrs : null

    // 返回一个禁止点击的链接
    return `<a href="${href}" class="no-href" data-link="a" title="${title}">`
}

this.md.renderer.rules.image = function (
    tokens,
    idx,
    options,
    env,
    self
) {
    const token = tokens
    const aIndex = token.attrIndex('src')
    if (token.content == 'diy-loading-img') {
      return `<img
          id="loading"
          src="/img/loading.gif"
          style="width:20px;height:20px;vertical-align: middle;margin:0;"
      />`
    } else {
      return `<img src="${token.attrs}" alt="${token.content}" />`
    }
}
} 天生表格的时候外面加了一层 div 防止表格过于宽 ,链接 加了一个禁止点击 根据data-link="a" 事件委托举行处理

然后每次返回的数据 拼接渲染就行了
this.streamBuffer += res.content + ''
const elImg = '!(/img/loading.gif)'
this.rawMarkdown += this.streamBuffer
console.log('this.rawMarkdown', this.rawMarkdown)

const mdSring = this.md.render(this.rawMarkdown + elImg)
this.$set(this.qaData.data.answer, 'answer', mdSring)
this.$forceUpdate() elImg 是每次文本后面的loading,this.forceUpdate() 因为是深层的对象,所以须要利用 逼迫渲染,大概就是这些,当然 还有更细节的处理方式

下面是一个对markdown流的处理
// import MarkdownIt from 'markdown-it/dist/markdown-it.js'

export class MarkdownStateMachine {
constructor() {
    this.state = {
      inCodeBlock: false,    // 是否在代码块内
      codeBlockLanguage: '', // 代码块语言
      inList: false,         // 是否在列表项中
      listDepth: 0,          // 列表嵌套深度
      inBlockquote: false,   // 是否在引用块中
      tableStage: 0,          // 表格解析阶段 (0: 未开始, 1: 表头, 2: 分隔线, 3: 表体)
      table: {
      stage: 0,
      header: [],
      body: [],
      buffer: []
      },
      activeBlock: null,
    };
    this.buffer = '';      // 原始未处理文本
    this.openBlocks = [];    // 未闭合块堆栈
    this.renderedHtml = '';// 已安全渲染的HTML
}
getMd(md) {
    this.md = md
}
// 更新状态机
updateState(chunk) {
    const lines = chunk.split('\n');
    lines.forEach(line => {
      if (this.state.inCodeBlock) {
      if (line.startsWith('```')) this.state.inCodeBlock = false;
      return; // 代码块内的内容不触发其他状态变化
      }

      if (line.startsWith('```')) {
      this.state.inCodeBlock = true;
      // this.state.codeBlockLanguage = line.replace('```', '').trim();
      // 处理代码块

      } else if (/^(\*|\-|\+)\s/.test(line)) {
      this.state.inList = true;
      this.state.listDepth = line.match(/^\s*/).length / 2; // 假设缩进为2空格
      } else if (line.startsWith('> ')) {
      this.state.inBlockquote = true;
      } else if (line.includes('|') && line.includes('-')) {
      this.state.tableStage = this.state.tableStage < 2 ? 2 : 3;
      this.state.table.stage = this.state.tableStage
      }
    });
}


// 判断当前缓冲区是否可安全渲染
isSafeToRender(buffer) {
    // if (this.state.inCodeBlock) return false;
    // if (this.state.tableStage % 2 !== 0) return false; // 表格需要成对渲染
    return true;
}

} isSafeToRender() 可以判断 现在正在举行的是什么表格 或者 代码块啦,这样 就可以根据具体的需求来处理,例如,天生表格的时候加一个处理进度什么之类的。
大概就是这些,每年都有提升,还不错,加油!

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: webscoket 渲染AI返回流式 markdown 格式数据