feat(ai-chat): 新增豆包API + AI模型配置项支持动态切换
- 后端新增豆包(火山引擎Ark)API集成:DoubaoController、ToolDoubaoServiceImpl, 使用OkHttp3 SSE流式对话,兼容OpenAI Chat Completions格式 - 新增DoubaoConfig配置类,读取doubao.api.*配置 - 在eb_system_config表新增ai_chat_model配置项,支持doubao/coze/gemini三种模型切换 - 新增GET /api/front/doubao/ai-model-config接口供前端读取当前模型配置 - 前端ai-nutritionist.vue的sendToAI按系统配置分发到_sendViaDoubao/_sendViaCoze/_sendViaGemini - 前端models-api.js新增doubaoChatStream/doubaoChat/getAiModelConfig函数 - 附带豆包API测试脚本和数据库初始化SQL Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -812,6 +812,163 @@ function cozeTextToSpeech(data) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 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,
|
||||
@@ -835,5 +992,10 @@ export default {
|
||||
cozeWorkflowStream,
|
||||
cozeWorkflowResume,
|
||||
cozeUploadFile,
|
||||
cozeTextToSpeech
|
||||
cozeTextToSpeech,
|
||||
// 豆包 API
|
||||
doubaoChat,
|
||||
doubaoChatStream,
|
||||
// AI 模型配置
|
||||
getAiModelConfig
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user