Files
msh-system/msh_single_uniapp/api/models-api.js
msh-agent 2facd355ab 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
2026-03-31 07:07:21 +08:00

672 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// API服务工具文件
// 统一访问 crmeb-front 项目,基地址来自 config/app.js
import { domain, TOKENNAME } from '@/config/app.js'
import store from '@/store'
const API_BASE_URL = domain
/**
* 通用请求方法
* @param {string} url 请求地址
* @param {object} options 请求配置
* @returns {Promise} 请求结果
*/
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) => {
if (res.statusCode === 200) {
resolve(res.data)
} else {
reject(new Error(`请求失败: ${res.statusCode}`))
}
},
fail: (error) => {
reject(error)
}
})
})
}
/**
* 根据ID获取文章详情
* @param {string|number} id 文章ID
* @returns {Promise} 文章详情数据
*/
function getArticleById(id) {
return request(`/api/front/article-models/${id}`)
}
/**
* 获取文章列表
* @param {object} params 查询参数
* @param {number} params.page 页码默认1
* @param {number} params.size 每页数量默认10
* @returns {Promise} 文章列表数据
*/
function getArticleList(params = { page: 1, size: 10 }) {
// 构建查询字符串(兼容小程序环境)
const queryString = Object.keys(params)
.filter(key => params[key] !== undefined && params[key] !== null)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
.join('&')
const url = queryString ? `/api/front/article-models?${queryString}` : '/api/front/article-models'
return request(url, {
method: 'GET'
})
}
/**
* 根据条件查询文章列表
* @param {object} params 查询参数
* @param {number} params.statusTask 任务状态(可选)
* @param {string} params.uid 用户ID可选
* @param {string} params.tags 标签(可选)
* @param {string} params.type 文章类型(可选)
* @param {number} params.page 页码默认1
* @param {number} params.size 每页数量默认10
* @returns {Promise} 符合条件的文章列表数据
*/
function searchArticles(params = { page: 1, size: 10 }) {
// 构建查询字符串(兼容小程序环境)
const queryString = Object.keys(params)
.filter(key => params[key] !== undefined && params[key] !== null)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
.join('&')
const url = queryString ? `/api/front/article-models/search?${queryString}` : '/api/front/article-models/search'
return request(url, {
method: 'GET'
})
}
/**
* 创建文生视频任务
* @param {object} params 创建参数
* @param {string} params.prompt 文本描述
* @param {Array} params.image_urls 图片URL数组可选
* @param {string} params.aspect_ratio 视频比例,如 'portrait' 或 '16:9'
* @param {number} params.n_frames 帧数默认5
* @param {string} params.size 视频尺寸,默认 'standard'
* @param {boolean} params.remove_watermark 是否移除水印默认true
* @returns {Promise} 任务创建结果
*/
function createTextToVideoTask(params) {
const requestBody = {
model: "sora-2-text-to-video",
title: params.prompt,
task_id: "",
nickname: params.nickname || "",
uid: params.uid || "",
input: {
prompt: params.prompt,
image_urls: params.image_urls || [],
aspect_ratio: params.aspect_ratio || "portrait",
n_frames: params.n_frames || 5,
size: params.size || "standard",
remove_watermark: params.remove_watermark !== false
}
}
return request('/api/front/kieai/text-to-video', {
method: 'POST',
data: requestBody
})
}
/**
* 创建图生视频任务
* @param {object} params 创建参数
* @param {string} params.imageUrl 图片URL
* @param {string} params.prompt 文本描述(可选)
* @param {string} params.aspect_ratio 视频比例,如 'portrait' 或 '16:9'
* @param {number} params.n_frames 帧数默认5
* @param {string} params.size 视频尺寸,默认 'standard'
* @param {boolean} params.remove_watermark 是否移除水印默认true
* @returns {Promise} 任务创建结果
*/
function createImageToVideoTask(params) {
const requestBody = {
model: "sora-2-image-to-video",
title: params.prompt || "根据图片生成视频",
task_id: "",
nickname: params.nickname || "",
uid: params.uid || "",
input: {
prompt: params.prompt || "根据图片生成视频",
image_urls: [params.imageUrl],
aspect_ratio: params.aspect_ratio || "portrait",
n_frames: params.n_frames || 5,
size: params.size || "standard",
remove_watermark: params.remove_watermark !== false
}
}
return request('/api/front/kieai/image-to-video', {
method: 'POST',
data: requestBody
})
}
/**
* 创建图片编辑任务
* @param {object} params 创建参数
* @param {string} params.prompt 编辑描述,如"改一下"、"去掉背景"
* @param {Array} params.image_urls 图片URL数组
* @param {string} params.output_format 输出格式,默认'png'
* @param {string} params.image_size 图片尺寸,默认'2:3'
* @param {string} params.title 任务标题默认使用prompt
* @param {string} params.task_id 任务ID可选
* @returns {Promise} 任务创建结果
*/
function createImageEditTask(params) {
const requestBody = {
model: "google/nano-banana-edit",
title: params.title || params.prompt || "图片编辑",
task_id: params.task_id || "",
nickname: params.nickname || "",
uid: params.uid || "",
input: {
prompt: params.prompt,
image_urls: params.image_urls || [],
output_format: params.output_format || "png",
image_size: params.image_size || "2:3",
aspect_ratio: params.aspect_ratio || "9:16",
resolution: params.resolution || "2K",
}
}
return request('/api/front/kieai/image-edit', {
method: 'POST',
data: requestBody
})
}
/**
* 创建图片编辑任务
* @param {object} params 创建参数
* @param {string} params.prompt 编辑描述,如"改一下"、"去掉背景"
* @param {Array} params.image_urls 图片URL数组
* @param {string} params.output_format 输出格式,默认'png'
* @param {string} params.aspect_ratio 图片尺寸,默认'2:3'
* @param {string} params.title 任务标题默认使用prompt
* @param {string} params.task_id 任务ID可选
* @returns {Promise} 任务创建结果
*/
function createImageEditTaskPro(params) {
const requestBody = {
model: "nano-banana-pro",
title: params.title || params.prompt || "图片编辑",
task_id: params.task_id || "",
nickname: params.nickname || "",
uid: params.uid || "",
input: {
prompt: params.prompt,
image_urls: params.image_urls || [],
output_format: params.output_format || "png",
aspect_ratio: params.aspect_ratio || "2:3",
resolution: params.resolution || "2K"
}
}
return request('/api/front/kieai/image-edit', {
method: 'POST',
data: requestBody
})
}
/**
* 用户上传文件
* @param {string} filePath 文件路径
* @param {object} options 上传配置
* @param {string} options.model 模块类型,默认'user'
* @param {string} options.pid 分类ID默认'0'
* @returns {Promise} 上传结果
*/
function uploadFile(filePath, options = {}) {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${API_BASE_URL}/api/front/upload/imageOuter`,
filePath: filePath,
name: 'multipart',
formData: {
model: options.model || 'user',
pid: options.pid || '0'
},
success: (res) => {
try {
let data = res.data
if (typeof data === 'string') {
data = JSON.parse(data)
}
if (!data || typeof data !== 'object') {
throw new Error('响应格式异常')
}
if (data.code === 200) {
// 拼接完整的图片URL
const fullUrl = data.data.url.startsWith('http')
? data.data.url
: `https://uthink2025.oss-cn-shanghai.aliyuncs.com/${data.data.url}`
resolve({
...data,
data: {
...data.data,
fullUrl: fullUrl
}
})
} else {
reject(new Error(data.message || '上传失败'))
}
} catch (error) {
const raw = typeof res.data === 'string' ? res.data.slice(0, 300) : String(res.data || '')
const msg = error.message || '响应数据解析失败'
reject(new Error(msg + (raw ? ' body: ' + raw : '')))
}
},
fail: (error) => {
reject(new Error('上传请求失败'))
}
})
})
}
/**
* 创建语音识别任务
* @param {object} params 请求参数
* @param {string} params.url 音频文件URL
* @param {string} params.engineModelType 引擎模型类型默认16k_zh
* @param {number} params.channelNum 声道数默认1
* @param {number} params.resTextFormat 结果文本格式默认0
* @param {number} params.sourceType 源类型默认0
* @returns {Promise} 识别任务信息
*/
function createAsrTask(params) {
const defaultParams = {
engineModelType: '16k_zh',
channelNum: 1,
resTextFormat: 0,
sourceType: 0,
filterDirty: false,
filterModal: false,
convertNumMode: false,
wordInfo: false
}
return request('/api/front/tencent/asr/create-task', {
method: 'POST',
data: {
...defaultParams,
...params
}
})
}
/**
* 查询语音识别任务状态
* @param {string|number} taskId 任务ID
* @returns {Promise} 任务状态和识别结果
*/
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 ====================
/**
* Coze - 发起对话 (Chat)
* @param {object} data 请求参数
* @param {string} data.bot_id 机器人ID
* @param {string} data.user_id 用户ID
* @param {Array} data.additional_messages 附加消息列表
* @param {boolean} data.stream 是否流式返回
* @param {boolean} data.auto_save_history 是否自动保存历史
* @param {object} data.meta_data 元数据
* @returns {Promise} 对话响应
*/
function cozeChat(data) {
return request('/api/front/coze/chat', {
method: 'POST',
data: data
})
}
/**
* Coze - 流式对话 (Chat Stream via SSE + enableChunked)
* 使用微信小程序 enableChunked 能力消费 SSE 事件流
* @param {object} data 请求参数(与 cozeChat 一致)
* @returns {object} 控制器 { onMessage, onError, onComplete, abort, getTask }
*/
function cozeChatStream(data) {
let _onMessage = () => {}
let _onError = () => {}
let _onComplete = () => {}
let _buffer = ''
let _task = null
let _gotChunks = false
const controller = {
onMessage(fn) { _onMessage = fn; return controller },
onError(fn) { _onError = fn; return controller },
onComplete(fn) { _onComplete = fn; return controller },
abort() { if (_task) _task.abort() },
getTask() { return _task }
}
const parseSseLines = (text) => {
_buffer += text
const lines = _buffer.split('\n')
_buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith(':')) continue
if (trimmed.startsWith('data:')) {
const jsonStr = trimmed.slice(5).trim()
if (!jsonStr) continue
try {
const evt = JSON.parse(jsonStr)
_onMessage(evt)
} catch (e) {
console.warn('[cozeChatStream] JSON parse failed in chunk, raw:', jsonStr.slice(0, 200))
}
}
}
}
const parseSseResponseBody = (body) => {
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()
if (!trimmed || trimmed.startsWith(':')) continue
if (trimmed.startsWith('data:')) {
const jsonStr = trimmed.slice(5).trim()
if (!jsonStr) continue
try {
const evt = JSON.parse(jsonStr)
_onMessage(evt)
} catch (e) {
console.warn('[cozeChatStream] JSON parse failed in body fallback, raw:', jsonStr.slice(0, 200))
}
}
}
}
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 ? { [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')
}
if (!_gotChunks && res && res.data) {
const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data)
parseSseResponseBody(body)
}
_onComplete()
},
fail: (err) => {
console.error('[cozeChatStream] request fail:', err)
_onError(err)
}
})
if (_task && _task.onChunkReceived) {
_task.onChunkReceived((res) => {
_gotChunks = true
try {
const bytes = new Uint8Array(res.data)
let text = ''
for (let i = 0; i < bytes.length; i++) {
text += String.fromCharCode(bytes[i])
}
text = decodeURIComponent(escape(text))
console.log('[cozeChatStream] chunk received, decoded length:', text.length)
parseSseLines(text)
} catch (e) {
console.warn('[cozeChatStream] chunk decode error:', e)
}
})
}
return controller
}
/**
* Coze - 检索对话详情 (Retrieve Chat)
* @param {object} params 请求参数
* @param {string} params.conversationId 会话ID
* @param {string} params.chatId 对话ID
* @returns {Promise} 对话详情
*/
function cozeRetrieveChat(params) {
return request('/api/front/coze/chat/retrieve', {
method: 'POST',
data: params
})
}
/**
* Coze - 查看对话消息详情 (List Messages)
* @param {object} params 请求参数
* @param {string} params.conversationId 会话ID
* @param {string} params.chatId 对话ID
* @returns {Promise} 消息列表
*/
function cozeMessageList(params) {
return request('/api/front/coze/chat/messages/list', {
method: 'POST',
data: params
})
}
/**
* Coze - 执行工作流 (Run Workflow)
* @param {object} data 请求参数
* @param {string} data.workflowId 工作流ID
* @param {object} data.parameters 工作流参数
* @param {boolean} data.isAsync 是否异步
* @returns {Promise} 执行结果
*/
function cozeWorkflowRun(data) {
return request('/api/front/coze/workflow/run', {
method: 'POST',
data: data
})
}
/**
* Coze - 执行工作流 (Run Workflow Stream)
* @param {object} data 请求参数
* @param {string} data.workflowId 工作流ID
* @param {object} data.parameters 工作流参数
* @returns {Promise} 执行结果
*/
function cozeWorkflowStream(data) {
return request('/api/front/coze/workflow/stream', {
method: 'POST',
data: data
})
}
/**
* Coze - 恢复工作流 (Resume Workflow)
* @param {object} data 请求参数
* @param {string} data.workflow_id 工作流ID
* @param {string} data.event_id 事件ID
* @param {string} data.resume_data 恢复数据
* @param {number} data.resume_type 恢复类型
* @returns {Promise} 执行结果
*/
function cozeWorkflowResume(data) {
return request('/api/front/coze/workflow/resume', {
method: 'POST',
data: data
})
}
/**
* Coze - 上传文件 (Upload File)
* @param {string} filePath 文件路径
* @returns {Promise} 上传结果
*/
function cozeUploadFile(filePath) {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${API_BASE_URL}/api/front/coze/file/upload`,
filePath: filePath,
name: 'file',
success: (res) => {
if (res.statusCode === 200) {
try {
const data = JSON.parse(res.data)
resolve(data)
} catch (e) {
reject(new Error('响应解析失败'))
}
} else {
reject(new Error(`上传失败: ${res.statusCode}`))
}
},
fail: (err) => {
reject(err)
}
})
})
}
/**
* 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,
getArticleList,
searchArticles,
createTextToVideoTask,
createImageToVideoTask,
createImageEditTask,
createImageEditTaskPro,
uploadFile,
createAsrTask,
queryAsrStatus,
kieaiGeminiChat,
// Coze API
cozeChat,
cozeChatStream,
cozeRetrieveChat,
cozeMessageList,
cozeWorkflowRun,
cozeWorkflowStream,
cozeWorkflowResume,
cozeUploadFile,
cozeTextToSpeech
}