服务器流式传输一段AI流体数据,提取标记的数据,并进行语音播放

181 阅读2分钟

在进行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")
})