- 旧链路把 mp3 上传到 /api/front/upload/imageOuter,被图片扩展名校验拒绝 - 改为本地读 base64 直接走 /api/front/tencent/asr/sentence-recognition(sourceType=1) - 适用 ≤60s 短音频场景,命中 ai-nutritionist 限时录音上限 60s 的设计 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1190 lines
38 KiB
JavaScript
1190 lines
38 KiB
JavaScript
// 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}`)
|
||
}
|
||
|
||
/**
|
||
* 一句话识别(≤60s 短音频,base64 直传,无需先走 OSS 上传,规避图片接口对 mp3 的扩展名校验)
|
||
* @param {string} base64Data 不含 data:URI 头的纯 base64
|
||
* @param {number} dataLen 解码后字节长度
|
||
* @param {string} format 音频格式,如 'mp3'
|
||
*/
|
||
function sentenceRecognition(base64Data, dataLen, format = 'mp3') {
|
||
return request('/api/front/tencent/asr/sentence-recognition', {
|
||
method: 'POST',
|
||
data: {
|
||
engineModelType: '16k_zh',
|
||
sourceType: 1,
|
||
data: base64Data,
|
||
dataLen: dataLen,
|
||
voiceFormat: format,
|
||
filterDirty: false,
|
||
filterModal: false,
|
||
convertNumMode: false
|
||
}
|
||
})
|
||
}
|
||
|
||
// ==================== KieAI Gemini Chat(BUG-005:AI 营养师文本/多模态对话) ====================
|
||
|
||
/** 将 message.content 规范为展示用字符串(多模态 parts / Gemini 嵌套结构) */
|
||
function normalizeGeminiContentToString(content) {
|
||
if (content == null) return ''
|
||
if (typeof content === 'number' || typeof content === 'boolean') return String(content)
|
||
if (typeof content === 'string') return content
|
||
if (Array.isArray(content)) {
|
||
return content
|
||
.map((part) => (part && part.text) ? part.text : '')
|
||
.filter(Boolean)
|
||
.join('')
|
||
}
|
||
if (typeof content === 'object') {
|
||
if (Array.isArray(content.parts)) {
|
||
return content.parts
|
||
.map((part) => (part && part.text) ? part.text : '')
|
||
.filter(Boolean)
|
||
.join('')
|
||
}
|
||
if (typeof content.text === 'string') return content.text
|
||
if (typeof content.content === 'string') return content.content
|
||
}
|
||
return ''
|
||
}
|
||
|
||
/**
|
||
* BUG-005:从「已是 OpenAI chat completion 形态」的对象上读取 data.choices[0].message.content(规整为字符串)。
|
||
* 用于 CommonResult.data 及浅层嵌套的 data/result/output/body。
|
||
*/
|
||
function readKieaiGeminiDataChoicesAssistantText(data) {
|
||
if (!data || typeof data !== 'object') return ''
|
||
if (!Array.isArray(data.choices) || !data.choices[0]) return ''
|
||
const msg = data.choices[0].message
|
||
if (!msg || typeof msg !== 'object') return ''
|
||
const t = normalizeGeminiContentToString(msg.content)
|
||
return t && t.trim() ? t.trim() : ''
|
||
}
|
||
|
||
/**
|
||
* 从单个 completion 节点读取首条助手正文(OpenAI choices 或 Gemini candidates)。
|
||
*/
|
||
function getFirstChoiceOrCandidateText(node) {
|
||
if (!node || typeof node !== 'object') return ''
|
||
if (Array.isArray(node.choices) && node.choices[0]) {
|
||
const msg = node.choices[0].message
|
||
if (msg) {
|
||
const t = normalizeGeminiContentToString(msg.content)
|
||
if (t && t.trim()) return t.trim()
|
||
}
|
||
const delta = node.choices[0].delta
|
||
if (delta) {
|
||
const t = normalizeGeminiContentToString(delta.content)
|
||
if (t && t.trim()) return t.trim()
|
||
}
|
||
}
|
||
if (Array.isArray(node.candidates) && node.candidates[0]) {
|
||
const t = normalizeGeminiContentToString(node.candidates[0].content)
|
||
if (t && t.trim()) return t.trim()
|
||
}
|
||
return ''
|
||
}
|
||
|
||
/**
|
||
* 将 CommonResult.data(或上游返回体)规范为含 choices/candidates 的对象,便于读 data.choices[0].message.content。
|
||
* 收集 data / result / output / body 链上所有带 choices 的节点,优先选用「首条正文非空」且更深的节点,
|
||
* 避免外层占位 choices 导致解析为空、误走其它逻辑。
|
||
*/
|
||
function unwrapGeminiCompletionData(payload) {
|
||
if (payload == null || typeof payload !== 'object') return payload
|
||
const seen = new Set()
|
||
const hits = []
|
||
function visit(node, depth) {
|
||
if (depth > 12 || node == null || typeof node !== 'object') return
|
||
if (seen.has(node)) return
|
||
seen.add(node)
|
||
const hasCh = Array.isArray(node.choices) && node.choices.length > 0
|
||
const hasCa = Array.isArray(node.candidates) && node.candidates.length > 0
|
||
if (hasCh || hasCa) {
|
||
const text = getFirstChoiceOrCandidateText(node)
|
||
hits.push({ node, depth, textLen: text.length })
|
||
}
|
||
for (const k of ['data', 'result', 'output', 'body']) {
|
||
const child = node[k]
|
||
if (child && typeof child === 'object') visit(child, depth + 1)
|
||
}
|
||
}
|
||
visit(payload, 0)
|
||
if (hits.length === 0) return payload
|
||
hits.sort((a, b) => {
|
||
if (a.textLen > 0 && b.textLen === 0) return -1
|
||
if (a.textLen === 0 && b.textLen > 0) return 1
|
||
if (a.depth !== b.depth) return b.depth - a.depth
|
||
return b.textLen - a.textLen
|
||
})
|
||
return hits[0].node
|
||
}
|
||
|
||
/** CommonResult.data 顶层已是 OpenAI choices 且首条助手正文非空时,不再 deep-unwrap,避免误选更深层的空 choices 节点(BUG-005)。 */
|
||
function hasNonEmptyFirstChoiceMessageContent(obj) {
|
||
if (!obj || typeof obj !== 'object') return false
|
||
if (!Array.isArray(obj.choices) || !obj.choices[0]) return false
|
||
const msg = obj.choices[0].message
|
||
if (!msg) return false
|
||
const t = normalizeGeminiContentToString(msg.content)
|
||
return !!(t && t.trim())
|
||
}
|
||
|
||
/**
|
||
* 从 kieaiGeminiChat() 的返回值读取模型正文。
|
||
* BUG-005:严格以 CommonResult.data 上的 OpenAI choices 为准,即 data.choices[0].message.content;
|
||
* 若网关/上游将 completion 再包一层(data.result.output),先浅层下钻再 deep-unwrap;
|
||
* 不生成本地固定话术,也不把业务失败当成功。
|
||
*/
|
||
function getKieaiGeminiChatMessageContent(apiResult) {
|
||
if (!apiResult || typeof apiResult !== 'object') return ''
|
||
let payload
|
||
if (Object.prototype.hasOwnProperty.call(apiResult, 'data')) {
|
||
payload = apiResult.data
|
||
if (payload == null) return ''
|
||
} else {
|
||
payload = apiResult
|
||
}
|
||
if (typeof payload === 'string') {
|
||
try {
|
||
payload = JSON.parse(payload)
|
||
} catch (e) {
|
||
return ''
|
||
}
|
||
}
|
||
if (typeof payload !== 'object' || payload == null) return ''
|
||
|
||
/** 浅层:payload 或其一阶子对象上是否已有非空 choices[0].message.content */
|
||
function tryShallowChoices(root) {
|
||
if (!root || typeof root !== 'object') return ''
|
||
let t = readKieaiGeminiDataChoicesAssistantText(root)
|
||
if (t) return t
|
||
for (const key of ['data', 'result', 'output', 'body']) {
|
||
const nested = root[key]
|
||
if (nested != null && typeof nested === 'object') {
|
||
t = readKieaiGeminiDataChoicesAssistantText(nested)
|
||
if (t) return t
|
||
const inner = nested.data
|
||
if (inner != null && typeof inner === 'object') {
|
||
t = readKieaiGeminiDataChoicesAssistantText(inner)
|
||
if (t) return t
|
||
}
|
||
}
|
||
}
|
||
return ''
|
||
}
|
||
|
||
let out = tryShallowChoices(payload)
|
||
if (out) return out
|
||
|
||
const node = unwrapGeminiCompletionData(payload)
|
||
out = readKieaiGeminiDataChoicesAssistantText(node)
|
||
if (out) return out
|
||
|
||
const fb = getFirstChoiceOrCandidateText(node)
|
||
return fb && fb.trim() ? fb.trim() : ''
|
||
}
|
||
|
||
/**
|
||
* 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 }
|
||
}).then((res) => {
|
||
// HTTP 200 时仍可能是 CommonResult 业务失败,禁止把失败当成功、用空内容走本地固定话术
|
||
const c = res && res.code
|
||
if (c != null && Number(c) !== 200) {
|
||
const msg = (res.message || res.msg || 'Gemini 对话失败').toString()
|
||
return Promise.reject(new Error(msg))
|
||
}
|
||
let outData = res && res.data
|
||
// 少数环境下 data 为 JSON 字符串,解析后便于页面读取 data.choices[0].message.content
|
||
if (typeof outData === 'string') {
|
||
try {
|
||
outData = JSON.parse(outData)
|
||
} catch (e) {
|
||
return res
|
||
}
|
||
}
|
||
if (outData != null && typeof outData === 'object' && !hasNonEmptyFirstChoiceMessageContent(outData)) {
|
||
let promoted = null
|
||
for (const key of ['data', 'result', 'output', 'body']) {
|
||
const nested = outData[key]
|
||
if (nested && typeof nested === 'object' && hasNonEmptyFirstChoiceMessageContent(nested)) {
|
||
promoted = nested
|
||
break
|
||
}
|
||
const d2 = nested && typeof nested === 'object' ? nested.data : null
|
||
if (d2 && typeof d2 === 'object' && hasNonEmptyFirstChoiceMessageContent(d2)) {
|
||
promoted = d2
|
||
break
|
||
}
|
||
}
|
||
outData = promoted != null ? promoted : unwrapGeminiCompletionData(outData)
|
||
}
|
||
return { ...res, data: outData }
|
||
})
|
||
}
|
||
|
||
/**
|
||
* KieAI Gemini 流式对话 (SSE + enableChunked)
|
||
* POST /api/front/kieai/gemini/chat stream=true
|
||
* 返回与 cozeChatStream 相同的 controller 接口 { onMessage, onError, onComplete, abort }
|
||
* onMessage(text) 每次收到一段增量文本
|
||
* @param {object} data 请求体 { messages }
|
||
* @returns {object} 控制器
|
||
*/
|
||
function kieaiGeminiChatStream(data) {
|
||
const messages = data && data.messages
|
||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||
const ctrl = {
|
||
onMessage() { return ctrl },
|
||
onError(fn) { fn(new Error('messages 不能为空')); return ctrl },
|
||
onComplete() { return ctrl },
|
||
abort() {}
|
||
}
|
||
return ctrl
|
||
}
|
||
|
||
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 }
|
||
}
|
||
|
||
/** 从 SSE data JSON 中提取增量文本 */
|
||
const extractDeltaText = (evt) => {
|
||
// OpenAI 兼容格式: choices[0].delta.content
|
||
if (evt && Array.isArray(evt.choices) && evt.choices[0]) {
|
||
const delta = evt.choices[0].delta
|
||
if (delta && typeof delta.content === 'string') return delta.content
|
||
// 非流式 fallback
|
||
const msg = evt.choices[0].message
|
||
if (msg && typeof msg.content === 'string') return msg.content
|
||
}
|
||
// Gemini 原生格式
|
||
if (evt && Array.isArray(evt.candidates) && evt.candidates[0]) {
|
||
const parts = evt.candidates[0].content && evt.candidates[0].content.parts
|
||
if (Array.isArray(parts) && parts[0] && typeof parts[0].text === 'string') return parts[0].text
|
||
}
|
||
return ''
|
||
}
|
||
|
||
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 === 'data: [DONE]') continue
|
||
if (trimmed.startsWith('data:')) {
|
||
const jsonStr = trimmed.slice(5).trim()
|
||
if (!jsonStr) continue
|
||
try {
|
||
const evt = JSON.parse(jsonStr)
|
||
const delta = extractDeltaText(evt)
|
||
if (delta) _onMessage(delta)
|
||
} catch (e) {
|
||
console.warn('[kieaiGeminiChatStream] JSON parse failed:', jsonStr.slice(0, 200))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const token = store.state && store.state.app && store.state.app.token
|
||
// 使用专用流式端点 /chat/stream(声明了 produces=text/event-stream),
|
||
// 而非 /chat + stream=true,避免 nginx 缓冲 SSE 响应导致流式失效
|
||
_task = uni.request({
|
||
url: `${API_BASE_URL}/api/front/kieai/gemini/chat/stream`,
|
||
method: 'POST',
|
||
data: { messages, stream: true },
|
||
header: {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'text/event-stream',
|
||
...(token ? { [TOKENNAME]: token } : {})
|
||
},
|
||
enableChunked: true,
|
||
responseType: 'text',
|
||
success: (res) => {
|
||
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 */ }
|
||
_onError(new Error(errMsg))
|
||
return
|
||
}
|
||
// 处理剩余 buffer
|
||
if (_buffer.trim()) parseSseLines('\n')
|
||
// 如果 enableChunked 不支持(非微信小程序),从完整 response body 解析
|
||
if (!_gotChunks && res && res.data) {
|
||
const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data)
|
||
parseSseLines(body + '\n')
|
||
}
|
||
_onComplete()
|
||
},
|
||
fail: (err) => {
|
||
console.error('[kieaiGeminiChatStream] 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))
|
||
parseSseLines(text)
|
||
} catch (e) {
|
||
console.warn('[kieaiGeminiChatStream] chunk decode error:', e)
|
||
}
|
||
})
|
||
}
|
||
|
||
return controller
|
||
}
|
||
|
||
// ==================== 扣子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)))
|
||
})
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 获取 AI 对话模型配置
|
||
* @returns {Promise} { data: { model: "doubao" | "coze" | "gemini" } }
|
||
*/
|
||
function getAiModelConfig() {
|
||
return request('/api/front/doubao/ai-model-config')
|
||
}
|
||
|
||
/**
|
||
* 豆包(火山引擎 Ark)- 非流式对话
|
||
* @param {object} data 请求参数 { messages: [{role, content}], model?, temperature?, maxTokens? }
|
||
* @returns {Promise} 对话响应(OpenAI Chat Completions 格式)
|
||
*/
|
||
function doubaoChat(data) {
|
||
return request('/api/front/doubao/chat', {
|
||
method: 'POST',
|
||
data: data
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 豆包(火山引擎 Ark)- 流式对话 (SSE + enableChunked)
|
||
* 返回 OpenAI 兼容的 SSE 事件流:choices[0].delta.content
|
||
* @param {object} data 请求参数 { messages: [{role, content}] }
|
||
* @returns {object} 控制器 { onMessage(deltaText), onError, onComplete, abort }
|
||
*/
|
||
function doubaoChatStream(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 }
|
||
}
|
||
|
||
/** 从 SSE data JSON 中提取增量文本(OpenAI 兼容格式) */
|
||
const extractDeltaText = (evt) => {
|
||
if (evt && Array.isArray(evt.choices) && evt.choices[0]) {
|
||
const delta = evt.choices[0].delta
|
||
if (delta && typeof delta.content === 'string') return delta.content
|
||
const msg = evt.choices[0].message
|
||
if (msg && typeof msg.content === 'string') return msg.content
|
||
}
|
||
return ''
|
||
}
|
||
|
||
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 === 'data: [DONE]') continue
|
||
if (trimmed.startsWith('data:')) {
|
||
const jsonStr = trimmed.slice(5).trim()
|
||
if (!jsonStr) continue
|
||
try {
|
||
const evt = JSON.parse(jsonStr)
|
||
const delta = extractDeltaText(evt)
|
||
if (delta) _onMessage(delta)
|
||
} catch (e) {
|
||
console.warn('[doubaoChatStream] JSON parse failed:', jsonStr.slice(0, 200))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const parseSseResponseBody = (body) => {
|
||
if (!body || typeof body !== 'string') return
|
||
const lines = body.split('\n')
|
||
for (const line of lines) {
|
||
const trimmed = line.trim()
|
||
if (!trimmed || trimmed.startsWith(':')) continue
|
||
if (trimmed === 'data: [DONE]') continue
|
||
if (trimmed.startsWith('data:')) {
|
||
const jsonStr = trimmed.slice(5).trim()
|
||
if (!jsonStr) continue
|
||
try {
|
||
const evt = JSON.parse(jsonStr)
|
||
const delta = extractDeltaText(evt)
|
||
if (delta) _onMessage(delta)
|
||
} catch (e) {
|
||
console.warn('[doubaoChatStream] JSON parse failed in body fallback:', jsonStr.slice(0, 200))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const token = store.state && store.state.app && store.state.app.token
|
||
_task = uni.request({
|
||
url: `${API_BASE_URL}/api/front/doubao/chat/stream`,
|
||
method: 'POST',
|
||
data: data,
|
||
header: {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'text/event-stream',
|
||
...(token ? { [TOKENNAME]: token } : {})
|
||
},
|
||
enableChunked: true,
|
||
responseType: 'text',
|
||
success: (res) => {
|
||
console.log('[doubaoChatStream] 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.error && body.error.message) errMsg = body.error.message
|
||
} catch (e) { /* keep default message */ }
|
||
_onError(new Error(errMsg))
|
||
return
|
||
}
|
||
// 处理 buffer 中残余内容
|
||
if (_buffer.trim()) {
|
||
parseSseLines('\n')
|
||
}
|
||
// 不支持 chunked 的环境降级:从完整 response body 解析
|
||
if (!_gotChunks && res && res.data) {
|
||
const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data)
|
||
parseSseResponseBody(body)
|
||
}
|
||
_onComplete()
|
||
},
|
||
fail: (err) => {
|
||
console.error('[doubaoChatStream] 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))
|
||
parseSseLines(text)
|
||
} catch (e) {
|
||
console.warn('[doubaoChatStream] chunk decode error:', e)
|
||
}
|
||
})
|
||
}
|
||
|
||
return controller
|
||
}
|
||
|
||
export default {
|
||
request,
|
||
getArticleById,
|
||
getArticleList,
|
||
searchArticles,
|
||
createTextToVideoTask,
|
||
createImageToVideoTask,
|
||
createImageEditTask,
|
||
createImageEditTaskPro,
|
||
uploadFile,
|
||
createAsrTask,
|
||
queryAsrStatus,
|
||
sentenceRecognition,
|
||
kieaiGeminiChat,
|
||
kieaiGeminiChatStream,
|
||
getKieaiGeminiChatMessageContent,
|
||
normalizeGeminiContentToString,
|
||
readKieaiGeminiDataChoicesAssistantText,
|
||
// Coze API
|
||
cozeChat,
|
||
cozeChatStream,
|
||
cozeRetrieveChat,
|
||
cozeMessageList,
|
||
cozeWorkflowRun,
|
||
cozeWorkflowStream,
|
||
cozeWorkflowResume,
|
||
cozeUploadFile,
|
||
cozeTextToSpeech,
|
||
// 豆包 API
|
||
doubaoChat,
|
||
doubaoChatStream,
|
||
// AI 模型配置
|
||
getAiModelConfig
|
||
}
|