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";
|
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 # 连接池中的最小空闲连接
|
min-idle: 0 # 连接池中的最小空闲连接
|
||||||
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
||||||
second:
|
second:
|
||||||
database: 23 # 微信accessToken存储库
|
database: 3 # 微信accessToken存储库
|
||||||
|
|
||||||
|
|
||||||
debug: true
|
debug: true
|
||||||
@@ -87,6 +87,15 @@ coze:
|
|||||||
nutrition-analysis-workflow-id: 1180790412263 #饮食打卡记录ai分析
|
nutrition-analysis-workflow-id: 1180790412263 #饮食打卡记录ai分析
|
||||||
diet-analysis-id: 1180790412263
|
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:
|
tencent-asr:
|
||||||
# API密钥ID
|
# API密钥ID
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ crmeb:
|
|||||||
- api/front/upload/imageOuter
|
- api/front/upload/imageOuter
|
||||||
- api/front/coze/**
|
- api/front/coze/**
|
||||||
- api/front/kieai/**
|
- api/front/kieai/**
|
||||||
|
- api/front/doubao/**
|
||||||
|
|
||||||
# 配置端口
|
# 配置端口
|
||||||
server:
|
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);
|
||||||
|
}
|
||||||
@@ -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 {
|
export default {
|
||||||
request,
|
request,
|
||||||
getArticleById,
|
getArticleById,
|
||||||
@@ -835,5 +992,10 @@ export default {
|
|||||||
cozeWorkflowStream,
|
cozeWorkflowStream,
|
||||||
cozeWorkflowResume,
|
cozeWorkflowResume,
|
||||||
cozeUploadFile,
|
cozeUploadFile,
|
||||||
cozeTextToSpeech
|
cozeTextToSpeech,
|
||||||
|
// 豆包 API
|
||||||
|
doubaoChat,
|
||||||
|
doubaoChatStream,
|
||||||
|
// AI 模型配置
|
||||||
|
getAiModelConfig
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
aiModel: 'doubao', // 当前AI模型: doubao / coze / gemini(从系统配置读取)
|
||||||
botId: '7591133240535449654',
|
botId: '7591133240535449654',
|
||||||
conversationId: '',
|
conversationId: '',
|
||||||
scrollTop: 0,
|
scrollTop: 0,
|
||||||
@@ -261,6 +262,7 @@ export default {
|
|||||||
});
|
});
|
||||||
this.initRecorder();
|
this.initRecorder();
|
||||||
this.initAudioContext();
|
this.initAudioContext();
|
||||||
|
this.loadAiModelConfig();
|
||||||
},
|
},
|
||||||
onUnload() {
|
onUnload() {
|
||||||
this.stopRecordTimer();
|
this.stopRecordTimer();
|
||||||
@@ -277,6 +279,19 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
/** 加载 AI 模型配置 */
|
||||||
|
loadAiModelConfig() {
|
||||||
|
api.getAiModelConfig().then(res => {
|
||||||
|
const data = res && res.data
|
||||||
|
if (data && data.model) {
|
||||||
|
this.aiModel = data.model.trim().toLowerCase()
|
||||||
|
console.log('[ai-nutritionist] AI模型配置:', this.aiModel)
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.warn('[ai-nutritionist] 获取AI模型配置失败,使用默认 doubao:', err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
// 初始化录音管理器
|
// 初始化录音管理器
|
||||||
initRecorder() {
|
initRecorder() {
|
||||||
// #ifdef MP-WEIXIN || APP-PLUS
|
// #ifdef MP-WEIXIN || APP-PLUS
|
||||||
@@ -744,87 +759,65 @@ export default {
|
|||||||
return '';
|
return '';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 从 Gemini(KieAI) 非流式响应中提取回复文本 */
|
||||||
|
getGeminiReplyFromResponse(response) {
|
||||||
|
if (!response || typeof response !== 'object') return ''
|
||||||
|
let data = response.data
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try { data = JSON.parse(data) } catch (e) { return '' }
|
||||||
|
}
|
||||||
|
const payload = this.getGeminiPayload(data || response)
|
||||||
|
if (!payload) return ''
|
||||||
|
// OpenAI 形态: choices[0].message.content
|
||||||
|
if (Array.isArray(payload.choices) && payload.choices[0]) {
|
||||||
|
const msg = payload.choices[0].message
|
||||||
|
if (msg) return this.extractReplyContent(msg.content)
|
||||||
|
const delta = payload.choices[0].delta
|
||||||
|
if (delta) return this.extractReplyContent(delta.content)
|
||||||
|
}
|
||||||
|
// Gemini 形态: candidates[0].content
|
||||||
|
if (Array.isArray(payload.candidates) && payload.candidates[0]) {
|
||||||
|
return this.extractReplyContent(payload.candidates[0].content)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
|
||||||
/** 工具方法:sleep ms 毫秒 */
|
/** 工具方法:sleep ms 毫秒 */
|
||||||
sleep(ms) {
|
sleep(ms) {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
},
|
},
|
||||||
|
|
||||||
buildGeminiMessages(content, type) {
|
|
||||||
if (type === 'text') {
|
|
||||||
return [{ role: 'user', content: typeof content === 'string' ? content : String(content) }];
|
|
||||||
}
|
|
||||||
const parts = Array.isArray(content) ? content : [{ type: 'text', text: String(content) }];
|
|
||||||
return [{ role: 'user', content: parts }];
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BUG-005:主路径为 response.data.choices[0].message.content(models-api 已尽量规范 data 形态)。
|
* 构建 OpenAI 兼容格式的消息列表(用于豆包 API)
|
||||||
* 兼容上游 candidates、或根级即 completion 等变体;仅使用接口返回字段,
|
* @param {string|Array} content 消息内容
|
||||||
* 成功路径不使用 api/tool.js 的 getAIResponse 或本地关键词话术。
|
* @param {string} type 消息类型 text / image / multimodal
|
||||||
|
* @returns {Array} messages 数组 [{role, content}]
|
||||||
*/
|
*/
|
||||||
getGeminiReplyFromResponse(response) {
|
buildChatMessages(content, type) {
|
||||||
if (!response || typeof response !== 'object') return '';
|
if (type === 'text') {
|
||||||
const looksLikeCompletion = (o) =>
|
return [{ role: 'user', content: typeof content === 'string' ? content : String(content) }]
|
||||||
o && typeof o === 'object' &&
|
|
||||||
(Array.isArray(o.choices) || Array.isArray(o.candidates));
|
|
||||||
let data = response.data;
|
|
||||||
if (typeof data === 'string') {
|
|
||||||
try {
|
|
||||||
data = JSON.parse(data);
|
|
||||||
} catch (e) {
|
|
||||||
return '';
|
|
||||||
}
|
}
|
||||||
|
if (type === 'image') {
|
||||||
|
// 图片:尝试构建多模态消息
|
||||||
|
let fileInfo = content
|
||||||
|
if (typeof fileInfo === 'string') {
|
||||||
|
try { fileInfo = JSON.parse(fileInfo) } catch (e) { /* 非JSON */ }
|
||||||
}
|
}
|
||||||
// 主路径:OpenAI 形态 choices[0].message.content(与需求 data.choices[0].message.content 一致)
|
const imageUrl = (fileInfo && fileInfo.url) || (fileInfo && fileInfo.path) || ''
|
||||||
if (data && typeof data === 'object' && Array.isArray(data.choices) && data.choices.length > 0) {
|
if (imageUrl) {
|
||||||
const choice0 = data.choices[0];
|
return [{
|
||||||
const msg = choice0 && choice0.message;
|
role: 'user',
|
||||||
if (msg && typeof msg === 'object') {
|
content: [
|
||||||
const fromMsg = this.extractReplyContent(msg.content);
|
{ type: 'image_url', image_url: { url: imageUrl } },
|
||||||
if (fromMsg.trim()) return fromMsg;
|
{ type: 'text', text: '请分析这张图片' }
|
||||||
|
]
|
||||||
|
}]
|
||||||
}
|
}
|
||||||
const delta = choice0 && choice0.delta;
|
return [{ role: 'user', content: '我发送了一张图片,请帮我分析' }]
|
||||||
if (delta && typeof delta === 'object') {
|
|
||||||
const fromDelta = this.extractReplyContent(delta.content);
|
|
||||||
if (fromDelta.trim()) return fromDelta;
|
|
||||||
}
|
}
|
||||||
if (choice0 && typeof choice0.text === 'string' && choice0.text.trim()) {
|
// multimodal:直接传多模态 parts
|
||||||
return choice0.text;
|
const parts = Array.isArray(content) ? content : [{ type: 'text', text: String(content) }]
|
||||||
}
|
return [{ role: 'user', content: parts }]
|
||||||
}
|
|
||||||
if (!looksLikeCompletion(data) && looksLikeCompletion(response)) {
|
|
||||||
data = response;
|
|
||||||
}
|
|
||||||
if (!data || typeof data !== 'object') return '';
|
|
||||||
const payload = this.getGeminiPayload(data);
|
|
||||||
if (!payload || typeof payload !== 'object') return '';
|
|
||||||
const choices = payload.choices;
|
|
||||||
if (Array.isArray(choices) && choices.length > 0) {
|
|
||||||
const choice0 = choices[0];
|
|
||||||
const msg = choice0 && choice0.message;
|
|
||||||
if (msg && typeof msg === 'object') {
|
|
||||||
const fromMsg = this.extractReplyContent(msg.content);
|
|
||||||
if (fromMsg.trim()) return fromMsg;
|
|
||||||
}
|
|
||||||
const delta = choice0 && choice0.delta;
|
|
||||||
if (delta && typeof delta === 'object') {
|
|
||||||
const fromDelta = this.extractReplyContent(delta.content);
|
|
||||||
if (fromDelta.trim()) return fromDelta;
|
|
||||||
}
|
|
||||||
if (choice0 && typeof choice0.text === 'string' && choice0.text.trim()) {
|
|
||||||
return choice0.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const cands = payload.candidates;
|
|
||||||
if (Array.isArray(cands) && cands.length > 0) {
|
|
||||||
const c0 = cands[0];
|
|
||||||
if (c0 && typeof c0 === 'object') {
|
|
||||||
const fromCand = this.extractReplyContent(c0.content);
|
|
||||||
if (fromCand.trim()) return fromCand;
|
|
||||||
if (typeof c0.text === 'string' && c0.text.trim()) return c0.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async sendToAI(content, type) {
|
async sendToAI(content, type) {
|
||||||
@@ -833,54 +826,15 @@ export default {
|
|||||||
this.messageList.push(aiMsg);
|
this.messageList.push(aiMsg);
|
||||||
this.scrollToBottom();
|
this.scrollToBottom();
|
||||||
|
|
||||||
const messages = this.buildGeminiMessages(content, type);
|
|
||||||
|
|
||||||
// 优先尝试流式输出以改善响应速度感知
|
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
// 根据系统配置的 aiModel 分发到不同的 API
|
||||||
const ctrl = api.kieaiGeminiChatStream({ messages })
|
if (this.aiModel === 'coze') {
|
||||||
this._streamCtrl = ctrl
|
await this._sendViaCoze(content, type, aiMsg)
|
||||||
|
} else if (this.aiModel === 'gemini') {
|
||||||
// 收到第一个 chunk 后切换为 streaming 状态
|
await this._sendViaGemini(content, type, aiMsg)
|
||||||
ctrl.onMessage((deltaText) => {
|
} else {
|
||||||
if (aiMsg.loading) {
|
// 默认:豆包
|
||||||
aiMsg.loading = false
|
await this._sendViaDoubao(content, type, aiMsg)
|
||||||
aiMsg.streaming = true
|
|
||||||
}
|
|
||||||
aiMsg.content += deltaText
|
|
||||||
this.messageList = [...this.messageList]
|
|
||||||
this.scrollToBottom()
|
|
||||||
})
|
|
||||||
|
|
||||||
ctrl.onError((err) => {
|
|
||||||
console.warn('[sendToAI] 流式请求失败,降级为非流式:', err)
|
|
||||||
this._streamCtrl = null
|
|
||||||
reject(err)
|
|
||||||
})
|
|
||||||
|
|
||||||
ctrl.onComplete(() => {
|
|
||||||
this._streamCtrl = null
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 流式完成,检查内容
|
|
||||||
if (!aiMsg.content.trim()) {
|
|
||||||
aiMsg.content = '模型未返回有效内容,请稍后重试。'
|
|
||||||
}
|
|
||||||
} catch (streamError) {
|
|
||||||
// 流式失败,降级为非流式请求
|
|
||||||
try {
|
|
||||||
const response = await api.kieaiGeminiChat({
|
|
||||||
messages,
|
|
||||||
stream: false
|
|
||||||
});
|
|
||||||
const reply = this.getGeminiReplyFromResponse(response);
|
|
||||||
aiMsg.content = reply.trim() ? reply : '模型未返回有效内容,请稍后重试。';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('KieAI Gemini 对话失败:', error);
|
|
||||||
const errText = error && error.message ? String(error.message) : '';
|
|
||||||
aiMsg.content = errText || '抱歉,处理您的请求时出现错误,请稍后再试。';
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
aiMsg.loading = false;
|
aiMsg.loading = false;
|
||||||
@@ -897,6 +851,159 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ========== 豆包 API(OpenAI 兼容,默认) ==========
|
||||||
|
async _sendViaDoubao(content, type, aiMsg) {
|
||||||
|
const messages = this.buildChatMessages(content, type)
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const ctrl = api.doubaoChatStream({ messages })
|
||||||
|
this._streamCtrl = ctrl
|
||||||
|
ctrl.onMessage((deltaText) => {
|
||||||
|
if (aiMsg.loading) { aiMsg.loading = false; aiMsg.streaming = true }
|
||||||
|
aiMsg.content += deltaText
|
||||||
|
this.messageList = [...this.messageList]
|
||||||
|
this.scrollToBottom()
|
||||||
|
})
|
||||||
|
ctrl.onError((err) => { this._streamCtrl = null; reject(err) })
|
||||||
|
ctrl.onComplete(() => { this._streamCtrl = null; resolve() })
|
||||||
|
})
|
||||||
|
if (!aiMsg.content.trim()) aiMsg.content = '模型未返回有效内容,请稍后重试。'
|
||||||
|
} catch (streamError) {
|
||||||
|
console.warn('[sendToAI:doubao] 流式失败,降级非流式:', streamError)
|
||||||
|
try {
|
||||||
|
const response = await api.doubaoChat({ messages, stream: false })
|
||||||
|
let reply = ''
|
||||||
|
if (response && response.choices && response.choices[0]) {
|
||||||
|
const msg = response.choices[0].message
|
||||||
|
if (msg && msg.content) reply = msg.content
|
||||||
|
}
|
||||||
|
aiMsg.content = reply.trim() ? reply : '模型未返回有效内容,请稍后重试。'
|
||||||
|
} catch (error) {
|
||||||
|
console.error('豆包对话失败:', error)
|
||||||
|
aiMsg.content = (error && error.message) || '抱歉,处理您的请求时出现错误,请稍后再试。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Coze API ==========
|
||||||
|
async _sendViaCoze(content, type, aiMsg) {
|
||||||
|
const userId = this.uid || (uni.getStorageSync('userInfo') || {}).id || 'default_user'
|
||||||
|
const cozeMessages = this.buildChatMessages(content, type).map(m => ({
|
||||||
|
...m, content_type: 'text'
|
||||||
|
}))
|
||||||
|
const reqData = {
|
||||||
|
botId: this.botId,
|
||||||
|
userId: String(userId),
|
||||||
|
additionalMessages: cozeMessages
|
||||||
|
}
|
||||||
|
if (this.conversationId) reqData.conversationId = this.conversationId
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const ctrl = api.cozeChatStream(reqData)
|
||||||
|
this._streamCtrl = ctrl
|
||||||
|
ctrl.onMessage((evt) => {
|
||||||
|
if (evt.conversation_id && !this.conversationId) {
|
||||||
|
this.conversationId = evt.conversation_id
|
||||||
|
}
|
||||||
|
if (evt.event === 'conversation.message.delta' && evt.type === 'answer' && evt.content) {
|
||||||
|
if (aiMsg.loading) { aiMsg.loading = false; aiMsg.streaming = true }
|
||||||
|
aiMsg.content += evt.content
|
||||||
|
this.messageList = [...this.messageList]
|
||||||
|
this.scrollToBottom()
|
||||||
|
}
|
||||||
|
if (evt.event === 'conversation.chat.completed' && evt.conversation_id) {
|
||||||
|
this.conversationId = evt.conversation_id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ctrl.onError((err) => { this._streamCtrl = null; reject(err) })
|
||||||
|
ctrl.onComplete(() => { this._streamCtrl = null; resolve() })
|
||||||
|
})
|
||||||
|
if (!aiMsg.content.trim()) aiMsg.content = '模型未返回有效内容,请稍后重试。'
|
||||||
|
} catch (streamError) {
|
||||||
|
console.warn('[sendToAI:coze] 流式失败,降级非流式:', streamError)
|
||||||
|
try {
|
||||||
|
reqData.stream = false
|
||||||
|
const response = await api.cozeChat(reqData)
|
||||||
|
if (response && response.data && response.data.chat) {
|
||||||
|
const { conversation_id, id: chat_id } = response.data.chat
|
||||||
|
if (conversation_id) this.conversationId = conversation_id
|
||||||
|
await this.pollCozeResult(conversation_id, chat_id, aiMsg)
|
||||||
|
} else {
|
||||||
|
aiMsg.content = '模型未返回有效内容,请稍后重试。'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Coze 对话失败:', error)
|
||||||
|
aiMsg.content = (error && error.message) || '抱歉,处理您的请求时出现错误,请稍后再试。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Gemini API (KieAI) ==========
|
||||||
|
async _sendViaGemini(content, type, aiMsg) {
|
||||||
|
const messages = this.buildChatMessages(content, type)
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const ctrl = api.kieaiGeminiChatStream({ messages })
|
||||||
|
this._streamCtrl = ctrl
|
||||||
|
ctrl.onMessage((deltaText) => {
|
||||||
|
if (aiMsg.loading) { aiMsg.loading = false; aiMsg.streaming = true }
|
||||||
|
aiMsg.content += deltaText
|
||||||
|
this.messageList = [...this.messageList]
|
||||||
|
this.scrollToBottom()
|
||||||
|
})
|
||||||
|
ctrl.onError((err) => { this._streamCtrl = null; reject(err) })
|
||||||
|
ctrl.onComplete(() => { this._streamCtrl = null; resolve() })
|
||||||
|
})
|
||||||
|
if (!aiMsg.content.trim()) aiMsg.content = '模型未返回有效内容,请稍后重试。'
|
||||||
|
} catch (streamError) {
|
||||||
|
console.warn('[sendToAI:gemini] 流式失败,降级非流式:', streamError)
|
||||||
|
try {
|
||||||
|
const response = await api.kieaiGeminiChat({ messages, stream: false })
|
||||||
|
const reply = this.getGeminiReplyFromResponse(response)
|
||||||
|
aiMsg.content = reply.trim() ? reply : '模型未返回有效内容,请稍后重试。'
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Gemini 对话失败:', error)
|
||||||
|
aiMsg.content = (error && error.message) || '抱歉,处理您的请求时出现错误,请稍后再试。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 非流式 Coze 降级:轮询对话状态并获取回复
|
||||||
|
*/
|
||||||
|
async pollCozeResult(conversationId, chatId, aiMsg) {
|
||||||
|
const maxAttempts = 30
|
||||||
|
const interval = 2000
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
await this.sleep(interval)
|
||||||
|
try {
|
||||||
|
const res = await api.cozeRetrieveChat({ conversationId, chatId })
|
||||||
|
const status = res && res.data && res.data.status
|
||||||
|
if (status === 'completed') {
|
||||||
|
const msgRes = await api.cozeMessageList({ conversationId, chatId })
|
||||||
|
if (msgRes && msgRes.data && Array.isArray(msgRes.data)) {
|
||||||
|
const answer = msgRes.data.find(m => m.role === 'assistant' && m.type === 'answer')
|
||||||
|
if (answer && answer.content) {
|
||||||
|
aiMsg.content = answer.content
|
||||||
|
this.messageList = [...this.messageList]
|
||||||
|
this.scrollToBottom()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aiMsg.content = '模型未返回有效内容,请稍后重试。'
|
||||||
|
return
|
||||||
|
} else if (status === 'failed') {
|
||||||
|
aiMsg.content = '对话处理失败,请稍后重试。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[pollCozeResult] 轮询出错:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aiMsg.content = '等待超时,请稍后重试。'
|
||||||
|
},
|
||||||
|
|
||||||
// ---------- TTS 方法 ----------
|
// ---------- TTS 方法 ----------
|
||||||
|
|
||||||
initAudioContext() {
|
initAudioContext() {
|
||||||
|
|||||||
21
sql/add_ai_chat_model_config.sql
Normal file
21
sql/add_ai_chat_model_config.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- AI 营养师对话模型配置项
|
||||||
|
-- 在 eb_system_config 表中新增模型选择配置
|
||||||
|
-- 可选值: doubao / coze / gemini
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 插入配置项(如果不存在)
|
||||||
|
INSERT INTO eb_system_config (`name`, `title`, `value`, `form_id`, `status`, `create_time`, `update_time`)
|
||||||
|
SELECT 'ai_chat_model', 'AI对话模型选择(doubao/coze/gemini)', 'doubao', 0, 0, NOW(), NOW()
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM eb_system_config WHERE `name` = 'ai_chat_model'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 如果需要修改模型,可以直接更新:
|
||||||
|
-- UPDATE eb_system_config SET `value` = 'coze', `update_time` = NOW() WHERE `name` = 'ai_chat_model';
|
||||||
|
-- UPDATE eb_system_config SET `value` = 'gemini', `update_time` = NOW() WHERE `name` = 'ai_chat_model';
|
||||||
|
-- UPDATE eb_system_config SET `value` = 'doubao', `update_time` = NOW() WHERE `name` = 'ai_chat_model';
|
||||||
|
|
||||||
|
-- 验证:
|
||||||
|
-- SELECT * FROM eb_system_config WHERE `name` = 'ai_chat_model';
|
||||||
104
test-doubao-api.py
Normal file
104
test-doubao-api.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
豆包(火山引擎 Ark)API 快速测试
|
||||||
|
用法: python3 test-doubao-api.py
|
||||||
|
"""
|
||||||
|
import json, time, sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
print("需要安装 requests: pip install requests")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
API_KEY = "18480c26-ebcd-4263-8a6f-48359b8bd65d"
|
||||||
|
BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
|
||||||
|
MODEL = "doubao-seed-2-0-pro-260215"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {API_KEY}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print(" 豆包 API 测试")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# ---- 测试 1: 非流式 ----
|
||||||
|
print("\n【测试 1】非流式调用")
|
||||||
|
payload = {
|
||||||
|
"model": MODEL,
|
||||||
|
"messages": [{"role": "user", "content": "你好,请用一句话介绍你自己"}],
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
try:
|
||||||
|
resp = requests.post(f"{BASE_URL}/chat/completions", headers=headers, json=payload, timeout=30)
|
||||||
|
elapsed = (time.time() - start) * 1000
|
||||||
|
print(f"HTTP 状态码: {resp.status_code}")
|
||||||
|
print(f"耗时: {elapsed:.0f} ms")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
choices = data.get("choices", [])
|
||||||
|
if choices:
|
||||||
|
print(f"回复: {choices[0]['message']['content']}")
|
||||||
|
usage = data.get("usage", {})
|
||||||
|
print(f"Token: prompt={usage.get('prompt_tokens',0)}, completion={usage.get('completion_tokens',0)}, total={usage.get('total_tokens',0)}")
|
||||||
|
print(f"模型: {data.get('model', '?')}")
|
||||||
|
else:
|
||||||
|
print(f"错误: {resp.text[:500]}")
|
||||||
|
except Exception as e:
|
||||||
|
elapsed = (time.time() - start) * 1000
|
||||||
|
print(f"请求失败 ({elapsed:.0f}ms): {e}")
|
||||||
|
|
||||||
|
# ---- 测试 2: 流式 ----
|
||||||
|
print("\n【测试 2】流式调用 (SSE)")
|
||||||
|
payload_stream = {
|
||||||
|
"model": MODEL,
|
||||||
|
"messages": [{"role": "user", "content": "简单说一下健康饮食的三个要点,每个要点一句话"}],
|
||||||
|
"stream": True
|
||||||
|
}
|
||||||
|
|
||||||
|
start2 = time.time()
|
||||||
|
first_token_time = None
|
||||||
|
full_content = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp2 = requests.post(f"{BASE_URL}/chat/completions", headers=headers, json=payload_stream, stream=True, timeout=30)
|
||||||
|
print(f"HTTP 状态码: {resp2.status_code}")
|
||||||
|
if resp2.status_code == 200:
|
||||||
|
print("流式输出: ", end="", flush=True)
|
||||||
|
for line in resp2.iter_lines(decode_unicode=True):
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if line.startswith("data: "):
|
||||||
|
json_str = line[6:].strip()
|
||||||
|
if json_str == "[DONE]":
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
evt = json.loads(json_str)
|
||||||
|
delta = evt.get("choices", [{}])[0].get("delta", {}).get("content", "")
|
||||||
|
if delta:
|
||||||
|
if first_token_time is None:
|
||||||
|
first_token_time = time.time()
|
||||||
|
full_content += delta
|
||||||
|
print(delta, end="", flush=True)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
total_time = (time.time() - start2) * 1000
|
||||||
|
ttft = (first_token_time - start2) * 1000 if first_token_time else 0
|
||||||
|
print(f"\n---")
|
||||||
|
print(f"首字时间 (TTFT): {ttft:.0f} ms")
|
||||||
|
print(f"总耗时: {total_time:.0f} ms")
|
||||||
|
print(f"回复长度: {len(full_content)} 字符")
|
||||||
|
else:
|
||||||
|
print(f"错误: {resp2.text[:500]}")
|
||||||
|
except Exception as e:
|
||||||
|
elapsed2 = (time.time() - start2) * 1000
|
||||||
|
print(f"\n请求失败 ({elapsed2:.0f}ms): {e}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(" 测试完成")
|
||||||
|
print("=" * 50)
|
||||||
144
test-doubao-api.sh
Normal file
144
test-doubao-api.sh
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 豆包 API 连通性测试脚本
|
||||||
|
# 用法: bash test-doubao-api.sh
|
||||||
|
|
||||||
|
API_KEY="18480c26-ebcd-4263-8a6f-48359b8bd65d"
|
||||||
|
BASE_URL="https://ark.cn-beijing.volces.com/api/v3"
|
||||||
|
MODEL="doubao-seed-2-0-pro-260215"
|
||||||
|
|
||||||
|
echo "============================================"
|
||||||
|
echo " 豆包(火山引擎 Ark)API 测试"
|
||||||
|
echo "============================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ---- 测试 1: 非流式调用 ----
|
||||||
|
echo "【测试 1】非流式调用"
|
||||||
|
echo "请求: 你好,请用一句话介绍你自己"
|
||||||
|
echo "---"
|
||||||
|
|
||||||
|
START=$(date +%s%3N 2>/dev/null || python3 -c "import time; print(int(time.time()*1000))")
|
||||||
|
|
||||||
|
RESPONSE=$(curl -s -w "\n---HTTP_CODE:%{http_code}---" \
|
||||||
|
-X POST "${BASE_URL}/chat/completions" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer ${API_KEY}" \
|
||||||
|
-d "{
|
||||||
|
\"model\": \"${MODEL}\",
|
||||||
|
\"messages\": [{\"role\": \"user\", \"content\": \"你好,请用一句话介绍你自己\"}],
|
||||||
|
\"stream\": false
|
||||||
|
}" 2>&1)
|
||||||
|
|
||||||
|
END=$(date +%s%3N 2>/dev/null || python3 -c "import time; print(int(time.time()*1000))")
|
||||||
|
ELAPSED=$((END - START))
|
||||||
|
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | grep -oP '(?<=---HTTP_CODE:)\d+(?=---)')
|
||||||
|
BODY=$(echo "$RESPONSE" | sed 's/---HTTP_CODE:[0-9]*---//')
|
||||||
|
|
||||||
|
echo "HTTP 状态码: ${HTTP_CODE}"
|
||||||
|
echo "耗时: ${ELAPSED} ms"
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
# 提取回复内容
|
||||||
|
CONTENT=$(echo "$BODY" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
choices = data.get('choices', [])
|
||||||
|
if choices:
|
||||||
|
print('回复:', choices[0].get('message', {}).get('content', '(empty)'))
|
||||||
|
usage = data.get('usage', {})
|
||||||
|
print(f'Token: prompt={usage.get(\"prompt_tokens\",0)}, completion={usage.get(\"completion_tokens\",0)}, total={usage.get(\"total_tokens\",0)}')
|
||||||
|
print(f'模型: {data.get(\"model\", \"?\")}')
|
||||||
|
" 2>&1)
|
||||||
|
echo "$CONTENT"
|
||||||
|
else
|
||||||
|
echo "错误响应: $BODY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ---- 测试 2: 流式调用 ----
|
||||||
|
echo "【测试 2】流式调用 (SSE)"
|
||||||
|
echo "请求: 简单说一下健康饮食的三个要点"
|
||||||
|
echo "---"
|
||||||
|
|
||||||
|
START2=$(date +%s%3N 2>/dev/null || python3 -c "import time; print(int(time.time()*1000))")
|
||||||
|
|
||||||
|
# 流式请求,逐行输出
|
||||||
|
STREAM_OUTPUT=""
|
||||||
|
FIRST_CHUNK_TIME=""
|
||||||
|
|
||||||
|
curl -s -N \
|
||||||
|
-X POST "${BASE_URL}/chat/completions" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer ${API_KEY}" \
|
||||||
|
-d "{
|
||||||
|
\"model\": \"${MODEL}\",
|
||||||
|
\"messages\": [{\"role\": \"user\", \"content\": \"简单说一下健康饮食的三个要点,每个要点一句话\"}],
|
||||||
|
\"stream\": true
|
||||||
|
}" 2>/dev/null | while IFS= read -r line; do
|
||||||
|
if [[ "$line" == data:* ]]; then
|
||||||
|
JSON_DATA="${line#data: }"
|
||||||
|
if [ "$JSON_DATA" = "[DONE]" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
# 提取 delta content
|
||||||
|
DELTA=$(echo "$JSON_DATA" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
try:
|
||||||
|
d = json.load(sys.stdin)
|
||||||
|
c = d.get('choices',[{}])[0].get('delta',{}).get('content','')
|
||||||
|
if c: print(c, end='', flush=True)
|
||||||
|
except: pass
|
||||||
|
" 2>/dev/null)
|
||||||
|
if [ -z "$FIRST_CHUNK_TIME" ] && [ -n "$DELTA" ]; then
|
||||||
|
FIRST_CHUNK_TIME=$(date +%s%3N 2>/dev/null || python3 -c "import time; print(int(time.time()*1000))")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
END2=$(date +%s%3N 2>/dev/null || python3 -c "import time; print(int(time.time()*1000))")
|
||||||
|
ELAPSED2=$((END2 - START2))
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "---"
|
||||||
|
echo "流式总耗时: ${ELAPSED2} ms"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ---- 测试 3: 通过后端接口调用(需先启动后端服务) ----
|
||||||
|
echo "【测试 3】通过后端接口调用(需后端服务运行中)"
|
||||||
|
echo "请确认后端已启动,默认地址: http://localhost:8081"
|
||||||
|
echo "---"
|
||||||
|
|
||||||
|
BACKEND_URL="http://localhost:8081/api/front/doubao/chat"
|
||||||
|
|
||||||
|
RESP3=$(curl -s -w "\n---HTTP_CODE:%{http_code}---" \
|
||||||
|
-X POST "${BACKEND_URL}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"messages\": [{\"role\": \"user\", \"content\": \"你好\"}]
|
||||||
|
}" 2>&1)
|
||||||
|
|
||||||
|
HTTP3=$(echo "$RESP3" | grep -oP '(?<=---HTTP_CODE:)\d+(?=---)')
|
||||||
|
BODY3=$(echo "$RESP3" | sed 's/---HTTP_CODE:[0-9]*---//')
|
||||||
|
|
||||||
|
if [ "$HTTP3" = "200" ]; then
|
||||||
|
echo "后端接口调用成功! HTTP $HTTP3"
|
||||||
|
echo "$BODY3" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
choices = data.get('choices', [])
|
||||||
|
if choices:
|
||||||
|
print('回复:', choices[0].get('message', {}).get('content', '(empty)')[:100])
|
||||||
|
" 2>&1
|
||||||
|
else
|
||||||
|
echo "后端接口调用失败: HTTP ${HTTP3:-连接失败}"
|
||||||
|
echo "(如果后端未启动,这是正常的)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================"
|
||||||
|
echo " 测试完成"
|
||||||
|
echo "============================================"
|
||||||
322
客户反馈分析报告.md
Normal file
322
客户反馈分析报告.md
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
# 测试问题与优化建议 — 修改解决方案
|
||||||
|
|
||||||
|
**分析日期:** 2026年4月11日
|
||||||
|
**项目:** msh_single_uniapp(UniApp 小程序)
|
||||||
|
**涉及页面:** calculator-result / ai-nutritionist / food-encyclopedia
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、总览:需处理项 vs 暂不处理项
|
||||||
|
|
||||||
|
| # | 问题 | 决策 | 涉及文件 |
|
||||||
|
|---|------|------|----------|
|
||||||
|
| 1 | 食物份数 → 克数 | **采纳** | `pages/tool/calculator-result.vue` |
|
||||||
|
| 2 | AI对话交互按钮 | **部分采纳** | `pages/tool/ai-nutritionist.vue` |
|
||||||
|
| 3 | AI回复速度慢 | **已优化(切换Coze流式API)** | `ai-nutritionist.vue` + `models-api.js` |
|
||||||
|
| 4 | 食物百科图片缺失 | **不处理**(后台可改) | — |
|
||||||
|
| 5 | 百科卡片误导点击 | **采纳**(跳转详情页) | `pages/tool/food-encyclopedia.vue` |
|
||||||
|
| 6 | 角色权限与分销 | **暂不处理**(后台人工配置) | — |
|
||||||
|
| 7 | 百科点击报错 Bug | **必须修复** | `pages/tool/food-encyclopedia.vue` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、需修改的问题及代码级解决方案
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 问题1:食物份数建议改为克数
|
||||||
|
|
||||||
|
**页面:** `pages/tool/calculator-result.vue`
|
||||||
|
|
||||||
|
**现状分析:**
|
||||||
|
当前"食物份数建议"卡片(约第94-113行)中,食物列表渲染逻辑为:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<text class="food-portion">{{ item.portion }} 份</text>
|
||||||
|
```
|
||||||
|
|
||||||
|
数据结构 `foodList` 中每个 item 包含 `{ number, name, portion }`,`portion` 为份数值。
|
||||||
|
|
||||||
|
**修改方案:**
|
||||||
|
|
||||||
|
1. **后端接口改造**(推荐):`getCalculatorResult` 接口返回数据中增加 `gram` 字段,或将 `portion` 的含义改为克数。
|
||||||
|
|
||||||
|
2. **前端模板修改**(`calculator-result.vue` 约第94-113行):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- 修改前 -->
|
||||||
|
<text class="card-title">食物份数建议</text>
|
||||||
|
...
|
||||||
|
<text class="food-portion">{{ item.portion }} 份</text>
|
||||||
|
|
||||||
|
<!-- 修改后 -->
|
||||||
|
<text class="card-title">每日食物建议</text>
|
||||||
|
...
|
||||||
|
<text class="food-portion">{{ item.gram || item.portion }} 克</text>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **数据层适配**(`applyResult` 方法,约第320-350行):解析接口返回时,确保 `foodList` 中的 item 携带 `gram` 字段。如后端暂未改造,可前端用换算公式临时过渡:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 在 applyResult 方法中
|
||||||
|
this.foodList = (res.data.foodList || []).map(item => ({
|
||||||
|
...item,
|
||||||
|
gram: item.gram || Math.round(item.portion * item.gramPerServing) || item.portion
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
**工作量估计:** 前端 0.5天,后端(如需改接口)0.5天
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 问题2:AI营养师对话页面新增交互按钮
|
||||||
|
|
||||||
|
**页面:** `pages/tool/ai-nutritionist.vue`
|
||||||
|
|
||||||
|
**现状分析:**
|
||||||
|
当前每条 AI 消息仅有 **1个操作按钮**:TTS 语音朗读(约第84-90行)。消息数据结构为 `{ role, content, type, loading, streaming }`。AI 回复通过 `api.kieaiGeminiChat()` 调用(实际走 `/api/front/kieai/gemini/chat`,`stream: false` 非流式),整体响应一次性返回。
|
||||||
|
|
||||||
|
**部分采纳后的需求:** 新增"复制"、"重新生成"、"删除"按钮(语音朗读已有)。
|
||||||
|
|
||||||
|
**修改方案:**
|
||||||
|
|
||||||
|
在消息气泡下方的操作区域(约第84-90行 TTS 按钮位置),扩展为按钮组:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- 修改前:仅 TTS 按钮 -->
|
||||||
|
<view class="tts-play-btn" v-if="msg.role === 'ai' && !msg.loading && !msg.streaming && msg.content && msg.type !== 'image'" @click="...">
|
||||||
|
<text>{{ ttsPlayingIndex === index ? '⏹' : '▶' }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 修改后:操作按钮组 -->
|
||||||
|
<view class="msg-actions" v-if="msg.role === 'ai' && !msg.loading && !msg.streaming && msg.content && msg.type !== 'image'">
|
||||||
|
<!-- 复制 -->
|
||||||
|
<view class="action-btn" @click="copyMessage(index)">
|
||||||
|
<text class="action-icon">📋</text>
|
||||||
|
</view>
|
||||||
|
<!-- 重新生成(仅最后一条AI消息显示) -->
|
||||||
|
<view class="action-btn" v-if="isLastAiMessage(index)" @click="regenerateMessage(index)">
|
||||||
|
<text class="action-icon">🔄</text>
|
||||||
|
</view>
|
||||||
|
<!-- 语音朗读(已有) -->
|
||||||
|
<view class="action-btn" @click="ttsPlayingIndex === index ? stopTTS() : playTTS(index)">
|
||||||
|
<text class="action-icon">{{ ttsPlayingIndex === index ? '⏹' : '▶' }}</text>
|
||||||
|
</view>
|
||||||
|
<!-- 删除 -->
|
||||||
|
<view class="action-btn" @click="deleteMessage(index)">
|
||||||
|
<text class="action-icon">🗑️</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
```
|
||||||
|
|
||||||
|
**新增 methods:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 复制消息
|
||||||
|
copyMessage(index) {
|
||||||
|
const msg = this.messageList[index]
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: msg.content,
|
||||||
|
success: () => uni.showToast({ title: '已复制', icon: 'success' })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除单条消息
|
||||||
|
deleteMessage(index) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '确定删除这条消息吗?',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
this.messageList.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 重新生成(重发上一条用户消息)
|
||||||
|
regenerateMessage(index) {
|
||||||
|
// 找到该AI消息对应的上一条用户消息
|
||||||
|
let userMsgIndex = index - 1
|
||||||
|
while (userMsgIndex >= 0 && this.messageList[userMsgIndex].role !== 'user') {
|
||||||
|
userMsgIndex--
|
||||||
|
}
|
||||||
|
if (userMsgIndex < 0) return
|
||||||
|
// 移除当前AI回复
|
||||||
|
this.messageList.splice(index, 1)
|
||||||
|
// 重新发送
|
||||||
|
this.sendToAI()
|
||||||
|
},
|
||||||
|
|
||||||
|
// 判断是否为最后一条AI消息
|
||||||
|
isLastAiMessage(index) {
|
||||||
|
for (let i = this.messageList.length - 1; i >= 0; i--) {
|
||||||
|
if (this.messageList[i].role === 'ai') return i === index
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**新增样式:**
|
||||||
|
|
||||||
|
```scss
|
||||||
|
.msg-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.action-btn {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f4f5f7;
|
||||||
|
}
|
||||||
|
.action-icon {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**工作量估计:** 1天
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 问题3:AI回复速度慢(已优化 — 切换至 Coze API)
|
||||||
|
|
||||||
|
**优化前调用链路:**
|
||||||
|
|
||||||
|
```
|
||||||
|
小程序 → POST /api/front/kieai/gemini/chat/stream
|
||||||
|
→ Spring KieAIController (SseEmitter)
|
||||||
|
→ OkHttp3 异步 POST https://api.kie.ai/gemini-2.5-flash/v1/chat/completions
|
||||||
|
→ KieAI 代理网关 → Gemini 2.5 Flash 模型
|
||||||
|
```
|
||||||
|
|
||||||
|
**优化后调用链路:**
|
||||||
|
|
||||||
|
```
|
||||||
|
小程序 → POST /api/front/coze/chat/stream
|
||||||
|
→ Spring CozeController (SseEmitter, 120s timeout, 15s heartbeat)
|
||||||
|
→ Coze 官方 SDK (client.chat().stream())
|
||||||
|
→ https://api.coze.cn → Coze Bot (7591133240535449654)
|
||||||
|
```
|
||||||
|
|
||||||
|
**已完成的优化:**
|
||||||
|
|
||||||
|
1. **前端切换至 Coze 流式 API:** `sendToAI()` 改为调用 `api.cozeChatStream()`,直接使用 Coze 的流式事件(`conversation.message.delta`),逐字渲染 AI 回复。首字可见时间大幅缩短。
|
||||||
|
|
||||||
|
2. **Coze 事件解析:** 正确解析 Coze SSE 事件格式 `{ event, conversation_id, chat_id, content, role, type }`,仅累积 `event=conversation.message.delta` 且 `type=answer` 的文本增量。
|
||||||
|
|
||||||
|
3. **多轮对话支持:** 自动保存 `conversation_id`,后续消息在同一会话中进行,Coze Bot 可获取完整上下文。
|
||||||
|
|
||||||
|
4. **消息格式适配:** 新增 `buildCozeMessages()` / `buildCozeRequest()` 方法,将用户输入(文本/图片/多模态)转换为 Coze 格式的 `additionalMessages`。
|
||||||
|
|
||||||
|
5. **降级策略:** 流式失败时自动降级为非流式 `api.cozeChat()`,并通过 `pollCozeResult()` 轮询获取最终回复。
|
||||||
|
|
||||||
|
**Coze 相比 KieAI 代理的优势:**
|
||||||
|
|
||||||
|
- 去掉一层 KieAI 代理网关,减少一跳网络延迟
|
||||||
|
- Coze 后端已配置 `X-Accel-Buffering: no` + `Cache-Control: no-cache`,SSE 不会被 nginx 缓冲
|
||||||
|
- Coze SDK 内置连接池管理,不再每次请求 new OkHttpClient
|
||||||
|
- 内置 15s heartbeat 防止连接超时断开
|
||||||
|
- 支持通过 `conversation_id` 进行多轮上下文对话
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 问题4:食物百科图片缺失
|
||||||
|
|
||||||
|
**决策:不处理。** 图片数据通过商城 PC 管理后台维护,属运营侧工作。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 问题5:百科卡片点击 → 跳转食物详情页
|
||||||
|
|
||||||
|
**页面:** `pages/tool/food-encyclopedia.vue`
|
||||||
|
|
||||||
|
**现状分析:**
|
||||||
|
当前卡片已绑定了 `@click="goToFoodDetail(item)"`(约第103行),`goToFoodDetail` 方法(约第1012-1030行)会尝试提取 item 的 id 并跳转到 `/pages/tool/food-detail`。项目中已存在 `pages/tool/food-detail.vue` 页面。
|
||||||
|
|
||||||
|
**问题:** 跳转逻辑本身已实现,但存在 Bug(见问题7),导致点击报错而非正常跳转,用户看到的效果就是"点击无反应"或"点击后闪退"。
|
||||||
|
|
||||||
|
**修改方案:** 修复问题7的 Bug 后,卡片点击跳转详情页的功能即可正常工作。需确认:
|
||||||
|
|
||||||
|
1. `food-detail.vue` 详情页内容是否完整可用
|
||||||
|
2. `pages.json` 中是否已注册 `/pages/tool/food-detail` 路由
|
||||||
|
|
||||||
|
**工作量估计:** 与问题7合并处理,0.5天
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 问题6:角色权限与分销系统
|
||||||
|
|
||||||
|
**决策:暂不处理。** 临时方案为运营人员在后台手动为指定用户账号配置角色标签(医生/护士/普通用户)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 问题7(Bug):食物百科点击报错 TypeError
|
||||||
|
|
||||||
|
**页面:** `pages/tool/food-encyclopedia.vue`
|
||||||
|
**错误信息:**
|
||||||
|
```
|
||||||
|
TypeError: Cannot read property 'id' of undefined
|
||||||
|
at pickNumericIdStr (food-encyclopedia.vue:1015)
|
||||||
|
at VueComponent.goToFoodDetail (food-encyclopedia.vue:1022)
|
||||||
|
```
|
||||||
|
|
||||||
|
**根因分析:**
|
||||||
|
|
||||||
|
`goToFoodDetail(item)` 方法(第1012行)中,内部闭包 `pickNumericIdStr` 直接访问 `item.id`(第1015行),但未做空值校验。当 `filteredFoodList` 中的某个 item 为 `undefined` 或 `null` 时(可能因 API 返回脏数据、`normalizeFoodItem` 边界情况),点击即触发 TypeError。
|
||||||
|
|
||||||
|
**修复方案:**
|
||||||
|
|
||||||
|
在 `goToFoodDetail` 方法开头增加防御性校验(约第1012行):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
goToFoodDetail(item) {
|
||||||
|
// 防御性校验:避免 item 为空时报错
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
console.warn('[food-encyclopedia] goToFoodDetail: item 为空,跳过跳转')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickNumericIdStr = () => {
|
||||||
|
const cands = [item.id, item.foodId, item.food_id, item.v2FoodId, item.v2_food_id, item.foodID]
|
||||||
|
// ... 原有逻辑不变
|
||||||
|
}
|
||||||
|
// ... 后续逻辑不变
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
同时建议加固 `filteredFoodList` 计算属性(约第261行),确保过滤掉无效项:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
filteredFoodList() {
|
||||||
|
const list = (this.foodList || []).filter(item => item != null && typeof item === 'object' && item.name)
|
||||||
|
// ... 后续过滤逻辑不变
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**工作量估计:** 0.5天(含测试验证)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、执行优先级排序
|
||||||
|
|
||||||
|
| 优先级 | 问题 | 预估工时 | 负责方 |
|
||||||
|
|--------|------|----------|--------|
|
||||||
|
| **P0** | #7 百科点击报错 Bug | 0.5天 | 前端 |
|
||||||
|
| **P1** | #1 份数→克数 | 0.5-1天 | 前端+后端 |
|
||||||
|
| **P1** | #5 百科卡片跳转详情页 | 与#7合并 | 前端 |
|
||||||
|
| **P2** | #3 AI响应速度(已切换Coze流式API) | ✅ 已完成 | 前端 |
|
||||||
|
| **P2** | #2 AI对话新增操作按钮 | 1天 | 前端 |
|
||||||
|
| **—** | #4 百科图片 | 不处理 | 运营 |
|
||||||
|
| **—** | #6 角色权限 | 暂不处理 | 运营(后台手动) |
|
||||||
|
|
||||||
|
**总预估:** 前端约 3-4天,后端约 2-3天
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*基于 msh_single_uniapp 项目代码分析生成,所有代码引用均已核对源文件。*
|
||||||
Reference in New Issue
Block a user