From 8868d6f94801832e9cc97ad3f4bfe2fe0707e73c Mon Sep 17 00:00:00 2001 From: msh-agent Date: Sun, 3 May 2026 03:17:00 +0800 Subject: [PATCH] =?UTF-8?q?perf(ai-nutritionist):=20=E6=96=87=E5=AD=97?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=20+=20TTS=20=E5=88=86=E5=8F=A5=E9=A6=96?= =?UTF-8?q?=E6=92=AD=EF=BC=88test-0415=20=E5=8F=8D=E9=A6=883-2/3-3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../pages/tool/ai-nutritionist.vue | 129 ++++++++++++++---- 1 file changed, 99 insertions(+), 30 deletions(-) diff --git a/msh_single_uniapp/pages/tool/ai-nutritionist.vue b/msh_single_uniapp/pages/tool/ai-nutritionist.vue index 7a755b5..f6d78b0 100644 --- a/msh_single_uniapp/pages/tool/ai-nutritionist.vue +++ b/msh_single_uniapp/pages/tool/ai-nutritionist.vue @@ -767,24 +767,41 @@ export default { } }, - // ========== KieAI Gemini(非流式,BUG-005) ========== - async _sendViaGemini(content, type, aiMsg) { - const messages = this.buildChatMessages(content, type) - try { - const response = await api.kieaiGeminiChat({ messages, stream: false }) - // BUG-005:成功时仅展示 data.choices[0].message.content(经规整);禁止 getAIResponse / 本地固定话术冒充模型输出 - let reply = api.readKieaiGeminiDataChoicesAssistantText(response && response.data) - if (!reply) { - reply = (api.getKieaiGeminiChatMessageContent(response) || '').trim() - } - if (!reply) { - throw new Error('模型未返回有效内容') - } - aiMsg.content = reply - } catch (error) { - console.error('Gemini 对话失败:', error) - aiMsg.content = (error && error.message) || '抱歉,处理您的请求时出现错误,请稍后再试。' - } + // ========== KieAI Gemini(流式 SSE,test-0415 反馈3-2)========== + // 旧版 stream: false 等待全文返回 → 5-15s 才出文字。 + // 现行:调用 kieaiGeminiChatStream,首包到达即停 loading 圈,逐 chunk 渲染 → TTFB <1.5s + _sendViaGemini(content, type, aiMsg) { + return new Promise((resolve) => { + const messages = this.buildChatMessages(content, type) + let receivedAny = false + let errorMsg = '' + + const ctrl = api.kieaiGeminiChatStream({ messages }) + this._streamCtrl = ctrl + ctrl.onMessage((delta) => { + if (!receivedAny) { + receivedAny = true + aiMsg.loading = false + aiMsg.streaming = true + this.messageList = [...this.messageList] + } + aiMsg.content += delta + // 每个 chunk 触发响应式刷新(保证 streaming 光标移动) + this.messageList = [...this.messageList] + this.scrollToBottom() + }) + .onError((err) => { + errorMsg = (err && err.message) || '' + console.error('Gemini 流式对话失败:', err) + }) + .onComplete(() => { + if (!receivedAny) { + aiMsg.content = errorMsg || '抱歉,处理您的请求时出现错误,请稍后再试。' + } + this._streamCtrl = null + resolve() + }) + }) }, // ---------- TTS 方法 ---------- @@ -793,6 +810,11 @@ export default { // #ifdef MP-WEIXIN || APP-PLUS const ctx = uni.createInnerAudioContext() ctx.onEnded(() => { + // test-0415 反馈3-3:分句队列里还有下一句则继续播;否则结束 + if (this.ttsPlaying && (this._ttsPrefetch || (this._ttsQueue && this._ttsQueue.length > 0))) { + this._playNextTTSChunk() + return + } this.ttsPlaying = false this.ttsPlayingIndex = -1 }) @@ -812,36 +834,83 @@ export default { } }, + /** + * 分句播放 TTS(test-0415 反馈3-3) + * - 旧版:把全文丢给 TTS → 大文本合成耗时长 → 首声延迟 4-8s + * - 现行:按中文句号/问号/感叹号/分号切分;首句合成立即播放, + * 下一句在前一句播放期间预合成,形成流水线 + */ async playTTS(index) { const msg = this.messageList[index] if (!msg || !msg.content) return + if (this.ttsPlaying) this.stopTTS() - if (this.ttsPlaying) { - this.stopTTS() + const sentences = this.splitTTSSentences(msg.content) + 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 { - const tempPath = await api.cozeTextToSpeech({ input: msg.content }) - if (!this.innerAudioContext) { - console.warn('[TTS] innerAudioContext 未初始化') - return - } - this.ttsPlayingIndex = index - this.ttsPlaying = true + const tempPath = await prefetch + if (!this.innerAudioContext || !this.ttsPlaying) return this.innerAudioContext.src = tempPath this.innerAudioContext.play() } catch (e) { console.error('[TTS] 合成失败', e) - uni.showToast({ title: '语音合成失败', icon: 'none' }) - this.ttsPlaying = false - this.ttsPlayingIndex = -1 + if (this._ttsQueue && this._ttsQueue.length > 0) { + // 当前句失败,跳到下一句 + this._playNextTTSChunk() + } else { + uni.showToast({ title: '语音合成失败', icon: 'none' }) + this.ttsPlaying = false + 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() { if (this.innerAudioContext) { this.innerAudioContext.stop() } + this._ttsQueue = [] + this._ttsPrefetch = null this.ttsPlaying = false this.ttsPlayingIndex = -1 },