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:
msh-agent
2026-04-11 18:03:21 +08:00
parent 58ea76498f
commit b164d8ba11
14 changed files with 1369 additions and 119 deletions

View File

@@ -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
}