在进行AI问答时,服务端会建立一个chat长连接聊天接口,然后按照下面的顺序发送一段xml格式的文本,我们的目的是获取content的内容,并使用语音播放这段文本
// 第一次发送的数据
<chatId>000001</chatId>
// 第二次发送的数据
<modelId>模型名称</modelId>
// 第三次发送的数据
<content>你
// 第四次发送的数据
好
// 第五次发送的数据
!今天是20
// 第六次发送的数据
024年9月
// 第七次发送的数据
2日。天气晴,适合
// 第八次发送的数据
写BUG。
// 第九次发送的数据
</content>
// 第十次发送的数据
<endChat>{message: "你好!今天是20024年9月2日。天气晴,适合写BUG。",chatId: 000001, modelId: "模型名称"}</endChat>
由于是使用的uniapp开发的小程序,这里以uniapp为例子,其他平台也大致是一个思路
1、设置uni.request开启支持流体请求
const requestTask = uni.request({
...
enableChunked: true
})
2、监听 Transfer-Encoding Chunk Received 事件
既然都不得不写uniapp了,所以肯定是要兼容多端的。这里web端有个坑,得另外写一个监听方法
// #ifdef H5
if(requestTask?._xhr) {
this.chunkValue = ""
requestTask._xhr.onprogress = (e) => {
if(e?.target?.response){
let text = e.target.response.replace(this.chunkValue, "")
this.chunkValue = e.target.response
uni.$emit("LLM_CHAT_CHUNK", text)
}
}
}
// #endif
// #ifndef H5
requestTask.onChunkReceived(function(res) {
const uint8Array = new Uint8Array(res.data);
let text = String.fromCharCode.apply(null, uint8Array);
text = decodeURIComponent(escape(text));
uni.$emit("LLM_CHAT_CHUNK", text)
})
// #endif
3、创建一个LLM_CHAT_CHUNK事件,用于接收流体发来的数据
// 这是个状态标识,标识chat聊天在处理什么内容
const chatNodeing = ref("")
uni.$off("LLM_CHAT_CHUNK")
uni.$on("LLM_CHAT_CHUNK", async text => {
const chatIdNode = await disposeChatIdNode(text)
const modelIdNode = await disposeModelIdNode(text)
const contentNode = await disposeContentNode(text)
const endChatNode = await disposeEndChatNode(text)
uni.$emit("SCROLL_BOTTOM")
})
function disposeChatIdNode(text) {
// 处理chatId相关的逻辑
...
}
function disposeModelIdNode(text) {
// 处理modelId相关的逻辑
...
}
function disposeContentNode(text) {
// 处理content相关的逻辑
// 在这里我们会收集聊天的数据,以及实时去语音播放发送来的聊天
...
}
function disposeEndChatNode(text) {
// 处理endChat相关的逻辑
...
}
4、实现disposeContentNode的逻辑
这里存在一个问题,通常来说,服务端会将<content>、</content>和前面二者之间的聊天内容分开发送,但经过测试发现有的模型性能不错返回数据特别快,从而导致服务端有时会将<content>和部分聊天文本一起返回给前端。
// ai的回复内容,用于显示
const aiReply = ref("")
function disposeContentNode(text) {
// 处理content相关的逻辑
// 在这里我们会收集聊天的数据,以及实时去语音播放发送来的聊天
return new Promise((resolve, reject) => {
if(text?.includes("<content>")) {
// 这是一种特殊情况,有些模型反应比较快,导致服务端会将content和部分聊天内容一起返回,所以我们要专门处理一下
chatNodeing.value = "CONTENT"
// 存入aiReply供页面显示
aiReply.value = extractText(text, "<content>", "</content>")
// aiReply有数据的话就发送语音开始事件TEXT_TO_SPEECH通知开始语音播放
if(aiReply.value) {
uni.$emit("TEXT_TO_SPEECH", aiReply.value)
}
resolve({status: 1})
chatNodeing.value = !text.includes("</content>") ? "CONTENT_TO_ENDCONTENT" : "ENDCONTENT"
} else if(chatNodeing.value == "CONTENT_TO_ENDCONTENT") {
let textDispose = extractText(text, "<content>", "</content>")
// 如果没有aiReply,说明没有粘包或者是第一次获取到数据,那么发送语音开始事件TEXT_TO_SPEECH,否则发送语音增加内容事件TEXT_CONCAT_TEXT
if(!aiReply.value) {
uni.$emit("TEXT_TO_SPEECH", textDispose)
} else {
uni.$emit("TEXT_CONCAT_TEXT", textDispose)
}
aiReply.value += textDispose
resolve({status: 1})
if(text.includes("</content>")) {
chatNodeing.value = "ENDCONTENT"
}
return
}
resolve({status: 0})
})
}
// 匹配标签内的内容
function extractText(text, start, end) {
// 匹配start和end之间的内容
const beforeAfterMatch = RegExp(`${start}(.*?)${end}`, 'gs').exec(text)
if (beforeAfterMatch) {
return beforeAfterMatch[1];
}
// 匹配只有start,返回start之后内容
const beforeMatch = RegExp(`${start}(.*)`, 'gs').exec(text);
if (beforeMatch) {
return beforeMatch[1];
}
// 匹配只有end,返回end之前内容
const afterBeforeMatch = RegExp(`(.*?)${end}`, 'gs').exec(text);
if (afterBeforeMatch) {
return afterBeforeMatch[1];
}
// 如果没有匹配到任何标签,返回原字符串
return text;
}
5、实现语音合成的逻辑
这里以微信小程序同声传译为例子来实现语音合成,其他平台找个合成插件也是类似的流程,或者找服务端让他们帮你合成也可以
// 同声传译-语音合成
export function useTextToSpeech() {
const plugin = requirePlugin('WechatSI');
let innerAudioContext = null
const textToSpeechContent = ref("")
const textToSpeechStatus = ref(0) // 0 未播放 1 调用了语音合成正在处理 2 正在播放
function onTextToSpeech(text = "") {
if(textToSpeechStatus.value > 0) {
uni.$emit("STOP_INNER_AUDIO_CONTEXT")
}
textToSpeechStatus.value = 1
uni.showLoading({
title: "语音合成中...",
mask: true,
})
if(text.length) {
textToSpeechContent.value = text
}
// 由于同声传译有最大上限,所以将文本分段合成
let content = textToSpeechContent.value.slice(0, 200)
textToSpeechContent.value = textToSpeechContent.value.slice(200, textToSpeechContent.value.length)
if(!content) {
uni.hideLoading()
return
}
plugin.textToSpeech({
lang: "zh_CN",
// lang: "en_US",
tts: true,
content: content,
success: (res) => {
uni.hideLoading()
innerAudioContext = uni.createInnerAudioContext()
innerAudioContext.src = res.filename
innerAudioContext.play()
textToSpeechStatus.value = 2
innerAudioContext.onEnded(() => {
innerAudioContext = null
textToSpeechStatus.value = 0
onTextToSpeech()
})
// 暂停或停止播放事件
uni.$off("STOP_INNER_AUDIO_CONTEXT")
uni.$on("STOP_INNER_AUDIO_CONTEXT", (pause) => {
textToSpeechStatus.value = 0
if(pause) {
innerAudioContext?.pause()
} else {
innerAudioContext?.stop()
innerAudioContext = null
textToSpeechContent.value = ""
}
})
},
fail: (res) => {
textToSpeechStatus.value = 0
uni.hideLoading()
toast("不支持合成的文字")
console.log("fail tts", res)
}
})
}
return {
onTextToSpeech,
textToSpeechContent,
textToSpeechStatus
}
}
const { onTextToSpeech, textToSpeechContent, textToSpeechStatus } = useTextToSpeech()
// 语音合成事件
uni.$off("TEXT_TO_SPEECH")
uni.$on("TEXT_TO_SPEECH", onTextToSpeech)
// 流式播放语音文本添加事件
uni.$off("TEXT_CONCAT_TEXT")
uni.$on("TEXT_CONCAT_TEXT", text => {
textToSpeechContent.value += text
})
onUnload(async () => {
uni.$emit("STOP_INNER_AUDIO_CONTEXT")
})