vue2 FastGTP 通过openApi 在企微实现“智能客服”功能

代码目录:

企业微信截图_17407272465989.png
代码

文件 index.vue

<template>
  <div class="page">
    <section class="page_main" ref="mainChat">
      <ul class="msg_list">
        <msgItem
          v-for="(info, i) in chatList"
          :key="i"
          :role="info.role"
          :avatar="info.avatar"
          :content="info.content"
          :direction="info.direction">
        </msgItem>
        <msgItem
          v-if="streaming"
          :streaming="true"
          :role="defaultInfo.role"
          :avatar="defaultInfo.avatar"
          :content="streamingText"
          :direction="'left'">
        </msgItem>
      </ul>
    </section>
    <section class="page_footer">
      <div class="msg_type"
        :class="{ audio: isDudio }"></div>
      <div class="msg_cont"
        :class="{ audio: isDudio }">
        <input class="msg_inp"
          v-show="!isDudio"
          v-model="text"
          type="text"
          :disabled="disabled"
          placeholder="请输入信息"
          @keydown.enter="sendMsg">
        <p class="msg_voice"
          v-show="isDudio">按住说话</p>
      </div>
      <div class="msg_send" @click="sendMsg()"></div>
    </section>
  </div>
</template>
<script>
import msgItem from './components/msgItem.vue'
import { StreamGpt, Typewriter } from './scripts'
const Avatar = require('../../assets/img/ai_avatar.png')
export default {
  components: {
    msgItem
  },
  data () {
    return {
      // 默认信息
      defaultInfo: {
        role: 'Ma机器人',
        avatar: Avatar
      },
      // 应用相关信息
      appId: 'YOUR_APPID',
      apikey: 'YOUR_API_KEY',
      // 当前用户信息用于展示
      clientInfo: {
        id: 1234, // 用户id,唯一的,用于chatId的参数值
        wechat_name: '八妹',
        avatar: 'https://wework.qpic.cn/bizmail/2yFdlfjdoBiayEEmBImMTPsd029CEibuDhlH1mTL9ibDINuWNXJDeQbWg/0'
      },
      // 消息相关
      isDudio: false, // 消息是否是语音
      text: '', // 消息
      chatList: [],
      // 输入框是否禁用
      disabled: false,
      // 初始化数据
      typewriter: null,
      gpt: null,
      streamingText: '',
      streaming: false,
      history: false
    }
  },
  methods: {
    sendMsg () {
      if (this.disabled) {
        return
      }
      if (this.text === '' || /^\s+$/.test(this.text)) {
        this.$toast('请输入信息')
        return
      }
      this.disabled = true
      this.gpt.stream(
        this.text,
        this.clientInfo.id,
        this.history ? this.chatList : undefined
      )
    },
    scrollToBottom () {
      this.$nextTick(() => {
        setTimeout(() => {
          const mainChat = this.$refs.mainChat
          mainChat.scrollTop = mainChat.scrollHeight
        }, 0)
      })
    },
    init () {
      this.typewriter = new Typewriter((str) => {
        this.streamingText += str || ''
        // console.log('str', str)
      })
      this.gpt = new StreamGpt(this.apikey, {
        onStart: (prompt) => {
          // console.log('onStart', prompt)
          this.streaming = true
          /* eslint-disable */
          this.chatList.push({
            role: this.clientInfo?.wechat_name || '',
            avatar: this.clientInfo?.avatar || '',
            content: prompt,
            direction: 'right'
          })
          this.text = ''
          this.scrollToBottom()
        },
        onPatch: (text) => {
          // console.log('onPatch', text)
          this.typewriter.add(text)
        },
        onCreated: () => {
          // console.log('onCreated')
          this.typewriter.start()
        },
        onDone: () => {
          // console.log('onDone')
          this.typewriter.done()
          this.streaming = false
          this.chatList.push({
            role: this.defaultInfo.role,
            avatar: this.defaultInfo.avatar,
            content: this.streamingText,
            direction: 'left'
          })
          this.streamingText = ''
          this.disabled = false
        }
      })
    }
  },
  watch: {
    streamingText () {
      this.scrollToBottom()
    }
  },
  created () {
    this.init()
  },
  beforeDestroy () {
    if (this.gpt) {
      this.gpt = null
    }
    if (this.typewriter) {
      this.typewriter = null
    }
  }
}
</script>
<style lang="stylus" scoped>
.page
  width 100%
  height 100vh
  background: #f6f6f6
  .page_main
    width 100%
    height calc(100% - 50px)
    overflow-y: auto
    overflow-x: hidden
    .msg_list
      width 100%
      padded_box(border-box, 15px)
  .page_footer
    width 100%
    height 50px
    background: #fff
    display: flex
    justify-content: center
    align-items: center
    padded_box(border-box, 6px 20px)
    box-shadow: 0 -1px 10px 0 rgba(51,80,138,0.06);
    .msg_type
      width 38px
      height 38px
      background: url('~assets/img/chat_audio.png') no-repeat center/30px
      &.audio
        background-image: url('~assets/img/chat_text.png')
        background-size 28px
    .msg_cont
      flex 1
      height: 100%
      &.audio
        background: #f0f1f5
        border-radius: 8px
        margin: 0 4px
      .msg_inp
        width 100%
        height 100%
        padded_box(border-box, 9px 8px)
      .msg_voice
        width 100%
        height 100%
        line-height: 38px
        text-align: center
    .msg_send
      width 38px
      height 38px
      background: url('~assets/img/chat_send.png') no-repeat center/30px
