瀏覽代碼

ai助教 正式版

zhuf 1 年之前
父節點
當前提交
707c933575

+ 2 - 0
package.json

@@ -18,6 +18,7 @@
     "crypto-js": "^4.2.0",
     "element-plus": "^2.3.3",
     "file-saver": "^2.0.5",
+    "js-base64": "^3.7.6",
     "lottie-web": "^5.12.2",
     "marked": "^11.1.1",
     "pinia": "^2.0.35",
@@ -30,6 +31,7 @@
   "devDependencies": {
     "@antfu/eslint-config": "^0.38.4",
     "@iconify-json/carbon": "^1.1.16",
+    "@types/crypto-js": "^4.2.2",
     "@types/node": "^18.15.11",
     "@unocss/eslint-config": "^0.51.4",
     "@unocss/preset-attributify": "^0.51.4",

+ 14 - 0
pnpm-lock.yaml

@@ -23,6 +23,9 @@ dependencies:
   file-saver:
     specifier: ^2.0.5
     version: registry.npmmirror.com/file-saver@2.0.5
+  js-base64:
+    specifier: ^3.7.6
+    version: 3.7.6
   lottie-web:
     specifier: ^5.12.2
     version: 5.12.2
@@ -55,6 +58,9 @@ devDependencies:
   '@iconify-json/carbon':
     specifier: ^1.1.16
     version: 1.1.16
+  '@types/crypto-js':
+    specifier: ^4.2.2
+    version: 4.2.2
   '@types/node':
     specifier: ^18.15.11
     version: 18.15.11
@@ -849,6 +855,10 @@ packages:
     resolution: {integrity: sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==}
     dev: true
 
+  /@types/crypto-js@4.2.2:
+    resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
+    dev: true
+
   /@types/debug@4.1.7:
     resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==}
     dependencies:
@@ -3334,6 +3344,10 @@ packages:
     resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
     dev: true
 
+  /js-base64@3.7.6:
+    resolution: {integrity: sha512-NPrWuHFxFUknr1KqJRDgUQPexQF0uIJWjeT+2KjEePhitQxQEx5EJBG1lVn5/hc8aLycTpXrDOgPQ6Zq+EDiTA==}
+    dev: false
+
   /js-beautify@1.14.6:
     resolution: {integrity: sha512-GfofQY5zDp+cuHc+gsEXKPpNw2KbPddreEo35O6jT6i0RVK6LhsoYBhq5TvK4/n74wnA0QbK8gGd+jUZwTMKJw==}
     engines: {node: '>=10'}

File diff suppressed because it is too large
+ 1 - 0
public/sdk/audio/processor.worker.js


File diff suppressed because it is too large
+ 1 - 0
public/sdk/recorder/processor.worker.js


File diff suppressed because it is too large
+ 1 - 0
public/sdk/recorder/processor.worklet.js


+ 136 - 20
src/components/chat-bot/index.vue

@@ -3,8 +3,10 @@
 // import animationData from './robot.json'
 import { Microphone } from '@element-plus/icons-vue'
 import placeImg from './place.png'
+import { TTSRecorder } from './sdk/chat'
+import { audioPlayer, connectTtsWS } from './sdk/tts'
+import { connectIatWS, iatStatus, recorder } from './sdk/iat'
 import { qa_audio, qa_text } from './qa.ts'
-
 // const anim = ref(null)
 
 // onMounted(() => {
@@ -17,6 +19,10 @@ import { qa_audio, qa_text } from './qa.ts'
 //   })
 // })
 
