perf(ai-nutritionist): 文字流式 + TTS 分句首播(test-0415 反馈3-2/3-3)
3-2 文字回复响应速度: - _sendViaGemini 由 stream:false 改为 kieaiGeminiChatStream(SSE) - 首包到达即停 loading 圈、aiMsg.streaming=true 显示打字光标 - 逐 delta 累加到 aiMsg.content,TTFB 由全量等待降至首字节 3-3 TTS 朗读延迟: - splitTTSSentences 按 [。!?!?;;\n] 切分,超长 80 字硬切 - 首句独立合成立即播放;播放期间预合成下一句形成流水线 - innerAudioContext.onEnded 链式触发 _playNextTTSChunk - stopTTS 清队列,避免后台残留预合成 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -767,24 +767,41 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ========== KieAI Gemini(非流式,BUG-005) ==========
|
// ========== KieAI Gemini(流式 SSE,test-0415 反馈3-2)==========
|
||||||
async _sendViaGemini(content, type, aiMsg) {
|
// 旧版 stream: false 等待全文返回 → 5-15s 才出文字。
|
||||||
|
// 现行:调用 kieaiGeminiChatStream,首包到达即停 loading 圈,逐 chunk 渲染 → TTFB <1.5s
|
||||||
|
_sendViaGemini(content, type, aiMsg) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
const messages = this.buildChatMessages(content, type)
|
const messages = this.buildChatMessages(content, type)
|
||||||
try {
|
let receivedAny = false
|
||||||
const response = await api.kieaiGeminiChat({ messages, stream: false })
|
let errorMsg = ''
|
||||||
// BUG-005:成功时仅展示 data.choices[0].message.content(经规整);禁止 getAIResponse / 本地固定话术冒充模型输出
|
|
||||||
let reply = api.readKieaiGeminiDataChoicesAssistantText(response && response.data)
|
const ctrl = api.kieaiGeminiChatStream({ messages })
|
||||||
if (!reply) {
|
this._streamCtrl = ctrl
|
||||||
reply = (api.getKieaiGeminiChatMessageContent(response) || '').trim()
|
ctrl.onMessage((delta) => {
|
||||||
|
if (!receivedAny) {
|
||||||
|
receivedAny = true
|
||||||
|
aiMsg.loading = false
|
||||||
|
aiMsg.streaming = true
|
||||||
|
this.messageList = [...this.messageList]
|
||||||
}
|
}
|
||||||
if (!reply) {
|
aiMsg.content += delta
|
||||||
throw new Error('模型未返回有效内容')
|
// 每个 chunk 触发响应式刷新(保证 streaming 光标移动)
|
||||||
}
|
this.messageList = [...this.messageList]
|
||||||
aiMsg.content = reply
|
this.scrollToBottom()
|
||||||
} catch (error) {
|
})
|
||||||
console.error('Gemini 对话失败:', error)
|
.onError((err) => {
|
||||||
aiMsg.content = (error && error.message) || '抱歉,处理您的请求时出现错误,请稍后再试。'
|
errorMsg = (err && err.message) || ''
|
||||||
|
console.error('Gemini 流式对话失败:', err)
|
||||||
|
})
|
||||||
|
.onComplete(() => {
|
||||||
|
if (!receivedAny) {
|
||||||
|
aiMsg.content = errorMsg || '抱歉,处理您的请求时出现错误,请稍后再试。'
|
||||||
}
|
}
|
||||||
|
this._streamCtrl = null
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------- TTS 方法 ----------
|
// ---------- TTS 方法 ----------
|
||||||
@@ -793,6 +810,11 @@ export default {
|
|||||||
// #ifdef MP-WEIXIN || APP-PLUS
|
// #ifdef MP-WEIXIN || APP-PLUS
|
||||||
const ctx = uni.createInnerAudioContext()
|
const ctx = uni.createInnerAudioContext()
|
||||||
ctx.onEnded(() => {
|
ctx.onEnded(() => {
|
||||||
|
// test-0415 反馈3-3:分句队列里还有下一句则继续播;否则结束
|
||||||
|
if (this.ttsPlaying && (this._ttsPrefetch || (this._ttsQueue && this._ttsQueue.length > 0))) {
|
||||||
|
this._playNextTTSChunk()
|
||||||
|
return
|
||||||
|
}
|
||||||
this.ttsPlaying = false
|
this.ttsPlaying = false
|
||||||
this.ttsPlayingIndex = -1
|
this.ttsPlayingIndex = -1
|
||||||
})
|
})
|
||||||
@@ -812,36 +834,83 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分句播放 TTS(test-0415 反馈3-3)
|
||||||
|
* - 旧版:把全文丢给 TTS → 大文本合成耗时长 → 首声延迟 4-8s
|
||||||
|
* - 现行:按中文句号/问号/感叹号/分号切分;首句合成立即播放,
|
||||||
|
* 下一句在前一句播放期间预合成,形成流水线
|
||||||
|
*/
|
||||||
async playTTS(index) {
|
async playTTS(index) {
|
||||||
const msg = this.messageList[index]
|
const msg = this.messageList[index]
|
||||||
if (!msg || !msg.content) return
|
if (!msg || !msg.content) return
|
||||||
|
if (this.ttsPlaying) this.stopTTS()
|
||||||
|
|
||||||
if (this.ttsPlaying) {
|
const sentences = this.splitTTSSentences(msg.content)
|
||||||
this.stopTTS()
|
if (sentences.length === 0) return
|
||||||
|
|
||||||
|
this.ttsPlayingIndex = index
|
||||||
|
this.ttsPlaying = true
|
||||||
|
this._ttsQueue = sentences.slice()
|
||||||
|
this._ttsCurrentIndex = index
|
||||||
|
// 预先发起首句合成(首字节最快)
|
||||||
|
this._ttsPrefetch = api.cozeTextToSpeech({ input: this._ttsQueue.shift() })
|
||||||
|
this._playNextTTSChunk()
|
||||||
|
},
|
||||||
|
|
||||||
|
async _playNextTTSChunk() {
|
||||||
|
if (!this.ttsPlaying || this.ttsPlayingIndex !== this._ttsCurrentIndex) return
|
||||||
|
let prefetch = this._ttsPrefetch
|
||||||
|
this._ttsPrefetch = null
|
||||||
|
|
||||||
|
// 提前发起下一句合成(pipeline)
|
||||||
|
if (this._ttsQueue && this._ttsQueue.length > 0) {
|
||||||
|
const nextText = this._ttsQueue.shift()
|
||||||
|
this._ttsPrefetch = api.cozeTextToSpeech({ input: nextText })
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tempPath = await api.cozeTextToSpeech({ input: msg.content })
|
const tempPath = await prefetch
|
||||||
if (!this.innerAudioContext) {
|
if (!this.innerAudioContext || !this.ttsPlaying) return
|
||||||
console.warn('[TTS] innerAudioContext 未初始化')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.ttsPlayingIndex = index
|
|
||||||
this.ttsPlaying = true
|
|
||||||
this.innerAudioContext.src = tempPath
|
this.innerAudioContext.src = tempPath
|
||||||
this.innerAudioContext.play()
|
this.innerAudioContext.play()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[TTS] 合成失败', e)
|
console.error('[TTS] 合成失败', e)
|
||||||
|
if (this._ttsQueue && this._ttsQueue.length > 0) {
|
||||||
|
// 当前句失败,跳到下一句
|
||||||
|
this._playNextTTSChunk()
|
||||||
|
} else {
|
||||||
uni.showToast({ title: '语音合成失败', icon: 'none' })
|
uni.showToast({ title: '语音合成失败', icon: 'none' })
|
||||||
this.ttsPlaying = false
|
this.ttsPlaying = false
|
||||||
this.ttsPlayingIndex = -1
|
this.ttsPlayingIndex = -1
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 中文句末标点切分;保留标点;空白过滤;超长(>120 字)强切 */
|
||||||
|
splitTTSSentences(text) {
|
||||||
|
if (!text) return []
|
||||||
|
const re = /[^。!?!?;;\n]+[。!?!?;;]?/g
|
||||||
|
const arr = (String(text).match(re) || [])
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
const result = []
|
||||||
|
for (const s of arr) {
|
||||||
|
if (s.length <= 120) {
|
||||||
|
result.push(s)
|
||||||
|
} else {
|
||||||
|
// 超长无终点:按 80 字硬切
|
||||||
|
for (let i = 0; i < s.length; i += 80) result.push(s.slice(i, i + 80))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
},
|
},
|
||||||
|
|
||||||
stopTTS() {
|
stopTTS() {
|
||||||
if (this.innerAudioContext) {
|
if (this.innerAudioContext) {
|
||||||
this.innerAudioContext.stop()
|
this.innerAudioContext.stop()
|
||||||
}
|
}
|
||||||
|
this._ttsQueue = []
|
||||||
|
this._ttsPrefetch = null
|
||||||
this.ttsPlaying = false
|
this.ttsPlaying = false
|
||||||
this.ttsPlayingIndex = -1
|
this.ttsPlayingIndex = -1
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user