vue3之写一个aichat---已聊天组件AI助手消息复制和文字转语音播放

复制

复制内容实现很简单,创建一个textarea标签,将textArea的值设为要复制的内容,document.execCommand(‘copy’)执行复制操作,尝试将选中的文本复制到剪切板,如果操作成功返回true,否则返回false。操作完成后删除textarea标签

const handleCopy = value => {
  let success

  var textArea = document.createElement('textarea')
  textArea.value = value
  document.body.appendChild(textArea)
  textArea.select()
  success = document.execCommand('copy')
  document.body.removeChild(textArea)
  if (success) {
    toast.show('复制成功')
  } else {
    toast.show('复制失败')
  }
}

文字转语音播放

大致流程是: 用户触发播放预处理文本 分段 转换每段为音频 按顺序播放
要实现的功能:文本智能分段处理、基于Web Audio API的播放控制、语音片段缓存机制、播放状态管理和错误处理、支持随时中断播放

实现详解

基础架构设计
audioContext 首次播放时初始化
messageStates采用Map结构存储各消息的独立状态
currentPlayingId确保同一时间只能有一个播放实列

// 音频上下文单例(全局唯一)
const audioContext = ref(null)

// 消息状态存储器
const messageStates = ref(new Map()) 

// 当前播放中的消息ID 
const currentPlayingId = ref(null)

播放状态
为什么使用Map而不是普通对象?

对比项Map普通对象
键类型支持任何类型,如对象支持字符串/Symbol
顺序保证插入的顺序ES6保留字符串顺序
性能大数据量时更高效小数据量无差别
内置方法size属性、forEach需要Object.keys
const getMessageState = message => {
  if (!messageStates.value.has(message.id)) {
    messageStates.value.set(message.id, {
      isPlaying: false,//当前消息的播放状态,是否在播放,一开始生成chatRecordList就添加了
      audioQueue: [],//待播放队列
      audioSource: null,//当前音频源,Web Audio API的音频源对象
      abortController: null,//中断控制器
      convertedSegments: new Map()//已转换片段缓存,已转换的文本-音频映射缓存
    })
  }
  return messageStates.value.get(message.id)
}

核心播放流程

在这里插入图片描述

const handlePlay = async message => {
  emit('isClickThought', true)
  // 获取当前的状态对象
  const state = getMessageState(message)

  try {
    // 切换播放状态
    if (state.isPlaying) {
      await stopMessagePlayback(message)
      return
    }

    // 停止其他正在播放的消息
    await stopAllPlayback()

    // 初始化音频上下文
    if (!(await initAudioContext())) return

    // 设置播放状态
    state.isPlaying = true
    currentPlayingId.value = message.id
    state.abortController = new AbortController()

    // 文本处理
    const text = stripHtmlTags(message.content)
    const segments = splitText(preprocessText(text))

    // 开始转换和播放流程
    for (const [index, segment] of segments.entries()) {
      if (!state.isPlaying) break//中断检查

      // 转换文本为语音
      const audioData = await convertSegment(segment, state.abortController.signal, message)

      if (audioData) {
        state.audioQueue.push({ audioData, index })
        await playAudioQueue(message)
      }
    }
  } catch (error) {
    if (error.name !== 'AbortError') {
      console.error('播放失败:', error)
      toast.show('播放失败,请重试')
    }
  } finally {
    state.isPlaying = false
    currentPlayingId.value = null
  }
}

文本预处理
1、HTML标签处理
因为流式返回的渲染数据是有样式带标签的,所以在调用文字转语音的接口之前还是先把标签去掉的好

const stripHtmlTags = htmlContent => {
  // 使用正则表达式去除所有的HTML标签
  let tempDiv = document.createElement('div')
  tempDiv.innerHTML = htmlContent
  let text = tempDiv.textContent || tempDiv.innerText || ''

  // 返回纯文本内容
  return text.trim()
}

2、文本规范化处理
因为返回的数据会含有文献引用标记、连续的空格等,为了语音播放的流畅,需要做规范化文本处理

const preprocessText = text => {
  return (
    text
      // 清理特殊字符
      .replace(/\[\d+\]/g, '') // 删除[1][2]类文献标记
      .replace(/\*\*/g, '') // 删除加粗符号
      .replace(/[""]/g, '"') // 统一引号
      .replace(/['']/g, "'") // 统一单引号
      .replace(/(\s*\n\s*)+/g, ' ') // 合并换行和空白为单空格
      .replace(/\s+/g, ' ') // 合并连续空格
      .trim()
  )
}

3、对文本进行分段
分段是因为后端接口最长只能传150个字符,首先尝试按句子分割文本,并确保每个分割后的断立长度不超过设置的最大长度,这里我设置了80个字符,如果句子超过了这个长度限制,则进一步更加标点符号将其细分为更小的部分