</style>

文件components/msgItem.vue

<template>
    <li class="msg_item"
        :class="direction">
        <div class="msg_item_box">
      <div class="avatar">
        <img :src="avatar" alt="">
      </div>
      <div class="msg_wrap">
        <p class="name">{{role}}</p>
        <div class="msg_cont">
          <div class="content"
            ref="contRef"
            v-html="mkHtml">
          </div>
        </div>
      </div>
    </div>
    </li>
</template>
<script>
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
const md = new MarkdownIt({
  highlight: function (str, lang) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return `<div class="hl-code"><div class="hl-code-header"><span>${lang}</span></div><div class="hljs"><code>${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
        }</code></div></div>`
      } catch (__) {
        console.log(__, 'error')
      }
    }
    return `<div class="hl-code"><div class="hl-code-header"><span>${lang}</span></div><div class="hljs"><code>${md.utils.escapeHtml(
      str
    )}</code></div></div>`
  }
})
export default {
  props: {
    role: String,
    avatar: {
      type: String,
      default: ''
    },
    content: {
      type: String,
      default: ''
    },
    streaming: {
      type: Boolean,
      default: false
    },
    direction: {
      type: String,
      default: ''
    }
  },
  data () {
    return {}
  },
  computed: {
    mkHtml: function () {
      // 发送内容
      if (this.direction === 'right') {
        return this.content
      }
      // 回复内容
      // console.log('this.content', this.content)
      const htmlContent = md.render(this.content)
      this.$nextTick(() => {
        if (this.streaming) {
          const parent = this.$refs.contRef
          if (!parent) return
          let lastChild = parent.lastElementChild || parent
          if (lastChild.tagName === 'PRE') {
            lastChild = lastChild.getElementsByClassName('hljs')[0] || lastChild
          }
          if (lastChild.tagName === 'OL') {
            lastChild = this.findLastElement(lastChild)
          }
          lastChild.insertAdjacentHTML('beforeend', `<span class="input-cursor"></span>`)
          // console.log('追加光标')
        }
      })
      return htmlContent
    }
  },
  methods: {
    findLastElement (element) {
      if (!element.children || !element.children.length) {
        return element
      }
      const lastChild = element.children[element.children.length - 1]
      if (lastChild.nodeType === Node.ELEMENT_NODE) {
        return this.findLastElement(lastChild)
      }
      return element
    }
  }
}
</script>
<style lang="stylus" scoped>
.msg_item
  width 100%
  margin-bottom 20px
  &.right
    .msg_item_box
      flex-direction: row-reverse
      .msg_wrap
        display: flex
        flex-direction: column
        align-items flex-end
        .msg_cont
          .content
            background: #5A77FF
            color #fff
            border-radius: 8px 0 8px 8px
  .msg_item_box
    display: flex
    align-items flex-start
    .avatar
      width 40px
      height 40px
      border-radius: 50%
      overflow: hidden
      img
        width 100%
    .msg_wrap
      flex: 1
      margin 0 10px
      .name
        line-height: 1.2;
        color: #848484;
        font-size: 13px;
        margin-bottom: 5px;
      .msg_cont
        max-width 88%
        .content
          width 100%
          background: #fff
          border-radius: 0 8px 8px
          padded_box(border-box, 12px 16px)
          line-height 1.4
          font-size 15px
          color #333333
          >>>.input-cursor
            display: inline-block;
            height: 16px;
            margin-bottom: -3px;
            width: 2px;
            animation: blink 1s infinite steps(1, start)
          /*这里设置动画blink*/
          @keyframes blink {

            0%,
            100% {
              background-color: #000;
              color: #aaa;
            }

            50% {
              background-color: #bbb;
              /* not #aaa because it's seem there is Google Chrome bug */
              color: #000;
            }
          }
