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:
@@ -0,0 +1,43 @@
|
||||
package com.zbkj.common.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 豆包(火山引擎 Ark)API 配置类
|
||||
* 使用 OpenAI 兼容的 Chat Completions 接口
|
||||
* +----------------------------------------------------------------------
|
||||
* | Author:ScottPan
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "doubao.api")
|
||||
public class DoubaoConfig {
|
||||
|
||||
/**
|
||||
* API 基础 URL
|
||||
*/
|
||||
private String baseUrl = "https://ark.cn-beijing.volces.com/api/v3";
|
||||
|
||||
/**
|
||||
* API Key(火山引擎控制台获取)
|
||||
*/
|
||||
private String apiKey;
|
||||
|
||||
/**
|
||||
* 默认模型名称
|
||||
*/
|
||||
private String model = "doubao-seed-2-0-pro-260215";
|
||||
|
||||
/**
|
||||
* 连接超时时间(毫秒)
|
||||
*/
|
||||
private Integer connectTimeout = 30000;
|
||||
|
||||
/**
|
||||
* 读取超时时间(毫秒)
|
||||
*/
|
||||
private Integer readTimeout = 120000;
|
||||
}
|
||||
@@ -202,4 +202,7 @@ public class SysConfigConstants {
|
||||
/** 京东云存储端点 */
|
||||
public static final String CONFIG_JD_CLOUD_ENDPOINT = "jdEndpoint";
|
||||
|
||||
// ====== AI 模型配置 ======
|
||||
/** AI 营养师对话模型选择: doubao / coze / gemini */
|
||||
public static final String CONFIG_KEY_AI_CHAT_MODEL = "ai_chat_model";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.zbkj.common.request.doubao;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 豆包(火山引擎 Ark)Chat Completions 请求 DTO
|
||||
* 兼容 OpenAI Chat Completions 格式
|
||||
* +----------------------------------------------------------------------
|
||||
* | Author:ScottPan
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@ApiModel(value = "DoubaoChatRequest", description = "豆包 Chat Completions 请求")
|
||||
public class DoubaoChatRequest implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Valid
|
||||
@NotEmpty(message = "messages不能为空")
|
||||
@ApiModelProperty(value = "消息列表", required = true)
|
||||
private List<Message> messages;
|
||||
|
||||
@ApiModelProperty(value = "是否流式返回", example = "false")
|
||||
private Boolean stream;
|
||||
|
||||
@ApiModelProperty(value = "模型名称(可选,不传则使用服务端默认配置)")
|
||||
private String model;
|
||||
|
||||
@ApiModelProperty(value = "温度参数 (0-2)", example = "0.7")
|
||||
private Double temperature;
|
||||
|
||||
@ApiModelProperty(value = "最大输出 token 数")
|
||||
private Integer maxTokens;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@ApiModel(value = "Message", description = "单条消息")
|
||||
public static class Message implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty(value = "角色: system, user, assistant")
|
||||
private String role;
|
||||
|
||||
@NotNull(message = "content不能为空")
|
||||
@ApiModelProperty(value = "内容:字符串或多模态数组", required = true)
|
||||
private Object content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.zbkj.front.controller;
|
||||
|
||||
import com.zbkj.common.constants.SysConfigConstants;
|
||||
import com.zbkj.common.request.doubao.DoubaoChatRequest;
|
||||
import com.zbkj.common.result.CommonResult;
|
||||
import com.zbkj.service.service.SystemConfigService;
|
||||
import com.zbkj.service.service.tool.ToolDoubaoService;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 豆包(火山引擎 Ark)对话接口 + AI 模型配置
|
||||
* +----------------------------------------------------------------------
|
||||
* | Author:ScottPan
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/front/doubao")
|
||||
@Api(tags = "豆包 AI 对话")
|
||||
public class DoubaoController {
|
||||
|
||||
@Autowired
|
||||
private ToolDoubaoService toolDoubaoService;
|
||||
|
||||
@Autowired
|
||||
private SystemConfigService systemConfigService;
|
||||
|
||||
/**
|
||||
* 获取当前 AI 对话模型配置
|
||||
* 返回 { model: "doubao" | "coze" | "gemini" }
|
||||
*/
|
||||
@ApiOperation(value = "获取AI模型配置", notes = "获取当前系统配置的AI对话模型")
|
||||
@GetMapping("/ai-model-config")
|
||||
public CommonResult<Map<String, String>> getAiModelConfig() {
|
||||
String model = systemConfigService.getValueByKey(SysConfigConstants.CONFIG_KEY_AI_CHAT_MODEL);
|
||||
if (model == null || model.isEmpty()) {
|
||||
model = "doubao"; // 默认使用豆包
|
||||
}
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("model", model.trim().toLowerCase());
|
||||
return CommonResult.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 非流式对话
|
||||
*/
|
||||
@ApiOperation(value = "对话", notes = "与豆包模型进行对话")
|
||||
@PostMapping("/chat")
|
||||
public Map<String, Object> chat(@RequestBody @Validated DoubaoChatRequest request) {
|
||||
request.setStream(false);
|
||||
return toolDoubaoService.chat(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式对话(SSE)
|
||||
*/
|
||||
@ApiOperation(value = "流式对话", notes = "与豆包模型进行流式对话,使用 SSE 实时推送响应")
|
||||
@PostMapping(value = "/chat/stream", produces = "text/event-stream")
|
||||
public SseEmitter chatStream(@RequestBody @Validated DoubaoChatRequest request,
|
||||
HttpServletResponse response) {
|
||||
response.setHeader("X-Accel-Buffering", "no");
|
||||
response.setHeader("Cache-Control", "no-cache");
|
||||
request.setStream(true);
|
||||
return toolDoubaoService.chatStream(request);
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ spring:
|
||||
min-idle: 0 # 连接池中的最小空闲连接
|
||||
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
||||
second:
|
||||
database: 23 # 微信accessToken存储库
|
||||
database: 3 # 微信accessToken存储库
|
||||
|
||||
|
||||
debug: true
|
||||
@@ -87,6 +87,15 @@ coze:
|
||||
nutrition-analysis-workflow-id: 1180790412263 #饮食打卡记录ai分析
|
||||
diet-analysis-id: 1180790412263
|
||||
|
||||
# 豆包(火山引擎 Ark)API 配置
|
||||
doubao:
|
||||
api:
|
||||
base-url: https://ark.cn-beijing.volces.com/api/v3
|
||||
api-key: 18480c26-ebcd-4263-8a6f-48359b8bd65d
|
||||
model: doubao-seed-2-0-pro-260215
|
||||
connect-timeout: 30000
|
||||
read-timeout: 120000
|
||||
|
||||
# 腾讯云语音识别配置
|
||||
tencent-asr:
|
||||
# API密钥ID
|
||||
|
||||
@@ -21,6 +21,7 @@ crmeb:
|
||||
- api/front/upload/imageOuter
|
||||
- api/front/coze/**
|
||||
- api/front/kieai/**
|
||||
- api/front/doubao/**
|
||||
|
||||
# 配置端口
|
||||
server:
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.zbkj.service.service.impl.tool;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.zbkj.common.config.DoubaoConfig;
|
||||
import com.zbkj.common.request.doubao.DoubaoChatRequest;
|
||||
import com.zbkj.common.utils.SseEmitterUtil;
|
||||
import com.zbkj.service.service.tool.ToolDoubaoService;
|
||||
import okhttp3.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 豆包(火山引擎 Ark)服务实现类
|
||||
* 使用 OpenAI 兼容的 Chat Completions 接口 + OkHttp SSE 流式
|
||||
* +----------------------------------------------------------------------
|
||||
* | Author:ScottPan
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Service
|
||||
public class ToolDoubaoServiceImpl implements ToolDoubaoService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ToolDoubaoServiceImpl.class);
|
||||
|
||||
private static final String CHAT_COMPLETIONS_PATH = "/chat/completions";
|
||||
|
||||
@Autowired
|
||||
private DoubaoConfig config;
|
||||
|
||||
/**
|
||||
* 构建发送给豆包 API 的请求体
|
||||
*/
|
||||
private Map<String, Object> buildRequestBody(DoubaoChatRequest request, boolean stream) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
// 模型:优先使用请求指定的,否则使用配置默认的
|
||||
String model = (request.getModel() != null && !request.getModel().isEmpty())
|
||||
? request.getModel() : config.getModel();
|
||||
body.put("model", model);
|
||||
|
||||
// 消息列表 — 用 FastJSON 解析保留原始结构(与 KieAI 实现一致)
|
||||
List<Object> messages = JSON.parseArray(JSON.toJSONString(request.getMessages()));
|
||||
body.put("messages", messages);
|
||||
|
||||
body.put("stream", stream);
|
||||
|
||||
if (request.getTemperature() != null) {
|
||||
body.put("temperature", request.getTemperature());
|
||||
}
|
||||
if (request.getMaxTokens() != null) {
|
||||
body.put("max_tokens", request.getMaxTokens());
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> chat(DoubaoChatRequest request) {
|
||||
if (config.getApiKey() == null || config.getApiKey().isEmpty()) {
|
||||
throw new RuntimeException("豆包 API Key 未配置");
|
||||
}
|
||||
Map<String, Object> body = buildRequestBody(request, false);
|
||||
String url = config.getBaseUrl() + CHAT_COMPLETIONS_PATH;
|
||||
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setReadTimeout(config.getReadTimeout());
|
||||
factory.setConnectTimeout(config.getConnectTimeout());
|
||||
RestTemplate rt = new RestTemplate(factory);
|
||||
|
||||
org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
|
||||
headers.set("Authorization", "Bearer " + config.getApiKey());
|
||||
headers.set("Content-Type", "application/json");
|
||||
|
||||
org.springframework.http.HttpEntity<String> entity =
|
||||
new org.springframework.http.HttpEntity<>(JSON.toJSONString(body), headers);
|
||||
|
||||
try {
|
||||
org.springframework.http.ResponseEntity<String> resp =
|
||||
rt.postForEntity(url, entity, String.class);
|
||||
if (resp.getBody() != null) {
|
||||
return JSON.parseObject(resp.getBody(), Map.class);
|
||||
}
|
||||
return Collections.emptyMap();
|
||||
} catch (Exception e) {
|
||||
logger.error("Doubao chat error", e);
|
||||
throw new RuntimeException("豆包对话失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public SseEmitter chatStream(DoubaoChatRequest request) {
|
||||
if (config.getApiKey() == null || config.getApiKey().isEmpty()) {
|
||||
throw new RuntimeException("豆包 API Key 未配置");
|
||||
}
|
||||
SseEmitter emitter = new SseEmitter(120000L);
|
||||
|
||||
Map<String, Object> body = buildRequestBody(request, true);
|
||||
String url = config.getBaseUrl() + CHAT_COMPLETIONS_PATH;
|
||||
|
||||
OkHttpClient client = new OkHttpClient.Builder()
|
||||
.connectTimeout(config.getConnectTimeout(), TimeUnit.MILLISECONDS)
|
||||
.readTimeout(config.getReadTimeout(), TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
RequestBody requestBody = RequestBody.create(
|
||||
MediaType.parse("application/json; charset=utf-8"),
|
||||
JSON.toJSONString(body));
|
||||
|
||||
Request okRequest = new Request.Builder()
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.addHeader("Authorization", "Bearer " + config.getApiKey())
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.addHeader("Accept", "text/event-stream")
|
||||
.build();
|
||||
|
||||
try {
|
||||
client.newCall(okRequest).enqueue(new Callback() {
|
||||
@Override
|
||||
public void onFailure(Call call, IOException e) {
|
||||
logger.error("Doubao chat stream request failed", e);
|
||||
SseEmitterUtil.completeWithError(emitter, e);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResponse(Call call, Response response) throws IOException {
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
String errBody = response.body() != null ? response.body().string() : "";
|
||||
logger.error("Doubao stream failed: {} {}", response.code(), errBody);
|
||||
SseEmitterUtil.completeWithError(emitter,
|
||||
new RuntimeException("豆包流式请求失败: " + response.code()));
|
||||
return;
|
||||
}
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(response.body().byteStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.startsWith("data: ")) {
|
||||
String json = line.substring(6).trim();
|
||||
if ("[DONE]".equals(json) || json.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
SseEmitterUtil.send(emitter, json);
|
||||
}
|
||||
}
|
||||
SseEmitterUtil.complete(emitter);
|
||||
} catch (Exception e) {
|
||||
logger.error("Doubao chat stream read error", e);
|
||||
SseEmitterUtil.completeWithError(emitter, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.error("Doubao chat stream error", e);
|
||||
SseEmitterUtil.completeWithError(emitter, e);
|
||||
}
|
||||
|
||||
return emitter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.zbkj.service.service.tool;
|
||||
|
||||
import com.zbkj.common.request.doubao.DoubaoChatRequest;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 豆包(火山引擎 Ark)服务接口
|
||||
* +----------------------------------------------------------------------
|
||||
* | Author:ScottPan
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
public interface ToolDoubaoService {
|
||||
|
||||
/**
|
||||
* 豆包对话(非流式)
|
||||
*
|
||||
* @param request 对话请求参数
|
||||
* @return 对话响应
|
||||
*/
|
||||
Map<String, Object> chat(DoubaoChatRequest request);
|
||||
|
||||
/**
|
||||
* 豆包对话(流式 SSE)
|
||||
*
|
||||
* @param request 对话请求参数
|
||||
* @return SSE 事件流
|
||||
*/
|
||||
SseEmitter chatStream(DoubaoChatRequest request);
|
||||
}
|
||||
Reference in New Issue
Block a user