const splitText = processedText => {
  // 更合理的分段逻辑:优先按句子分割,兼顾长度限制
  const sentenceRegex = /[^。!?;\n]+[。!?;\n]*/g//找到以句号、感叹号、问号、分号或换行符结尾的句子
  let sentences = processedText.match(sentenceRegex) || []//提取所有匹配的句子,如果没有匹配的返回空数组
//
  return sentences.reduce((segments, sentence) => {
    sentence = sentence.trim()
    if (!sentence) return segments

    const MAX_LENGTH = 80//设置最大长度为80个字符
    if (sentence.length <= MAX_LENGTH) {
      segments.push(sentence)//长度小于或等于最大长度,则直接将该句子添加到段落列表 segments 中
    } else {
      //如果超过最大长度则进行二次分割
      // 对长句子进行二次分割
      let currentChunk = ''//存储当前正在构建的段落部分
      //  split(/([,,]|\.\s+)/) 方法将句子按逗号、中文逗号或句号加空格进行分割。遍历后逐个添加到currentChunk
      sentence.split(/([,,]|\.\s+)/).forEach(part => {
        if ((currentChunk + part).length > MAX_LENGTH) {
          // 如果添加part后长度超过最大长度,则将currentChunk添加到segments,并重置currentChunk为part
          segments.push(currentChunk.trim())
          currentChunk = part
        } else {
          currentChunk += part
        }
      })
      // 如果最后还有剩余的currentChunk,则添加到segments
      if (currentChunk) segments.push(currentChunk.trim())
    }
    return segments //返回
  }, [])
}

初始化音频

这里主要目的是确保 AudioContext 被正确初始化,并在需要时恢复其状态。这对于处理音频数据是非常重要的,因为 AudioContext 是 Web Audio API 的核心。

const initAudioContext = async () => {
  try {
    //是否已存在audioContext,如果不存在,则创建一个新的AudioContext 实例
    if (!audioContext.value) {
      audioContext.value = new (window.AudioContext || window.webkitAudioContext)()//区别不同浏览器对AudioContext 的支持肯能有所不同,这里这样写是为了保持兼容性
    }
    // 检查AudioContext 的状态是否为suspended,在某些情况下,浏览器自动挂起策略可能会导致AudioContext 被挂起,因此需要用resume方法来恢复其状态
    if (audioContext.value.state === 'suspended') {
      await audioContext.value.resume()
    }
    return true //初始化成功返回true
  } catch (error) {
    console.error('初始化AudioContext失败:', error)
    return false
  }
}

关于AudioContext :AudioContext 是Web Audio API的核心组件之一,它提供了一个强大的、模块化的且基于可复用组件的系统来合成音频,通过AudioContext ,开发者可以在网页或应用中创建、控制以及处理音频资源
自动播放策略:现代浏览器为了改善用户体验、实施了严格的自动播放政策,这意味着需要用户交互,比如点击才能播放音频

文本转语音

segment:要转换的文本片段
signal 用于中断请求信号
message包含转换状态的信息对象
使用缓存机制可以避免重复请求;请求中断在用户取消操作或需要理解停止的转换的时候有用

// 处理单个文本片段的转换
const convertSegment = async (segment, signal, message) => {
  try {
    const state = getMessageState(message)//获取消息状态
    // 缓存检查
    if (state.convertedSegments.has(segment)) {
      return state.convertedSegments.get(segment) //如果有缓存,直接使用缓存
    }
  // signal中断信号
    const response = await api.chat.TextToVoice({ text: segment }, { signal })
    const audioData = response?.data?.Response?.Audio

    if (audioData) {//缓存结果
      state.convertedSegments.set(segment, audioData)
      return audioData
    }
  } catch (error) {
    if (error.name !== 'AbortError') {
      console.error('语音转换失败:', error)
    }
    return null
  }
}

播放队列管理

前面调用文字转语音的接口返回了多个音频片段,函数通过检查audioQueue的当都和当前这段音频的播放状态进行while循环,这样确保了只有在允许播放的情况下才会继续播放音频;通过使用shift方法从队列中取出音频对象,确保每次只播放一个音频并且是按顺序播放的

}
// 播放音频队列管理
const playAudioQueue = async message => {
  const state = getMessageState(message)//获取消息状态
  // 确保按顺序播放,根据每个音频的index属性排序
  state.audioQueue.sort((a, b) => a.index - b.index)
//循环播放音频
  while (state.audioQueue.length > 0 && state.isPlaying) {
    const { audioData } = state.audioQueue.shift()//获取并移除队列中的第一个音频,并提取音频数据
    await playAudioData(audioData, message) //播放音频数据
  }
}

decodeAudioData:异步解码音频文件

支持格式:MP3、WAV、OGG等

自动检测音频格式

返回AudioBuffer供播放使用

createBufferSource:创建可控音频源

可以设置播放速度

支持循环播放

精确控制播放时间

为什么要将Base64转为arrayBuffer?
音频数据通常以Base64字符串传输,但是Web Audio API需要二进制数据,所以需要转。base64ToArrayBuffer 函数通过atob解码,再转为Unit8Array
audioContext.value.decodeAudioData(arrayBuffer)将二进制数据解码为音频缓冲区AudioBuffer