</style>

文件scripts/index.js

/**
 * 打字机
 */
export class Typewriter {
  constructor (onConsume) {
    this.queue = []
    this.consuming = false
    this.timmer = null
    this.onConsume = onConsume
  }

  // 消费间隔
  dynamicSpeed () {
    const speed = 2000 / this.queue.length
    if (speed > 200) {
      return 200
    } else {
      return speed
    }
  }
  // 添加队列
  add (str) {
    if (!str) return
    this.queue.push(...str.split(''))
  }
  // 消费队列
  consume = () => {
    if (this.queue.length > 0) {
      const str = this.queue.shift()
      str && this.onConsume(str)
    }
  }
  // 消费下一个
  next = () => {
    this.consume()
    this.timmer = setTimeout(() => {
      this.consume()
      if (this.consuming) {
        this.next()
      }
    }, this.dynamicSpeed())
  }
  // 开始进入队列
  start = () => {
    this.consuming = true
    this.next()
  }
  // 队列结束
  done = () => {
    this.consuming = false
    clearTimeout(this.timmer)
    this.onConsume(this.queue.join(''))
    this.queue = []
  }
}

/**
 * 解析数据
 */
const parsePack = (str) => {
  const pattern = /data:\s*({.*?})\s*\n/g
  const result = []
  let match
  while ((match = pattern.exec(str)) !== null) {
    const jsonStr = match[1]
    try {
      const json = JSON.parse(jsonStr)
      result.push(json)
    } catch (e) {
      console.log(e)
    }
  }
  return result
}

// 获取流式数据
export class StreamGpt {
  constructor (key, options) {
    const { onStart, onCreated, onDone, onPatch } = options
    this.key = key
    this.onStart = onStart
    this.onCreated = onCreated
    this.onDone = onDone
    this.onPatch = onPatch
  }

  async stream (prompt, chatId = null, history = [], preset = []) {
    let finish = false
    let count = 0
    const _history = [...history]
    this.onStart(prompt)
    const res = await this.fetch(chatId, [
      ...preset,
      ..._history,
      {
        role: 'user', // role的值必须是'user'
        content: prompt
      }
    ])
    if (!res.body) {
      return
    }
    const reader = res.body.getReader()
    const decoder = new TextDecoder()
    while (!finish) {
      const { done, value } = await reader.read()
      if (done) {
        finish = true
        this.onDone()
        break
      }
      count++
      const jsonArray = parsePack(decoder.decode(value))
      if (count === 1) {
        this.onCreated()
      }
      jsonArray.forEach((json) => {
        if (!json.choices || json.choices.length === 0) {
          return
        }
        const text = json.choices[0].delta.content
        this.onPatch(text)
      })
    }
  }

  async fetch (chatId, messages) {
    /* eslint-disable */
    let url = `https://api.openai.com/v1/chat/completions`
    return await fetch(url, {
      method: 'POST',
      body: JSON.stringify({
        messages,
        chatId,
        stream: true,
        detail: true
      }),
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${this.key}`
      }
    })
  }
}
图标
ai_avatar.png

chat_audio.png

chat_send.png

chat_text.png
踩坑

需要使用插件markdown-it和11.11.1实现markdown文本渲染,(插曲:vue版本是V4.4.6 使用v14.1.0版本的markdown-it报错,后面降低markdown-it版本为v13.0.2解决)

参考文献:
最终效果:
image.png
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容