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:
@@ -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 Chat(BUG-005:AI 营养师文本/多模态对话) ====================
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user