diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/config/DoubaoConfig.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/config/DoubaoConfig.java new file mode 100644 index 0000000..b9ed819 --- /dev/null +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/config/DoubaoConfig.java @@ -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; +} diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/constants/SysConfigConstants.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/constants/SysConfigConstants.java index 3c80f72..2072280 100644 --- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/constants/SysConfigConstants.java +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/constants/SysConfigConstants.java @@ -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"; } diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/doubao/DoubaoChatRequest.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/doubao/DoubaoChatRequest.java new file mode 100644 index 0000000..00b285a --- /dev/null +++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/doubao/DoubaoChatRequest.java @@ -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 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; + } +} diff --git a/msh_crmeb_22/crmeb-front/src/main/java/com/zbkj/front/controller/DoubaoController.java b/msh_crmeb_22/crmeb-front/src/main/java/com/zbkj/front/controller/DoubaoController.java new file mode 100644 index 0000000..3332bb0 --- /dev/null +++ b/msh_crmeb_22/crmeb-front/src/main/java/com/zbkj/front/controller/DoubaoController.java @@ -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> getAiModelConfig() { + String model = systemConfigService.getValueByKey(SysConfigConstants.CONFIG_KEY_AI_CHAT_MODEL); + if (model == null || model.isEmpty()) { + model = "doubao"; // 默认使用豆包 + } + Map result = new HashMap<>(); + result.put("model", model.trim().toLowerCase()); + return CommonResult.success(result); + } + + /** + * 非流式对话 + */ + @ApiOperation(value = "对话", notes = "与豆包模型进行对话") + @PostMapping("/chat") + public Map 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); + } +} diff --git a/msh_crmeb_22/crmeb-front/src/main/resources/application-sophia.yml b/msh_crmeb_22/crmeb-front/src/main/resources/application-sophia.yml index bec4ab6..65dd397 100644 --- a/msh_crmeb_22/crmeb-front/src/main/resources/application-sophia.yml +++ b/msh_crmeb_22/crmeb-front/src/main/resources/application-sophia.yml @@ -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 diff --git a/msh_crmeb_22/crmeb-front/src/main/resources/application.yml b/msh_crmeb_22/crmeb-front/src/main/resources/application.yml index b8ccc0b..30e05a2 100644 --- a/msh_crmeb_22/crmeb-front/src/main/resources/application.yml +++ b/msh_crmeb_22/crmeb-front/src/main/resources/application.yml @@ -21,6 +21,7 @@ crmeb: - api/front/upload/imageOuter - api/front/coze/** - api/front/kieai/** + - api/front/doubao/** # 配置端口 server: diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolDoubaoServiceImpl.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolDoubaoServiceImpl.java new file mode 100644 index 0000000..a15cddd --- /dev/null +++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolDoubaoServiceImpl.java @@ -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 buildRequestBody(DoubaoChatRequest request, boolean stream) { + Map body = new LinkedHashMap<>(); + // 模型:优先使用请求指定的,否则使用配置默认的 + String model = (request.getModel() != null && !request.getModel().isEmpty()) + ? request.getModel() : config.getModel(); + body.put("model", model); + + // 消息列表 — 用 FastJSON 解析保留原始结构(与 KieAI 实现一致) + List 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 chat(DoubaoChatRequest request) { + if (config.getApiKey() == null || config.getApiKey().isEmpty()) { + throw new RuntimeException("豆包 API Key 未配置"); + } + Map 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 entity = + new org.springframework.http.HttpEntity<>(JSON.toJSONString(body), headers); + + try { + org.springframework.http.ResponseEntity 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 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; + } +} diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolDoubaoService.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolDoubaoService.java new file mode 100644 index 0000000..c11e97f --- /dev/null +++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolDoubaoService.java @@ -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 chat(DoubaoChatRequest request); + + /** + * 豆包对话(流式 SSE) + * + * @param request 对话请求参数 + * @return SSE 事件流 + */ + SseEmitter chatStream(DoubaoChatRequest request); +} diff --git a/msh_single_uniapp/api/models-api.js b/msh_single_uniapp/api/models-api.js index 2e0fea6..678774e 100644 --- a/msh_single_uniapp/api/models-api.js +++ b/msh_single_uniapp/api/models-api.js @@ -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 } diff --git a/msh_single_uniapp/pages/tool/ai-nutritionist.vue b/msh_single_uniapp/pages/tool/ai-nutritionist.vue index d3319b6..59998e1 100644 --- a/msh_single_uniapp/pages/tool/ai-nutritionist.vue +++ b/msh_single_uniapp/pages/tool/ai-nutritionist.vue @@ -224,6 +224,7 @@ export default { }, data() { return { + aiModel: 'doubao', // 当前AI模型: doubao / coze / gemini(从系统配置读取) botId: '7591133240535449654', conversationId: '', scrollTop: 0, @@ -261,6 +262,7 @@ export default { }); this.initRecorder(); this.initAudioContext(); + this.loadAiModelConfig(); }, onUnload() { this.stopRecordTimer(); @@ -277,6 +279,19 @@ export default { } }, 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() { // #ifdef MP-WEIXIN || APP-PLUS @@ -744,87 +759,65 @@ export default { 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) { 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 形态)。 - * 兼容上游 candidates、或根级即 completion 等变体;仅使用接口返回字段, - * 成功路径不使用 api/tool.js 的 getAIResponse 或本地关键词话术。 + * 构建 OpenAI 兼容格式的消息列表(用于豆包 API) + * @param {string|Array} content 消息内容 + * @param {string} type 消息类型 text / image / multimodal + * @returns {Array} messages 数组 [{role, content}] */ - getGeminiReplyFromResponse(response) { - if (!response || typeof response !== 'object') return ''; - const looksLikeCompletion = (o) => - 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 ''; - } + buildChatMessages(content, type) { + if (type === 'text') { + return [{ role: 'user', content: typeof content === 'string' ? content : String(content) }] } - // 主路径:OpenAI 形态 choices[0].message.content(与需求 data.choices[0].message.content 一致) - if (data && typeof data === 'object' && Array.isArray(data.choices) && data.choices.length > 0) { - const choice0 = data.choices[0]; - const msg = choice0 && choice0.message; - if (msg && typeof msg === 'object') { - const fromMsg = this.extractReplyContent(msg.content); - if (fromMsg.trim()) return fromMsg; + if (type === 'image') { + // 图片:尝试构建多模态消息 + let fileInfo = content + if (typeof fileInfo === 'string') { + try { fileInfo = JSON.parse(fileInfo) } catch (e) { /* 非JSON */ } } - 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 imageUrl = (fileInfo && fileInfo.url) || (fileInfo && fileInfo.path) || '' + if (imageUrl) { + return [{ + role: 'user', + content: [ + { type: 'image_url', image_url: { url: imageUrl } }, + { type: 'text', text: '请分析这张图片' } + ] + }] } + return [{ role: 'user', content: '我发送了一张图片,请帮我分析' }] } - 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 ''; + // multimodal:直接传多模态 parts + const parts = Array.isArray(content) ? content : [{ type: 'text', text: String(content) }] + return [{ role: 'user', content: parts }] }, async sendToAI(content, type) { @@ -833,54 +826,15 @@ export default { this.messageList.push(aiMsg); this.scrollToBottom(); - const messages = this.buildGeminiMessages(content, type); - - // 优先尝试流式输出以改善响应速度感知 try { - await new Promise((resolve, reject) => { - const ctrl = api.kieaiGeminiChatStream({ messages }) - this._streamCtrl = ctrl - - // 收到第一个 chunk 后切换为 streaming 状态 - ctrl.onMessage((deltaText) => { - if (aiMsg.loading) { - aiMsg.loading = false - 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 || '抱歉,处理您的请求时出现错误,请稍后再试。'; + // 根据系统配置的 aiModel 分发到不同的 API + if (this.aiModel === 'coze') { + await this._sendViaCoze(content, type, aiMsg) + } else if (this.aiModel === 'gemini') { + await this._sendViaGemini(content, type, aiMsg) + } else { + // 默认:豆包 + await this._sendViaDoubao(content, type, aiMsg) } } finally { 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 方法 ---------- initAudioContext() { diff --git a/sql/add_ai_chat_model_config.sql b/sql/add_ai_chat_model_config.sql new file mode 100644 index 0000000..1c28202 --- /dev/null +++ b/sql/add_ai_chat_model_config.sql @@ -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'; diff --git a/test-doubao-api.py b/test-doubao-api.py new file mode 100644 index 0000000..a658a61 --- /dev/null +++ b/test-doubao-api.py @@ -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) diff --git a/test-doubao-api.sh b/test-doubao-api.sh new file mode 100644 index 0000000..93a8e4e --- /dev/null +++ b/test-doubao-api.sh @@ -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 "============================================" diff --git a/客户反馈分析报告.md b/客户反馈分析报告.md new file mode 100644 index 0000000..20f8179 --- /dev/null +++ b/客户反馈分析报告.md @@ -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 +{{ item.portion }} 份 +``` + +数据结构 `foodList` 中每个 item 包含 `{ number, name, portion }`,`portion` 为份数值。 + +**修改方案:** + +1. **后端接口改造**(推荐):`getCalculatorResult` 接口返回数据中增加 `gram` 字段,或将 `portion` 的含义改为克数。 + +2. **前端模板修改**(`calculator-result.vue` 约第94-113行): + +```html + +食物份数建议 +... +{{ item.portion }} 份 + + +每日食物建议 +... +{{ item.gram || item.portion }} 克 +``` + +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 + + + {{ ttsPlayingIndex === index ? '⏹' : '▶' }} + + + + + + + 📋 + + + + 🔄 + + + + {{ ttsPlayingIndex === index ? '⏹' : '▶' }} + + + + 🗑️ + + +``` + +**新增 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 项目代码分析生成,所有代码引用均已核对源文件。*