+const isWorking = ref(false)
+
+const bigModel = $ref(new TTSRecorder())
+
 const videoRef = ref<HTMLVideoElement>()
 const videoLoaded = ref(false)
 function handleLoadedMetadata() {
@@ -31,6 +37,7 @@ function pauseVideoPlay() {
   console.log('pauseVideoPlay')
   videoRef.value!.pause()
   videoRef.value!.currentTime = 0
+  isWorking.value = false
 }
 
 const ifDrawer = ref(false)
@@ -49,9 +56,24 @@ audioEl.addEventListener('ended', () => {
   pauseVideoPlay()
 })
 
-function handleSendMessage(q?: string, isInput?: boolean) {
-  if (isInput)
-    return
+function sleep(ms: number) {
+  return new Promise(resolve => setTimeout(resolve, ms))
+}
+
+async function handleSendMessage(q?: string, isInput?: boolean) {
+  isWorking.value = true
+  console.log('handleSendMessage', q, isInput)
+  console.log('iatStatus', iatStatus.value)
+
+  if (iatStatus.value === 'OPEN') {
+    recorder.stop()
+    // 录音机关闭后,需要等待一段时间,才能清空输入框
+    await sleep(1000)
+  }
+  if (isInput) {
+    console.log('清空输入框')
+    questionInput.value = ''
+  }
   if (!q)
     return
   const t = Date.now()
@@ -61,19 +83,18 @@ function handleSendMessage(q?: string, isInput?: boolean) {
     d: encodeURIComponent(q),
     t,
   })
-  questionInput.value = ''
-  setTimeout(() => {
-    const answer = {
+  nextTick(() => {
+    scrollbarRef.value.setScrollTop(99999999)
+  })
+  if (!isInput && qa_text[q]) {
+    const i = chatList.value.push({
       left: true,
       name: 'AI助教',
       d: '',
       t: t + 1,
-    }
-    const i = chatList.value.push(answer)
-    nextTick(() => {
-      scrollbarRef.value.setScrollTop(99999999)
     })
     startVideoPlay()
+
     const fullAnswer = qa_text[q].replace(/(\r\n|\n|\r)/gm, '<br />') ?? ''
     const fullAnswerList = fullAnswer.match(/.{1,3}/g) ?? []
     const answerAudio = qa_audio[q]
@@ -92,8 +113,54 @@ function handleSendMessage(q?: string, isInput?: boolean) {
       nextTick(() => {
         scrollbarRef.value.setScrollTop(99999999)
       })
-    }, 100)
-  }, 1000)
+    }, 400)
+    return
+  }
+
+  if (!['init', 'endPlay', 'errorTTS'].includes(bigModel.status)) {
+    // chatList.value.push({
+    //   left: true,
+    //   name: 'AI助教',
+    //   d: encodeURIComponent(''),
+    //   t,
+    // })
+    isWorking.value = false
+    return
+  }
+
+  bigModel.start(q)
+  const i = chatList.value.push({
+    left: true,
+    name: 'AI助教',
+    d: '思考中',
+    t: t + 1,
+  })
+  // bigModel.onWillResultChange = (resultData) => {
+  //   chatList.value[i - 1].d += encodeURIComponent(resultData)
+  //   nextTick(() => {
+  //     scrollbarRef.value.setScrollTop(99999999)
+  //   })
+  // }
+  bigModel.onWillResultFinish = (resultData) => {
+    chatList.value[i - 1].d = ''
+
+    console.log('handleStartAudioTts', resultData)
+    const fullAnswerList = resultData.match(/.{1,3}/g) ?? []
+    const timer = setInterval(() => {
+      if (fullAnswerList.length === 0) {
+        clearInterval(timer)
+        // pauseVideoPlay()
+        return
+      }
+      const item = fullAnswerList!.shift()
+      chatList.value[i - 1].d += encodeURIComponent(item)
+      nextTick(() => {
+        scrollbarRef.value.setScrollTop(99999999)
+      })
+    }, 400)
+    startVideoPlay()
+    handleStartAudioTts(resultData, i - 1)
+  }
 }
 
 const optionInputs = [
@@ -101,6 +168,41 @@ const optionInputs = [
   '如何锻炼英语口语的发音',
   '哪些学习的记忆方法较好',
 ]
+
+// 音频播放结束
+audioPlayer.onStop = () => {
+  console.log('audioPlayer.onStop')
+  pauseVideoPlay()
+}
+
+function handleStartAudioTts(text: string, idx: number) {
+  console.log('handleStartAudioTts')
+
+  const ttsWS = connectTtsWS(text)
+  ttsWS.onWillResultFinish = (audioPlayer: any) => {
+    console.log('read finsih : ', audioPlayer.getAudioDataBlob('wav'))
+    chatList.value[idx].audio = URL.createObjectURL(audioPlayer.getAudioDataBlob('wav'))
+  }
+}
+// 语音识别
+function handleStartIat() {
+  if (iatStatus.value === 'UNDEFINED' || iatStatus.value === 'CLOSED') {
+    const iatWS = connectIatWS()
+    iatWS.onWillResultStart = () => {
+      questionInput.value = ''
+    }
+    iatWS.onWillResultChange = (resultData) => {
+      console.log('onWillResultChange', resultData)
+      questionInput.value = resultData
+    }
+    iatWS.onWillResultFinish = (resultData) => {
+      console.log('onWillResultFinish', resultData)
+    }
+  }
+  else if (iatStatus.value === 'OPEN') {
+    recorder.stop()
+  }
+}
 </script>
 
 <template>
@@ -112,7 +214,7 @@ const optionInputs = [
     <img :src="placeImg" alt="">
   </div>
   <el-drawer
-    v-model="ifDrawer" direction="rtl" append-to-body :size="800" title="AI助教" :z-index="201"
+    v-model="ifDrawer" direction="rtl" append-to-body :size="880" title="AI助教" :z-index="201"
     style="background-color: #f0f0f0;" class="no-mb"
   >
     <div class="flex">
@@ -122,7 +224,7 @@ const optionInputs = [
           class="h-full flex items-center justify-center" loop muted @loadedmetadata="handleLoadedMetadata"
         />
       </div>
-      <div class="w-360px flex flex-col justify-between rounded bg-white px-10px py-10px">
+      <div class="w-440px flex flex-col justify-between rounded bg-white px-10px py-10px">
         <el-scrollbar ref="scrollbarRef" height="680px" class="px-14px">
           <div class="flex flex-col gap-4">
             <div class="rounded-4px bg-hex-f0f0f0 p-14px text-gray-500">
@@ -143,17 +245,31 @@ const optionInputs = [
           </div>
         </el-scrollbar>
         <div class="flex items-start justify-between space-x-14px">
-          <div
+          <!-- <div
             class="h-32px w-32px flex flex-none cursor-pointer items-center justify-center rounded-16px bg-[#626aef] text-white"
           >
             <Microphone class="h-20px w-20px" />
-          </div>
-
+          </div> -->
+          <el-tooltip
+            class=""
+            effect="dark"
+            content="一次语音识别时长不能超过60秒"
+            placement="top-start"
+          >
+            <el-button
+              :icon="Microphone" circle :color="(iatStatus === 'UNDEFINED' || iatStatus === 'CLOSED') ? '#626aef' : '#f56c6c'"
+              :disabled="isWorking" style="--color: #fff;"
+              @click="handleStartIat"
+            />
+          </el-tooltip>
           <el-input
-            v-model="questionInput" disabled size="large" type="textarea" placeholder="请输入您的问题"
+            v-model="questionInput" size="large" type="textarea" placeholder="请输入您的问题"
             :autosize="{ minRows: 1, maxRows: 4 }"
           />
-          <el-button color="#626aef" @click="handleSendMessage(questionInput, true)">
+          <el-button
+            color="#626aef" :disabled="isWorking"
+            @click="handleSendMessage(questionInput, true)"
+          >
             发送
           </el-button>
         </div>

File diff suppressed because it is too large
+ 0 - 290
src/components/chat-bot/indey.vue


+ 2 - 1
src/components/chat-bot/info-item.vue

@@ -17,10 +17,11 @@ const showMsg = $computed(() => marked(decodeURIComponent(props.d!)))
     </div>
     <div class="r_flex-row-reverse mt-2 flex">
       <div
-        class="text_wrapper max-w-284px min-h-44px min-w-50px rounded-3xl bg-white px-14px py-10px"
+        class="text_wrapper max-w-360px min-h-44px min-w-50px rounded-3xl bg-white px-14px py-10px"
         v-html="showMsg"
       />
     </div>
+    <!-- <el-button></el-button> -->
   </div>
 </template>
 

File diff suppressed because it is too large
+ 0 - 1
src/components/chat-bot/robot.json


+ 158 - 0
src/components/chat-bot/sdk/chat.ts

@@ -0,0 +1,158 @@
+import CryptoJS from 'crypto-js'
+import { API_KEY, API_SECRET, APPID } from './secret'
+import { user } from '~/store/index'
+
+let total_res = '' // 保存回答历史
+
+function getWebsocketUrl(): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const apiKey = API_KEY
+    const apiSecret = API_SECRET
+    let url = 'wss://spark-api.xf-yun.com/v3.1/chat'
+    const host = location.host
+    const date = new Date().toGMTString()
+    // const date = Date.now()
+    const algorithm = 'hmac-sha256'
+    const headers = 'host date request-line'
+    const signatureOrigin = `host: ${host}\ndate: ${date}\nGET /v3.1/chat HTTP/1.1`
+    const signatureSha = CryptoJS.HmacSHA256(signatureOrigin, apiSecret)
+    const signature = CryptoJS.enc.Base64.stringify(signatureSha)
+    const authorizationOrigin = `api_key="${apiKey}", algorithm="${algorithm}", headers="${headers}", signature="${signature}"`
+    const authorization = btoa(authorizationOrigin)
+    url = `${url}?authorization=${authorization}&date=${date}&host=${host}`
+    // url = `${url}?authorization=${encodeURIComponent(authorization)}&date=${encodeURIComponent(date)}&host=${encodeURIComponent(host)}`
+    console.log('getWebsocketUrl : ', url)
+    resolve(url)
+  })
+}
+
+export class TTSRecorder {
+  appId: string
+  status: string
+  ttsWS?: WebSocket
+  playTimeout: any
+  onWillStatusChange?: (oldStatus: string, newStatus: string) => void
+  onWillResultChange?: (answerPart: string) => void
+  onWillResultFinish?: (answerFull: string) => void
+
+  constructor({
+    appId = APPID,
+  } = {}) {
+    this.appId = appId
+    this.status = 'init'
+    console.log('TTSRecorder init')
+  }
+
+  // 修改状态
+  setStatus(status) {
+    this.onWillStatusChange && this.onWillStatusChange(this.status, status)
+    this.status = status
+  }
+
+  // 连接websocket
+  connectChatWS(question: string) {
+    this.setStatus('ttsing')
+    return getWebsocketUrl().then((url) => {
+      let ttsWS
+      if ('WebSocket' in window) {
+        this.ttsWS = ttsWS = new WebSocket(url)
+        console.log('WebSocket创建成功')
+      }
+      else if ('MozWebSocket' in window) {
+        // @ts-expect-error
+        this.ttsWS = ttsWS = new MozWebSocket(url)
+        console.log('WebSocket创建成功')
+      }
+      else {
+        console.error('浏览器不支持WebSocket')
+        alert('浏览器不支持WebSocket')
+        return
+      }
+      ttsWS.onopen = (e: any) => {
+        this.webSocketSend(question)
+      }
+      ttsWS.onmessage = (e: any) => {
+        this.result(e.data)
+      }
+      ttsWS.onerror = (e: any) => {
+        clearTimeout(this.playTimeout)
+        this.setStatus('error')
+        alert('WebSocket报错,请f12查看详情')
+        console.error(`详情查看:${encodeURI(url.replace('wss:', 'https:'))}`)
+      }
+      ttsWS.onclose = (e: any) => {
+        console.log(e)
+      }
+    })
+  }
+
+  // websocket发送数据
+  webSocketSend(question: string) {
+    const params = {
+      header: {
+        app_id: this.appId,
+        uid: user.value?.user_id,
+      },
+      parameter: {
+        chat: {
+          domain: 'generalv3',
+          // temperature: 0.5,
+          // max_tokens: 1024,
+          auditing: 'default',
+        },
+      },
+      payload: {
+        message: {
+          text: [
+            // {
+            //   role: 'user',
+            //   content: '中国第一个皇帝是谁?',
+            // },
+            // {
+            //   role: 'assistant',
+            //   content: '秦始皇',
+            // },
+            {
+              role: 'user',
+              content: question,
+            },
+          ],
+        },
+      },
+    }
+    console.log(params)
+    console.log(JSON.stringify(params))
+    this.ttsWS?.send(JSON.stringify(params))
+  }
+
+  start(question: string) {
+    total_res = '' // 清空回答历史
+    return this.connectChatWS(question)
+  }
+
+  // websocket接收数据的处理
+  result(resultData: string) {
+    const jsonData = JSON.parse(resultData)
+
+    // console.log('websocket接收数据的处理 : ', jsonData)
+
+    // 提问失败
+    if (jsonData.header.code !== 0) {
+      // alert(`提问失败: ${jsonData.header.code}:${jsonData.header.message}`)
+      console.error(`${jsonData.header.code}:${jsonData.header.message}`)
+      this.onWillResultChange && this.onWillResultChange(jsonData.header.message)
+      return
+    }
+
+    total_res = total_res + jsonData?.payload?.choices?.text?.[0]?.content
+    this.onWillResultChange && this.onWillResultChange(jsonData?.payload?.choices?.text?.[0]?.content)
+
+    if (jsonData.header.code === 0 && jsonData.header.status === 2) {
+      this.ttsWS?.close()
+      this.setStatus('init')
+      this.onWillResultFinish && this.onWillResultFinish(total_res)
+    }
+
+    return jsonData
+  }
+}

+ 171 - 0
src/components/chat-bot/sdk/iat.ts

@@ -0,0 +1,171 @@
+import CryptoJS from 'crypto-js'
+import { ref } from 'vue'
+import { API_KEY, API_SECRET, APPID } from './secret'
+import RecorderManager from './other/recorder.esm.js'
+
+export const recorder = new RecorderManager('/sdk/recorder')
+recorder.onStart = () => {
+  changeIatStatus('OPEN')
+}
+
+let iatWS: any
+let resultText = ''
+let resultTextTemp = ''
+export const iatStatus = ref('UNDEFINED')
+
+function changeIatStatus(status) {
+  iatStatus.value = status
+  if (status === 'CONNECTING') {
+    resultText = ''
+    resultTextTemp = ''
+    iatWS?.onWillResultStart && iatWS?.onWillResultStart()
+  }
+}
+
+function getWebSocketUrl() {
+  // 请求地址根据语种不同变化
+  let url = 'wss://iat-api.xfyun.cn/v2/iat'
+  const host = location.host
+  const apiKey = API_KEY
+  const apiSecret = API_SECRET
+  const date = new Date().toGMTString()
+  const algorithm = 'hmac-sha256'
+  const headers = 'host date request-line'
+  const signatureOrigin = `host: ${host}\ndate: ${date}\nGET /v2/iat HTTP/1.1`
+  const signatureSha = CryptoJS.HmacSHA256(signatureOrigin, apiSecret)
+  const signature = CryptoJS.enc.Base64.stringify(signatureSha)
+  const authorizationOrigin = `api_key="${apiKey}", algorithm="${algorithm}", headers="${headers}", signature="${signature}"`
+  const authorization = btoa(authorizationOrigin)
+  url = `${url}?authorization=${authorization}&date=${date}&host=${host}`
+  return url
+}
+
+function toBase64(buffer) {
+  let binary = ''
+  const bytes = new Uint8Array(buffer)
+  const len = bytes.byteLength
+  for (let i = 0; i < len; i++)
+    binary += String.fromCharCode(bytes[i])
+
+  return window.btoa(binary)
+}
+
+function renderResult(resultData) {
+  // 识别结束
+  const jsonData = JSON.parse(resultData)
+  if (jsonData.data && jsonData.data.result) {
+    const data = jsonData.data.result
+    let str = ''
+    const ws = data.ws
+    for (let i = 0; i < ws.length; i++)
+      str = str + ws[i].cw[0].w
+
+    // 开启wpgs会有此字段(前提:在控制台开通动态修正功能)
+    // 取值为 "apd"时表示该片结果是追加到前面的最终结果;取值为"rpl" 时表示替换前面的部分结果,替换范围为rg字段
+    if (data.pgs) {
+      if (data.pgs === 'apd') {
+        // 将resultTextTemp同步给resultText
+        resultText = resultTextTemp
+      }
+      // 将结果存储在resultTextTemp中
+      resultTextTemp = resultText + str
+    }
+    else {
+      resultText = resultText + str
+    }
+    iatWS?.onWillResultChange(resultTextTemp || resultText || '')
+  }
+  if (jsonData.code === 0 && jsonData.data.status === 2) {
+    iatWS.close()
+    iatWS?.onWillResultFinish(resultTextTemp || resultText || '')
+  }
+
+  if (jsonData.code !== 0) {
+    iatWS.close()
+    console.error(jsonData)
+  }
+}
+
+export function connectIatWS() {
+  const websocketUrl = getWebSocketUrl()
+  if ('WebSocket' in window) {
+    iatWS = new WebSocket(websocketUrl)
+  }
+  else if ('MozWebSocket' in window) {
+    iatWS = new MozWebSocket(websocketUrl)
+  }
+  else {
+    alert('浏览器不支持WebSocket')
+    return
+  }
+  changeIatStatus('CONNECTING')
+  iatWS.onopen = (e) => {
+    // 开始录音
+    recorder.start({
+      sampleRate: 16000,
+      frameSize: 1280,
+    })
+    const params = {
+      common: {
+        app_id: APPID,
+      },
+      business: {
+        language: 'zh_cn',
+        domain: 'iat',
+        accent: 'mandarin',
+        vad_eos: 5000,
+        dwa: 'wpgs',
+      },
+      data: {
+        status: 0,
+        format: 'audio/L16;rate=16000',
+        encoding: 'raw',
+      },
+    }
+    iatWS.send(JSON.stringify(params))
+  }
+  iatWS.onmessage = (e) => {
+    renderResult(e.data)
+  }
+  iatWS.onerror = (e) => {
+    console.error(e)
+    recorder.stop()
+    changeIatStatus('CLOSED')
+  }
+  iatWS.onclose = (e) => {
+    recorder.stop()
+    changeIatStatus('CLOSED')
+  }
+
+  return iatWS
+}
+
+recorder.onFrameRecorded = ({ isLastFrame, frameBuffer }) => {
+  if (iatWS.readyState === iatWS.OPEN) {
+    iatWS.send(
+      JSON.stringify({
+        data: {
+          status: isLastFrame ? 2 : 1,
+          format: 'audio/L16;rate=16000',
+          encoding: 'raw',
+          audio: toBase64(frameBuffer),
+        },
+      }),
+    )
+    if (isLastFrame)
+      changeIatStatus('CLOSING')
+  }
+}
+recorder.onStop = () => {
+  //
+}
+
+// function () {
+//   if (iatStatus.value === 'UNDEFINED' || iatStatus.value === 'CLOSED') {
+//     connectIatWS()
+//   }
+//   else if (iatStatus.value === 'CONNECTING' || iatStatus.value === 'OPEN') {
+//     // 结束录音
+//     recorder.stop()
+//   }
+// }

File diff suppressed because it is too large
+ 1 - 0
src/components/chat-bot/sdk/other/audio.esm.js


File diff suppressed because it is too large
+ 1 - 0
src/components/chat-bot/sdk/other/recorder.esm.js


+ 3 - 0
src/components/chat-bot/sdk/secret.ts

@@ -0,0 +1,3 @@
+export const APPID = '0c2de7e2'
+export const API_KEY = 'ed03d038e1b226469a56b2071fe53cb0'
+export const API_SECRET = 'NDlkZTU1ZTU3NGY2MDc4MzI0M2E2NWEy'

+ 164 - 0
src/components/chat-bot/sdk/tts.ts

@@ -0,0 +1,164 @@
+import CryptoJS from 'crypto-js'
+import { Base64 } from 'js-base64'
+import AudioPlayer from './other/audio.esm.js'
+import { API_KEY, API_SECRET, APPID } from './secret'
+
+// type ISaveAudioData = 'pcm' | 'wav'
+// declare class AudioPlayer {
+//   constructor(processorPath?: string)
+//   private toSampleRate
+//   private resumePlayDuration
+//   private fromSampleRate
+//   private isAudioDataEnded
+//   private playAudioTime?
+//   private status
+//   private audioContext?
+//   private bufferSource?
+//   private audioDatas
+//   private pcmAudioDatas
+//   private audioDataOffset
+//   private processor
+//   postMessage({ type, data, isLastData }: {
+//     type: 'base64' | 'string' | 'Int16Array' | 'Float32Array'
+//     data: string | Int16Array | Float32Array
+//     isLastData: boolean
+//   }): void
+//   private onPlay?
+//   private onStop?
+//   private playAudio
+//   reset(): void
+//   start({ autoPlay, sampleRate, resumePlayDuration }?: {
+//     autoPlay?: boolean
+//     sampleRate?: number
+//     resumePlayDuration?: number
+//   }): void
+//   play(): void
+//   stop(): void
+//   getAudioDataBlob(type: ISaveAudioData): Blob | undefined
+// }
+
+// let total_res = ''
+
+export const ttsStatus = ref('UNDEFINED') // "UNDEFINED" "CONNECTING" "PLAY" "STOP"
+export function changeTtsStatus(status: string) {
+  ttsStatus.value = status
+}
+
+export const audioPlayer = new AudioPlayer('/sdk/audio')
+// audioPlayer.onPlay = () => {
+//   changeTtsStatus('PLAY')
+// }
+// audioPlayer.onStop = (audioDatas) => {
+//   console.log(audioDatas)
+//   ttsStatus === 'PLAY' && changeTtsStatus('STOP')
+// }
+
+function getWebSocketUrl(): string {
+  const apiKey = API_KEY
+  const apiSecret = API_SECRET
+  let url = 'wss://tts-api.xfyun.cn/v2/tts'
+  const host = location.host
+  const date = new Date().toGMTString()
+  const algorithm = 'hmac-sha256'
+  const headers = 'host date request-line'
+  const signatureOrigin = `host: ${host}\ndate: ${date}\nGET /v2/tts HTTP/1.1`
+  const signatureSha = CryptoJS.HmacSHA256(signatureOrigin, apiSecret)
+  const signature = CryptoJS.enc.Base64.stringify(signatureSha)
+  const authorizationOrigin = `api_key="${apiKey}", algorithm="${algorithm}", headers="${headers}", signature="${signature}"`
+  const authorization = btoa(authorizationOrigin)
+  url = `${url}?authorization=${authorization}&date=${date}&host=${host}`
+  console.log('getWebSocketUrl : ', url)
+  return url
+}
+
+function encodeText(text, type) {
+  if (type === 'unicode') {
+    const buf = new ArrayBuffer(text.length * 4)
+    const bufView = new Uint16Array(buf)
+    for (let i = 0, strlen = text.length; i < strlen; i++)
+      bufView[i] = text.charCodeAt(i)
+
+    let binary = ''
+    const bytes = new Uint8Array(buf)
+    const len = bytes.byteLength
+    for (let i = 0; i < len; i++)
+      binary += String.fromCharCode(bytes[i])
+
+    return window.btoa(binary)
+  }
+  else {
+    return Base64.encode(text)
+    // return window.btoa(text)
+  }
+}
+
+export function connectTtsWS(text: string) {
+  let ttsWS: any
+  const url = getWebSocketUrl()
+  if ('WebSocket' in window) {
+    ttsWS = new WebSocket(url)
+  }
+  else if ('MozWebSocket' in window) {
+    ttsWS = new MozWebSocket(url)
+  }
+  else {
+    alert('浏览器不支持WebSocket')
+    return
+  }
+  // changeTtsStatus('CONNECTING')
+
+  ttsWS.onopen = (e: any) => {
+    audioPlayer.start({
+      autoPlay: true,
+      sampleRate: 16000,
+      resumePlayDuration: 1000,
+    })
+    // changeTtsStatus('PLAY')
+    // const text = ''
+    const params = {
+      common: {
+        app_id: APPID,
+      },
+      business: {
+        aue: 'raw',
+        bgs: 0,
+        auf: 'audio/L16;rate=16000',
+        tte: 'UTF8',
+        vcn: 'xiaoyan',
+      },
+      data: {
+        status: 2,
+        text: encodeText(text, 'UTF8'),
+      },
+    }
+    ttsWS.send(JSON.stringify(params))
+  }
+  ttsWS.onmessage = (e: any) => {
+    const jsonData = JSON.parse(e.data)
+    // console.log('jsonData : ', jsonData)
+    // 合成失败
+    if (jsonData.code !== 0) {
+      console.error(jsonData)
+      // changeTtsStatus('UNDEFINED')
+      return
+    }
+    audioPlayer.postMessage({
+      type: 'base64',
+      data: jsonData.data.audio,
+      isLastData: jsonData.data.status === 2,
+    })
+    // total_res += jsonData.data.audio
+    if (jsonData.code === 0 && jsonData.data.status === 2)
+      ttsWS.close()
+  }
+  ttsWS.onerror = (e: any) => {
+    console.error(e)
+  }
+
+  ttsWS.onclose = (e: any) => {
+    console.log('ttsWS close : ', e)
+    // console.log('total_res : ', total_res  audioPlayer.getAudioDataBlob('wav'))
+    ttsWS.onWillResultFinish && ttsWS.onWillResultFinish(audioPlayer)
+  }
+  return ttsWS
+}

+ 1 - 0
src/pages/frontpage.vue

@@ -6,6 +6,7 @@ import layout from './frontpage/layout/index.vue'
 <template>
   <el-config-provider :locale="zhCn">
     <layout />
+    <chat-bot />
   </el-config-provider>
 </template>
 

+ 0 - 1
src/pages/frontpage/homePage/index.vue

@@ -405,7 +405,6 @@ function goDetail(item) {
       </div>
     </div>
   </div>
-  <chat-bot />
 </template>
 
 <style lang="scss" scoped>