const playAudioData = async (audioData, message) => {
  const state = getMessageState(message)//获取消息状态
  try {
    const arrayBuffer = base64ToArrayBuffer(audioData)//将base64字符串转换为ArrayBuffer
    const audioBuffer = await audioContext.value.decodeAudioData(arrayBuffer)//异步解码音频数据

    const source = audioContext.value.createBufferSource()//创建一个新的音频源
    source.buffer = audioBuffer//将解码后的音频数据赋值给音频源
    source.connect(audioContext.value.destination)//将音频源连接到音频上下文的目标节点(通常是扬声器)

    state.audioSource = source//将音频源保存到消息状态中,以便稍后可以停止它
    await new Promise(resolve => {
      source.onended = resolve//当音频播放结束时,解决Promise
      source.start(0)//开始播放音频
    })
  } catch (error) {
    if (error.name !== 'AbortError') {
      console.error('播放失败:', error)
    }
  } finally {
    state.audioSource = null
  }
}
// 示例:base64字符串 "ABC" → 二进制数据 [0x00, 0x01, 0x02]
const base64ToArrayBuffer = (base64) => {
  const binaryString = atob(base64) // 解码base64
  const bytes = new Uint8Array(binaryString.length)
  for (let i = 0; i < binaryString.length; i++) {
    bytes[i] = binaryString.charCodeAt(i) // 转为ASCII码
  }
  return bytes.buffer
}

停止机制

const stopMessagePlayback = async message => {
  const state = getMessageState(message)
  state.isPlaying = false
  state.abortController?.abort()//中止当前正在进行的转换和播放操作
  if (state.audioSource) {
    state.audioSource.stop()//停止音频播放
    state.audioSource.disconnect()//断开音频源与音频上下文的连接
  }
  state.audioQueue = []//清空待播放的音频队列
}
//停止所有正在播放的音频
const stopAllPlayback = async () => {
  for (const [id, state] of messageStates.value) {
    if (state.isPlaying) {
      await stopMessagePlayback({ id })
    }
  }
}

点击查看Web Audio API相关文章介绍

### 如何在 UniApp 中创建 AI 界面 #### 创建项目结构 为了构建一个AI界面,在HBuilderX中新建UniApp项目。根据需求编Vue单文件组件(.vue),并利用内置或自定义组件来设计页面布局[^1]。 ```javascript // main.js import Vue from &#39;vue&#39;; import App from &#39;./App&#39;; Vue.config.productionTip = false; App.mpType = &#39;app&#39;; const app = new Vue({ ...App }); app.$mount(); ``` #### 设计AI交互界面 对于AI界面的设计,可以借鉴聊天页面的实现方法。这涉及到消息列表、输入框、发送按钮等元素,并且能够处理不同类型的用户互动,比如文本输入语音识别的结果显示[^3]。 ```html <!-- components/AIChat.vue --> <template> <view class="chat-container"> <scroll-view scroll-y="true" :style="{ height: chatHeight }" @touchend="onScrollToBottom"> <block v-for="(item, index) in messageList" :key="index"> <!-- 用户消息 --> <view v-if="item.type === &#39;user&#39;" class="message user-message">{{ item.content }}</view> <!-- AI回复 --> <view v-else class="message ai-reply">{{ item.content }}</view> </block> </scroll-view> <view class="input-area"> <input type="text" placeholder="请输入..." v-model.trim="inputValue"/> <button @click="sendMessage">发送</button> </view> </view> </template> <script> export default { data() { return { inputValue: &#39;&#39;, messageList: [], chatHeight: &#39;calc(100vh - 80rpx)&#39; }; }, methods: { sendMessage() { if (!this.inputValue.length) {return;} this.messageList.push({type:&#39;user&#39;, content:this.inputValue}); this.inputValue = &#39;&#39;; // 模拟AI响应 setTimeout(() => { const replyContent = `这是来自AI的回答:${new Date().toLocaleTimeString()}`; this.messageList.push({type: &#39;ai&#39;, content:replyContent }); uni.pageScrollTo({ scrollTop: 9999 }); // 自动滚到最底部 }, 500); } } }; </script> <style scoped> .chat-container{padding-bottom:80rpx} .user-message{text-align:right;margin-right:20rpx;color:#fff;background-color:#f44;padding-left:1em;border-radius:.5rem;line-height:1.75;font-size:.875rem;display:inline-block;max-width:60%} .ai-reply{text-align:left;margin-left:20rpx;color:#fff;background-color:#0bb;padding-left:1em;border-radius:.5rem;line-height:1.75;font-size:.875rem;display:inline-block;max-width:60%} .input-area{position:fixed;width:100%;bottom:0;left:0;height:80rpx;z-index:999;display:flex;align-items:center;padding:0 20rpx;background:white;border-top:solid 1px #ddd} </style> ``` 此代码片段展示了如何设置基本的消息传递机制以及简单的UI样式调整,使得用户体验更加友好。当用户键入信息后点击“发送”,会触发`sendMessage()`函数向数组中添加新的条目,并模拟一段时间延迟后的AI回应。 #### 集成高级特性 要使这个AI界面对话更加强大,还可以考虑集成自然语言处理(NLP)服务或其他第三方API来进行真实的机器学习模型预测服务调用[^4]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值