feat(ai-nutritionist): Coze TTS and streaming robustness

- Add Coze TTS endpoint and service; expose binary MP3 from controller.
- Bypass ResponseFilter for /audio/speech so MP3 bodies are not UTF-8 wrapped.
- UniApp: cozeTextToSpeech, TTS UI and play flow; SSE HTTP errors and diagnostics.
- Document TTS in docs/features.md; extend test-0325-1 with curl verification.

Made-with: Cursor
This commit is contained in:
msh-agent
2026-03-31 07:07:21 +08:00
parent 35052d655f
commit 2facd355ab
8 changed files with 433 additions and 351 deletions

View File

@@ -1,6 +1,8 @@
// API服务工具文件
// 统一访问 crmeb-front 项目,基地址来自 config/app.js
import { domain } from '@/config/app.js'
import { domain, TOKENNAME } from '@/config/app.js'
import store from '@/store'
const API_BASE_URL = domain
/**
@@ -11,12 +13,14 @@ const API_BASE_URL = domain
*/
function request(url, options = {}) {
return new Promise((resolve, reject) => {
const token = store.state && store.state.app && store.state.app.token
uni.request({
url: `${API_BASE_URL}${url}`,
method: options.method || 'GET',
data: options.data || {},
header: {
'Content-Type': 'application/json',
...(token ? { [TOKENNAME]: token } : {}),
...options.header
},
success: (res) => {
@@ -318,6 +322,33 @@ function queryAsrStatus(taskId) {
return request(`/api/front/tencent/asr/query-status/${taskId}`)
}
// ==================== KieAI Gemini ChatBUG-005AI 营养师文本/多模态对话) ====================
/**
* KieAI Gemini 对话
* POST /api/front/kieai/gemini/chat
* 文本对话请求体: { messages: [{ role: 'user', content: 用户输入 }], stream: false }
* 多模态时 content 可为 OpenAI 风格 parts 数组。
* 成功时 HTTP 体为 CommonResult模型结果为 data 字段OpenAI 形态data.choices[0].message.content
* 页面展示必须从该 content 读取,禁止用语义无关的本地固定话术冒充模型输出。
* @param {object} data 请求体
* @param {Array<{role: string, content: string|Array}>} data.messages 消息列表
* @param {boolean} [data.stream=false] 是否流式
* @returns {Promise<{code: number, data: {choices?: Array<{message?: {content?: unknown}}>}}>}
*/
function kieaiGeminiChat(data) {
const messages = data && data.messages
if (!messages || !Array.isArray(messages) || messages.length === 0) {
return Promise.reject(new Error('messages 不能为空'))
}
// BUG-005仅传 messages + stream文本对话为 { messages: [{ role: 'user', content: 用户输入 }], stream: false }
const stream = data && typeof data.stream === 'boolean' ? data.stream : false
return request('/api/front/kieai/gemini/chat', {
method: 'POST',
data: { messages, stream }
})
}
// ==================== 扣子Coze API ====================
/**
@@ -331,21 +362,6 @@ function queryAsrStatus(taskId) {
* @param {object} data.meta_data 元数据
* @returns {Promise} 对话响应
*/
/**
* KieAI Gemini 对话POST /api/front/kieai/gemini/chat
* 文本对话请求体: { messages: [{ role: 'user', content: 用户输入 }], stream: false }
* @param {object} data 请求体
* @param {Array} data.messages 消息列表 [{ role: 'user'|'assistant'|'system', content: string|Array }]
* @param {boolean} data.stream 是否流式,默认 false
* @returns {Promise} 响应 data 为 Gemini 格式 { choices: [{ message: { content } }] },回复取 data.choices[0].message.content
*/
function kieaiGeminiChat(data) {
return request('/api/front/kieai/gemini/chat', {
method: 'POST',
data: data
})
}
function cozeChat(data) {
return request('/api/front/coze/chat', {
method: 'POST',
@@ -390,14 +406,18 @@ function cozeChatStream(data) {
const evt = JSON.parse(jsonStr)
_onMessage(evt)
} catch (e) {
// skip malformed JSON fragments
console.warn('[cozeChatStream] JSON parse failed in chunk, raw:', jsonStr.slice(0, 200))
}
}
}
}
const parseSseResponseBody = (body) => {
if (!body || typeof body !== 'string') return
if (!body || typeof body !== 'string') {
console.warn('[cozeChatStream] parseSseResponseBody: body is not a string, type:', typeof body)
return
}
console.log('[cozeChatStream] parseSseResponseBody: body length', body.length, 'preview:', body.slice(0, 200))
const lines = body.split('\n')
for (const line of lines) {
const trimmed = line.trim()
@@ -409,24 +429,36 @@ function cozeChatStream(data) {
const evt = JSON.parse(jsonStr)
_onMessage(evt)
} catch (e) {
// skip malformed JSON fragments
console.warn('[cozeChatStream] JSON parse failed in body fallback, raw:', jsonStr.slice(0, 200))
}
}
}
}
const token = uni.getStorageSync('LOGIN_STATUS_TOKEN') || ''
const token = store.state && store.state.app && store.state.app.token
_task = uni.request({
url: `${API_BASE_URL}/api/front/coze/chat/stream`,
method: 'POST',
data: data,
header: {
'Content-Type': 'application/json',
...(token ? { 'Authori-zation': token } : {})
...(token ? { [TOKENNAME]: token } : {})
},
enableChunked: true,
responseType: 'text',
success: (res) => {
console.log('[cozeChatStream] success: statusCode=', res.statusCode, '_gotChunks=', _gotChunks)
if (res.statusCode !== 200) {
let errMsg = '请求失败: ' + res.statusCode
try {
const body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
if (body && body.message) errMsg = body.message
else if (body && body.msg) errMsg = body.msg
} catch (e) { /* keep default message */ }
console.error('[cozeChatStream] HTTP error', res.statusCode, errMsg)
_onError(new Error(errMsg))
return
}
if (_buffer.trim()) {
parseSseLines('\n')
}
@@ -437,6 +469,7 @@ function cozeChatStream(data) {
_onComplete()
},
fail: (err) => {
console.error('[cozeChatStream] request fail:', err)
_onError(err)
}
})
@@ -451,9 +484,10 @@ function cozeChatStream(data) {
text += String.fromCharCode(bytes[i])
}
text = decodeURIComponent(escape(text))
console.log('[cozeChatStream] chunk received, decoded length:', text.length)
parseSseLines(text)
} catch (e) {
// chunk decode error, skip
console.warn('[cozeChatStream] chunk decode error:', e)
}
})
}
@@ -564,6 +598,53 @@ function cozeUploadFile(filePath) {
})
}
/**
* Coze - 文本转语音 (TTS)
* POST /api/front/coze/audio/speech
* 返回 Promise<string> 临时文件路径,可直接赋给 innerAudioContext.src 播放
* @param {object} data 请求参数
* @param {string} data.input 要合成的文本
* @param {string} [data.voiceId] 音色ID不传时后端使用默认中文音色
* @param {string} [data.format] 音频格式,默认 "mp3"
* @param {number} [data.speed] 语速,默认 1.0
* @returns {Promise<string>} 临时音频文件路径
*/
function cozeTextToSpeech(data) {
return new Promise((resolve, reject) => {
const token = store.state && store.state.app && store.state.app.token
uni.request({
url: `${API_BASE_URL}/api/front/coze/audio/speech`,
method: 'POST',
data: data,
header: {
'Content-Type': 'application/json',
...(token ? { [TOKENNAME]: token } : {})
},
responseType: 'arraybuffer',
success: (res) => {
if (res.statusCode === 200) {
const fs = uni.getFileSystemManager()
const tempPath = `${uni.env.USER_DATA_PATH}/tts_${Date.now()}.mp3`
fs.writeFile({
filePath: tempPath,
data: res.data, // ArrayBuffer — 不传 encoding否则数据会损坏
success: () => resolve(tempPath),
fail: (err) => reject(new Error('TTS 音频写入失败: ' + JSON.stringify(err)))
})
} else {
let errMsg = 'TTS 请求失败: ' + res.statusCode
try {
const body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
if (body && body.message) errMsg = body.message
} catch (e) { /* keep default */ }
reject(new Error(errMsg))
}
},
fail: (err) => reject(new Error('TTS 网络请求失败: ' + JSON.stringify(err)))
})
})
}
export default {
request,
getArticleById,
@@ -585,5 +666,6 @@ export default {
cozeWorkflowRun,
cozeWorkflowStream,
cozeWorkflowResume,
cozeUploadFile
cozeUploadFile,
cozeTextToSpeech
}