Junjie
8 天以前 4898d942bd6e3c1119493cf0314b15f2bd54daf3
#mcp
1个文件已添加
10个文件已修改
961 ■■■■■ 已修改文件
src/main/java/com/zy/ai/controller/WcsDiagnosisController.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/entity/ChatCompletionRequest.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/mcp/config/McpToolsBootstrap.java 34 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/mcp/controller/McpController.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/LlmChatService.java 246 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/PythonService.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/WcsDiagnosisService.java 395 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/utils/AiPromptUtils.java 111 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/utils/AiUtils.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application.yml 14 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/ai/diagnosis.html 39 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/controller/WcsDiagnosisController.java
@@ -2,7 +2,6 @@
import com.zy.ai.entity.ChatCompletionRequest;
import com.zy.ai.entity.WcsDiagnosisRequest;
import com.zy.ai.entity.WcsDiagnosisResponse;
import com.zy.ai.service.WcsDiagnosisService;
import com.zy.ai.utils.AiUtils;
import com.zy.common.web.BaseController;
@@ -24,13 +23,6 @@
    private WcsDiagnosisService wcsDiagnosisService;
    @Autowired
    private AiUtils aiUtils;
    @GetMapping("/runAi")
    public WcsDiagnosisResponse runAi() {
        WcsDiagnosisRequest request = aiUtils.makeAiRequest(1000, "系统当前不执行任务,但具体原因不明,请根据以下信息帮助判断。\n\n");
        WcsDiagnosisResponse response = diagnose(request);
        return response;
    }
    @GetMapping("/runAiStream")
    public SseEmitter runAiStream() {
@@ -77,18 +69,5 @@
    @GetMapping("/chats/{chatId}/history")
    public List<ChatCompletionRequest.Message> getChatHistory(@PathVariable("chatId") String chatId) {
        return wcsDiagnosisService.getChatHistory(chatId);
    }
    /**
     * POST /api/ai/diagnose/wcs
     */
    @PostMapping("/wcs")
    public WcsDiagnosisResponse diagnose(@RequestBody WcsDiagnosisRequest request) {
        String analysis = wcsDiagnosisService.diagnose(request);
        WcsDiagnosisResponse resp = new WcsDiagnosisResponse();
        resp.setAnalysis(analysis);
        resp.setOriginalRequest(request);
        return resp;
    }
}
src/main/java/com/zy/ai/entity/ChatCompletionRequest.java
@@ -13,10 +13,28 @@
    private Double temperature;
    private Integer max_tokens;
    private Boolean stream;
    private List<Object> tools;
    private Object tool_choice;
    @Data
    public static class Message {
        private String role;    // "user" / "assistant" / "system"
        private String content;
        private String name;
        private String tool_call_id;
        private List<ToolCall> tool_calls;
    }
    @Data
    public static class ToolCall {
        private String id;
        private String type;
        private Function function;
    }
    @Data
    public static class Function {
        private String name;
        private String arguments;
    }
}
src/main/java/com/zy/ai/mcp/config/McpToolsBootstrap.java
@@ -13,10 +13,10 @@
    public static void registerAll(ToolRegistry registry, final WcsDataFacade facade) {
        registry.register(tool(
                "device.get_crn_status",
                "Query realtime status of a crn device by deviceNo.",
                "device_get_crn_status",
                "通过堆垛机编号查询堆垛机设备实时数据",
                schemaObj(
                        propInt("crnNos", true)
                        propArr("crnNos", true, "integer")
                ),
                schemaObj(
                        propObj("devices", true)
@@ -29,8 +29,8 @@
        ));
        registry.register(tool(
                "device.get_station_status",
                "Query realtime status of a station device",
                "device_get_station_status",
                "查询输送线站点设备实时数据",
                schemaObj(
                ),
@@ -45,10 +45,10 @@
        ));
        registry.register(tool(
                "device.get_rgv_status",
                "Query realtime status of a rgv device by deviceNo.",
                "device_get_rgv_status",
                "通过RGV编号查询RGV设备实时数据",
                schemaObj(
                        propInt("rgvNos", true)
                        propArr("rgvNos", true, "integer")
                ),
                schemaObj(
                        propObj("devices", true)
@@ -61,8 +61,8 @@
        ));
        registry.register(tool(
                "task.list",
                "List tasks by filters (status/CrnDevice/RgvDevice//time window).",
                "task_list",
                "通过筛选条件查询任务数据",
                schemaObj(
                        propInt("crnNo", false),
                        propInt("rgvNo", false),
@@ -78,8 +78,8 @@
        ));
        registry.register(tool(
                "log.query",
                "Query logs by keyword/level/time window/device/task. Return clipped log lines.",
                "log_query",
                "通过筛选条件查询日志数据",
                schemaObj(
                        propInt("limit", false)
                ),
@@ -92,8 +92,8 @@
        ));
        registry.register(tool(
                "config.get_device_config",
                "Get device config by deviceCode.",
                "config_get_device_config",
                "通过设备编号查询设备配置数据",
                schemaObj(
                        propArr("crnNos", false, "integer"),
                        propArr("rgvNos", false, "integer"),
@@ -108,8 +108,8 @@
        ));
        registry.register(tool(
                "config.get_system_config",
                "Get key system configs for diagnosis.",
                "config_get_system_config",
                "查询系统配置数据",
                schemaObj(
                ),
@@ -229,4 +229,4 @@
        m.put("items", items);
        return m;
    }
}
}
src/main/java/com/zy/ai/mcp/controller/McpController.java
@@ -87,4 +87,19 @@
            return JsonRpcResponse.err(id, -32000, "Server error", e.getMessage());
        }
    }
}
    public List<Map<String, Object>> listTools() {
        return registry.listTools();
    }
    public Object callTool(String toolName, JSONObject arguments) throws Exception {
        if (toolName == null || toolName.trim().isEmpty()) {
            throw new IllegalArgumentException("missing tool name");
        }
        ToolDefinition def = registry.get(toolName);
        if (def == null) {
            throw new IllegalArgumentException("tool not found: " + toolName);
        }
        return def.getHandler().handle(arguments == null ? new JSONObject() : arguments);
    }
}
src/main/java/com/zy/ai/service/LlmChatService.java
@@ -12,6 +12,7 @@
import reactor.core.publisher.Mono;
import reactor.core.publisher.Flux;
import java.util.HashMap;
import java.util.List;
import java.util.function.Consumer;
import java.util.concurrent.LinkedBlockingQueue;
@@ -33,6 +34,9 @@
    @Value("${llm.model}")
    private String model;
    @Value("${llm.pythonPlatformUrl}")
    private String pythonPlatformUrl;
    /**
     * 通用对话方法:传入 messages,返回大模型文本回复
@@ -74,6 +78,47 @@
        }
        return response.getChoices().get(0).getMessage().getContent();
    }
    public ChatCompletionResponse chatCompletion(List<ChatCompletionRequest.Message> messages,
                                                 Double temperature,
                                                 Integer maxTokens,
                                                 List<Object> tools) {
        ChatCompletionRequest req = new ChatCompletionRequest();
        req.setModel(model);
        req.setMessages(messages);
        req.setTemperature(temperature != null ? temperature : 0.3);
        req.setMax_tokens(maxTokens != null ? maxTokens : 1024);
        req.setStream(false);
        if (tools != null && !tools.isEmpty()) {
            req.setTools(tools);
            req.setTool_choice("auto");
        }
        return complete(req);
    }
    public ChatCompletionResponse complete(ChatCompletionRequest req) {
        try {
            return llmWebClient.post()
                    .uri("/chat/completions")
                    .header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
                    .contentType(MediaType.APPLICATION_JSON)
                    .accept(MediaType.APPLICATION_JSON, MediaType.TEXT_EVENT_STREAM)
                    .bodyValue(req)
                    .exchangeToMono(resp -> resp.bodyToFlux(String.class)
                            .collectList()
                            .map(list -> {
                                String payload = String.join("\n\n", list);
                                return parseCompletion(payload);
                            }))
                    .doOnError(ex -> log.error("调用 LLM 失败", ex))
                    .onErrorResume(ex -> Mono.empty())
                    .block();
        } catch (Exception e) {
            log.error("调用 LLM 失败", e);
            return null;
        }
    }
    public void chatStream(List<ChatCompletionRequest.Message> messages,
@@ -172,6 +217,207 @@
        });
    }
    public void chatStreamWithTools(List<ChatCompletionRequest.Message> messages,
                                    Double temperature,
                                    Integer maxTokens,
                                    List<Object> tools,
                                    Consumer<String> onChunk,
                                    Runnable onComplete,
                                    Consumer<Throwable> onError) {
        ChatCompletionRequest req = new ChatCompletionRequest();
        req.setModel(model);
        req.setMessages(messages);
        req.setTemperature(temperature != null ? temperature : 0.3);
        req.setMax_tokens(maxTokens != null ? maxTokens : 1024);
        req.setStream(true);
        if (tools != null && !tools.isEmpty()) {
            req.setTools(tools);
            req.setTool_choice("auto");
        }
        Flux<String> flux = llmWebClient.post()
                .uri("/chat/completions")
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
                .contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.TEXT_EVENT_STREAM)
                .bodyValue(req)
                .retrieve()
                .bodyToFlux(String.class)
                .doOnError(ex -> log.error("调用 LLM 流式失败", ex));
        AtomicBoolean doneSeen = new AtomicBoolean(false);
        AtomicBoolean errorSeen = new AtomicBoolean(false);
        LinkedBlockingQueue<String> queue = new LinkedBlockingQueue<>();
        Thread drain = new Thread(() -> {
            try {
                while (true) {
                    String s = queue.poll(5, TimeUnit.SECONDS);
                    if (s != null) {
                        try { onChunk.accept(s); } catch (Exception ignore) {}
                    }
                    if (doneSeen.get() && queue.isEmpty()) {
                        if (!errorSeen.get()) {
                            try { if (onComplete != null) onComplete.run(); } catch (Exception ignore) {}
                        }
                        break;
                    }
                }
            } catch (InterruptedException ignore) {
                ignore.printStackTrace();
            }
        });
        drain.setDaemon(true);
        drain.start();
        flux.subscribe(payload -> {
            if (payload == null || payload.isEmpty()) return;
            String[] events = payload.split("\\r?\\n\\r?\\n");
            for (String part : events) {
                String s = part;
                if (s == null || s.isEmpty()) continue;
                if (s.startsWith("data:")) {
                    s = s.substring(5);
                    if (s.startsWith(" ")) s = s.substring(1);
                }
                if ("[DONE]".equals(s.trim())) {
                    doneSeen.set(true);
                    continue;
                }
                try {
                    JSONObject obj = JSON.parseObject(s);
                    JSONArray choices = obj.getJSONArray("choices");
                    if (choices != null && !choices.isEmpty()) {
                        JSONObject c0 = choices.getJSONObject(0);
                        JSONObject delta = c0.getJSONObject("delta");
                        if (delta != null) {
                            String content = delta.getString("content");
                            if (content != null) {
                                try { queue.offer(content); } catch (Exception ignore) {}
                            }
                        }
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }, err -> {
            errorSeen.set(true);
            doneSeen.set(true);
            if (onError != null) onError.accept(err);
        }, () -> {
            if (!doneSeen.get()) {
                errorSeen.set(true);
                doneSeen.set(true);
                if (onError != null) onError.accept(new RuntimeException("LLM 流意外完成"));
            } else {
                doneSeen.set(true);
            }
        });
    }
    public void chatStreamRunPython(String prompt, String chatId, Consumer<String> onChunk,
                                    Runnable onComplete,
                                    Consumer<Throwable> onError) {
        HashMap<String, Object> req = new HashMap<>();
        req.put("prompt", prompt);
        req.put("chatId", chatId);
        Flux<String> flux = llmWebClient.post()
                .uri(pythonPlatformUrl)
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
                .contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.TEXT_EVENT_STREAM)
                .bodyValue(req)
                .retrieve()
                .bodyToFlux(String.class)
                .doOnError(ex -> log.error("调用 LLM 流式失败", ex));
        AtomicBoolean doneSeen = new AtomicBoolean(false);
        AtomicBoolean errorSeen = new AtomicBoolean(false);
        LinkedBlockingQueue<String> queue = new LinkedBlockingQueue<>();
        Thread drain = new Thread(() -> {
            try {
                while (true) {
                    String s = queue.poll(2, TimeUnit.SECONDS);
                    if (s != null) {
                        try {
                            onChunk.accept(s);
                        } catch (Exception ignore) {
                        }
                    }
                    if (doneSeen.get() && queue.isEmpty()) {
                        if (!errorSeen.get()) {
                            try {
                                if (onComplete != null) onComplete.run();
                            } catch (Exception ignore) {
                            }
                        }
                        break;
                    }
                }
            } catch (InterruptedException ignore) {
                ignore.printStackTrace();
            }
        });
        drain.setDaemon(true);
        drain.start();
        flux.subscribe(payload -> {
            if (payload == null || payload.isEmpty()) return;
            String[] events = payload.split("\\r?\\n\\r?\\n");
            for (String part : events) {
                String s = part;
                if (s == null || s.isEmpty()) continue;
                if (s.startsWith("data:")) {
                    s = s.substring(5);
                    if (s.startsWith(" ")) s = s.substring(1);
                }
                if ("[DONE]".equals(s.trim())) {
                    doneSeen.set(true);
                    continue;
                }
                if("<think>".equals(s.trim()) || "</think>".equals(s.trim())) {
                    queue.offer(s.trim());
                    continue;
                }
                try {
                    JSONObject obj = JSON.parseObject(s);
                    JSONArray choices = obj.getJSONArray("choices");
                    if (choices != null && !choices.isEmpty()) {
                        JSONObject c0 = choices.getJSONObject(0);
                        JSONObject delta = c0.getJSONObject("delta");
                        if (delta != null) {
                            String content = delta.getString("content");
                            if (content != null) {
                                try {
                                    queue.offer(content);
                                } catch (Exception ignore) {
                                }
                            }
                        }
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }, err -> {
            errorSeen.set(true);
            doneSeen.set(true);
            if (onError != null) onError.accept(err);
        }, () -> {
            if (!doneSeen.get()) {
                errorSeen.set(true);
                doneSeen.set(true);
                if (onError != null) onError.accept(new RuntimeException("LLM 流意外完成"));
            } else {
                doneSeen.set(true);
            }
        });
    }
    private ChatCompletionResponse mergeSseChunk(ChatCompletionResponse acc, String payload) {
        if (payload == null || payload.isEmpty()) return acc;
        String[] events = payload.split("\\r?\\n\\r?\\n");
src/main/java/com/zy/ai/service/PythonService.java
New file
@@ -0,0 +1,55 @@
package com.zy.ai.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@Slf4j
@Component
public class PythonService {
    @Autowired
    private LlmChatService llmChatService;
    public boolean runPython(String prompt, String chatId, SseEmitter emitter) {
        try {
            llmChatService.chatStreamRunPython(prompt, chatId, s -> {
                try {
                    String safe = s == null ? "" : s.replace("\r", "").replace("\n", "\\n");
                    if (!safe.isEmpty()) {
                        sse(emitter, safe);
                    }
                } catch (Exception ignore) {
                }
            }, () -> {
                try {
                    sse(emitter, "\\n\\n【AI】运行已停止(正常结束)\\n\\n");
                    log.info("AI MCP diagnose stopped: final end");
                    emitter.complete();
                } catch (Exception ignore) {
                }
            }, e -> {
                sse(emitter, "\\n\\n【AI】分析出错,正在回退...\\n\\n");
            });
            return true;
        } catch (Exception e) {
            try {
                sse(emitter, "\\n\\n【AI】运行已停止(异常)\\n\\n");
                log.error("AI MCP diagnose stopped: error", e);
                emitter.completeWithError(e);
            } catch (Exception ignore) {}
            return true;
        }
    }
    private void sse(SseEmitter emitter, String data) {
        if (data == null) return;
        try {
            emitter.send(SseEmitter.event().data(data));
        } catch (Exception e) {
            log.warn("SSE send failed", e);
        }
    }
}
src/main/java/com/zy/ai/service/WcsDiagnosisService.java
@@ -1,15 +1,20 @@
package com.zy.ai.service;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.zy.ai.entity.ChatCompletionRequest;
import com.zy.ai.entity.ChatCompletionResponse;
import com.zy.ai.entity.WcsDiagnosisRequest;
import com.zy.ai.mcp.controller.McpController;
import com.zy.ai.utils.AiPromptUtils;
import com.zy.ai.utils.AiUtils;
import com.zy.common.utils.RedisUtil;
import com.zy.core.enums.RedisKeyType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
@@ -18,10 +23,13 @@
@Service
@RequiredArgsConstructor
@Slf4j
public class WcsDiagnosisService {
    private static final long CHAT_TTL_SECONDS = 7L * 24 * 3600;
    @Value("${llm.platform}")
    private String platform;
    @Autowired
    private LlmChatService llmChatService;
    @Autowired
@@ -30,31 +38,27 @@
    private AiPromptUtils aiPromptUtils;
    @Autowired
    private AiUtils aiUtils;
    /**
     * 针对“系统不执行任务 / 不知道哪个设备没在运行”的通用 AI 诊断
     */
    public String diagnose(WcsDiagnosisRequest request) {
        List<ChatCompletionRequest.Message> messages = new ArrayList<>();
        // 1. system:定义专家身份 + 输出结构
        ChatCompletionRequest.Message system = new ChatCompletionRequest.Message();
        system.setRole("system");
        system.setContent(aiPromptUtils.getAiDiagnosePrompt());
        messages.add(system);
        ChatCompletionRequest.Message user = new ChatCompletionRequest.Message();
        user.setRole("user");
        user.setContent(aiUtils.buildDiagnosisUserContent(request));
        messages.add(user);
        // 调用大模型
        return llmChatService.chat(messages, 0.2, 2048);
    }
    @Autowired(required = false)
    private McpController mcpController;
    @Autowired
    private PythonService pythonService;
    public void diagnoseStream(WcsDiagnosisRequest request, SseEmitter emitter) {
        List<ChatCompletionRequest.Message> messages = new ArrayList<>();
        ChatCompletionRequest.Message mcpSystem = new ChatCompletionRequest.Message();
        mcpSystem.setRole("system");
        mcpSystem.setContent(aiPromptUtils.getAiDiagnosePromptMcp());
        ChatCompletionRequest.Message mcpUser = new ChatCompletionRequest.Message();
        mcpUser.setRole("user");
        mcpUser.setContent(aiUtils.buildDiagnosisUserContentMcp(request));
        if (runMcpStreamingDiagnosis(messages, mcpSystem, mcpUser, 0.3, 2048, emitter, null)) {
            return;
        }
        messages = new ArrayList<>();
        ChatCompletionRequest.Message system = new ChatCompletionRequest.Message();
        system.setRole("system");
        system.setContent(aiPromptUtils.getAiDiagnosePrompt());
@@ -65,7 +69,7 @@
        user.setContent(aiUtils.buildDiagnosisUserContent(request));
        messages.add(user);
        llmChatService.chatStream(messages, 0.2, 2048, s -> {
        llmChatService.chatStream(messages, 0.3, 2048, s -> {
            try {
                String safe = s == null ? "" : s.replace("\r", "").replace("\n", "\\n");
                if (!safe.isEmpty()) {
@@ -73,9 +77,16 @@
                }
            } catch (Exception ignore) {}
        }, () -> {
            try { emitter.complete(); } catch (Exception ignore) {}
            try {
                log.info("AI diagnose stream stopped: normal end");
                emitter.complete();
            } catch (Exception ignore) {}
        }, e -> {
            try { emitter.completeWithError(e); } catch (Exception ignore) {}
            try {
                try { emitter.send(SseEmitter.event().data("【AI】运行已停止(异常)")); } catch (Exception ignore) {}
                log.error("AI diagnose stream stopped: error", e);
                emitter.completeWithError(e);
            } catch (Exception ignore) {}
        });
    }
@@ -84,12 +95,12 @@
                          String chatId,
                          boolean reset,
                          SseEmitter emitter) {
        List<ChatCompletionRequest.Message> base = new ArrayList<>();
        if (platform.equals("python")) {
            pythonService.runPython(prompt, chatId, emitter);
            return;
        }
        ChatCompletionRequest.Message system = new ChatCompletionRequest.Message();
        system.setRole("system");
        system.setContent(aiPromptUtils.getWcsSensorPrompt());
        base.add(system);
        List<ChatCompletionRequest.Message> messages = new ArrayList<>();
        List<ChatCompletionRequest.Message> history = null;
        String historyKey = null;
@@ -107,21 +118,11 @@
                    ChatCompletionRequest.Message m = convertToMessage(o);
                    if (m != null) history.add(m);
                }
                if (!history.isEmpty()) base.addAll(history);
                if (!history.isEmpty()) messages.addAll(history);
            } else {
                history = new ArrayList<>();
            }
        }
        ChatCompletionRequest.Message contextMsg = new ChatCompletionRequest.Message();
        contextMsg.setRole("user");
        contextMsg.setContent(aiUtils.buildAskUserContent(request));
        base.add(contextMsg);
        ChatCompletionRequest.Message questionMsg = new ChatCompletionRequest.Message();
        questionMsg.setRole("user");
        questionMsg.setContent("【用户提问】\n" + (prompt == null ? "" : prompt));
        base.add(questionMsg);
        StringBuilder assistantBuffer = new StringBuilder();
        final String finalChatId = chatId;
@@ -129,7 +130,30 @@
        final String finalMetaKey = metaKey;
        final String finalPrompt = prompt;
        llmChatService.chatStream(base, 0.2, 2048, s -> {
        ChatCompletionRequest.Message mcpSystem = new ChatCompletionRequest.Message();
        mcpSystem.setRole("system");
        mcpSystem.setContent(aiPromptUtils.getWcsSensorPromptMcp());
        ChatCompletionRequest.Message mcpUser = new ChatCompletionRequest.Message();
        mcpUser.setRole("user");
        mcpUser.setContent("【用户提问】\n" + (prompt == null ? "" : prompt));
        if (runMcpStreamingDiagnosis(messages, mcpSystem, mcpUser, 0.3, 2048, emitter, finalChatId)) {
            return;
        }
        messages = new ArrayList<>();
        ChatCompletionRequest.Message system = new ChatCompletionRequest.Message();
        system.setRole("system");
        system.setContent(aiPromptUtils.getWcsSensorPrompt());
        messages.add(system);
        ChatCompletionRequest.Message questionMsg = new ChatCompletionRequest.Message();
        questionMsg.setRole("user");
        questionMsg.setContent("【用户提问】\n" + (prompt == null ? "" : prompt));
        messages.add(questionMsg);
        llmChatService.chatStream(messages, 0.3, 2048, s -> {
            try {
                String safe = s == null ? "" : s.replace("\r", "").replace("\n", "\\n");
                if (!safe.isEmpty()) {
@@ -231,5 +255,294 @@
        String p = prompt.replaceAll("\n", " ").trim();
        return p.length() > 20 ? p.substring(0, 20) : p;
    }
    private boolean runMcpStreamingDiagnosis(List<ChatCompletionRequest.Message> baseMessages,
                                             ChatCompletionRequest.Message systemPrompt,
                                             ChatCompletionRequest.Message userQuestion,
                                             Double temperature,
                                             Integer maxTokens,
                                             SseEmitter emitter,
                                             String chatId) {
        try {
            if (mcpController == null) return false;
            List<Object> tools = buildOpenAiTools();
            if (tools.isEmpty()) return false;
            baseMessages.add(systemPrompt);
            baseMessages.add(userQuestion);
            List<ChatCompletionRequest.Message> messages = new ArrayList<>(baseMessages.size() + 8);
            messages.addAll(baseMessages);
            sse(emitter, "<think>\\n正在初始化诊断与工具环境...\\n");
            int maxRound = 10;
            int i = 0;
            while(true) {
                sse(emitter, "\\n正在分析(第" + (i + 1) + "轮)...\\n");
                ChatCompletionResponse resp = llmChatService.chatCompletion(messages, temperature, maxTokens, tools);
                if (resp == null || resp.getChoices() == null || resp.getChoices().isEmpty() || resp.getChoices().get(0).getMessage() == null) {
                    sse(emitter, "\\n分析出错,正在回退...\\n");
                    return false;
                }
                ChatCompletionRequest.Message assistant = resp.getChoices().get(0).getMessage();
                messages.add(assistant);
                sse(emitter, assistant.getContent());
                List<ChatCompletionRequest.ToolCall> toolCalls = assistant.getTool_calls();
                if (toolCalls == null || toolCalls.isEmpty()) {
                    break;
                }
                for (ChatCompletionRequest.ToolCall tc : toolCalls) {
                    String toolName = tc != null && tc.getFunction() != null ? tc.getFunction().getName() : null;
                    if (toolName == null || toolName.trim().isEmpty()) continue;
                    sse(emitter, "\\n准备调用工具:" + toolName + "\\n");
                    JSONObject args = new JSONObject();
                    if (tc.getFunction() != null && tc.getFunction().getArguments() != null && !tc.getFunction().getArguments().trim().isEmpty()) {
                        try {
                            args = JSON.parseObject(tc.getFunction().getArguments());
                        } catch (Exception ignore) {
                            args = new JSONObject();
                            args.put("_raw", tc.getFunction().getArguments());
                        }
                    }
                    Object output;
                    try {
                        output = mcpController.callTool(toolName, args);
                    } catch (Exception e) {
                        java.util.LinkedHashMap<String, Object> err = new java.util.LinkedHashMap<String, Object>();
                        err.put("tool", toolName);
                        err.put("error", e.getMessage());
                        output = err;
                    }
                    sse(emitter, "\\n工具返回,正在继续推理...\\n");
                    ChatCompletionRequest.Message toolMsg = new ChatCompletionRequest.Message();
                    toolMsg.setRole("tool");
                    toolMsg.setTool_call_id(tc == null ? null : tc.getId());
                    toolMsg.setContent(JSON.toJSONString(output));
                    messages.add(toolMsg);
                }
                if(i++ >= maxRound) break;
            }
            sse(emitter, "\\n正在根据数据进行分析...\\n</think>\\n\\n");
            ChatCompletionRequest.Message diagnosisMessage = new ChatCompletionRequest.Message();
            diagnosisMessage.setRole("system");
            diagnosisMessage.setContent("根据以上信息进行分析,并给出完整的诊断结论。");
            messages.add(diagnosisMessage);
            StringBuilder assistantBuffer = new StringBuilder();
            llmChatService.chatStreamWithTools(messages, temperature, maxTokens, tools, s -> {
                try {
                    String safe = s == null ? "" : s.replace("\r", "").replace("\n", "\\n");
                    if (!safe.isEmpty()) {
                        sse(emitter, safe);
                        assistantBuffer.append(safe);
                    }
                } catch (Exception ignore) {}
            }, () -> {
                try {
                    sse(emitter, "\\n\\n【AI】运行已停止(正常结束)\\n\\n");
                    log.info("AI MCP diagnose stopped: final end");
                    emitter.complete();
                    if (chatId != null) {
                        String historyKey = RedisKeyType.AI_CHAT_HISTORY.key + chatId;
                        String metaKey = RedisKeyType.AI_CHAT_META.key + chatId;
                        ChatCompletionRequest.Message a = new ChatCompletionRequest.Message();
                        a.setRole("assistant");
                        a.setContent(assistantBuffer.toString());
                        redisUtil.lSet(historyKey, userQuestion);
                        redisUtil.lSet(historyKey, a);
                        redisUtil.expire(historyKey, CHAT_TTL_SECONDS);
                        Map<Object, Object> old = redisUtil.hmget(metaKey);
                        Long createdAt = old != null && old.get("createdAt") != null ?
                                (old.get("createdAt") instanceof Number ? ((Number) old.get("createdAt")).longValue() : Long.valueOf(String.valueOf(old.get("createdAt"))))
                                : System.currentTimeMillis();
                        Map<String, Object> meta = new java.util.HashMap<>();
                        meta.put("chatId", chatId);
                        meta.put("title", buildTitleFromPrompt(userQuestion.getContent()));
                        meta.put("createdAt", createdAt);
                        meta.put("updatedAt", System.currentTimeMillis());
                        redisUtil.hmset(metaKey, meta, CHAT_TTL_SECONDS);
                    }
                } catch (Exception ignore) {}
            }, e -> {
                sse(emitter, "\\n\\n【AI】分析出错,正在回退...\\n\\n");
            });
            return true;
        } catch (Exception e) {
            try {
                sse(emitter, "\\n\\n【AI】运行已停止(异常)\\n\\n");
                log.error("AI MCP diagnose stopped: error", e);
                emitter.completeWithError(e);
            } catch (Exception ignore) {}
            return true;
        }
    }
    private void sse(SseEmitter emitter, String data) {
        if (data == null) return;
        try {
            emitter.send(SseEmitter.event().data(data));
        } catch (Exception e) {
            log.warn("SSE send failed", e);
        }
    }
    private List<Object> buildOpenAiTools() {
        if (mcpController == null) return java.util.Collections.emptyList();
        List<Map<String, Object>> mcpTools = mcpController.listTools();
        if (mcpTools == null || mcpTools.isEmpty()) return java.util.Collections.emptyList();
        List<Object> tools = new ArrayList<>();
        for (Map<String, Object> t : mcpTools) {
            if (t == null) continue;
            Object name = t.get("name");
            if (name == null) continue;
            Object inputSchema = t.get("inputSchema");
            java.util.LinkedHashMap<String, Object> function = new java.util.LinkedHashMap<String, Object>();
            function.put("name", String.valueOf(name));
            Object desc = t.get("description");
            if (desc != null) function.put("description", String.valueOf(desc));
            function.put("parameters", inputSchema == null ? new java.util.LinkedHashMap<String, Object>() : inputSchema);
            java.util.LinkedHashMap<String, Object> tool = new java.util.LinkedHashMap<String, Object>();
            tool.put("type", "function");
            tool.put("function", function);
            tools.add(tool);
        }
        return tools;
    }
    private void sendLargeText(SseEmitter emitter, String text) {
        if (text == null) return;
        String safe = text.replace("\r", "").replace("\n", "\\n");
        int chunkSize = 256;
        int i = 0;
        while (i < safe.length()) {
            int end = Math.min(i + chunkSize, safe.length());
            String part = safe.substring(i, end);
            if (!part.isEmpty()) {
                try { emitter.send(SseEmitter.event().data(part)); } catch (Exception ignore) {}
            }
            i = end;
        }
    }
    private static final java.util.regex.Pattern DSML_INVOKE_PATTERN =
            java.util.regex.Pattern.compile("<\\uFF5CDSML\\uFF5Cinvoke\\s+name=\\\"([^\\\"]+)\\\"[^>]*>([\\s\\S]*?)</\\uFF5CDSML\\uFF5Cinvoke>", java.util.regex.Pattern.MULTILINE);
    private static final java.util.regex.Pattern JSON_OBJECT_PATTERN =
            java.util.regex.Pattern.compile("\\{[\\s\\S]*\\}");
    private static final java.util.regex.Pattern DSML_PARAM_PATTERN =
            java.util.regex.Pattern.compile("<\\uFF5CDSML\\uFF5Cparameter\\s+name=\\\"([^\\\"]+)\\\"\\s*([^>]*)>([\\s\\S]*?)</\\uFF5CDSML\\uFF5Cparameter>", java.util.regex.Pattern.MULTILINE);
    private java.util.List<DsmlInvocation> parseDsmlInvocations(String content) {
        java.util.List<DsmlInvocation> list = new java.util.ArrayList<>();
        if (content == null || content.isEmpty()) return list;
        java.util.regex.Matcher m = DSML_INVOKE_PATTERN.matcher(content);
        while (m.find()) {
            String name = m.group(1);
            String inner = m.group(2);
            com.alibaba.fastjson.JSONObject args = null;
            if (inner != null) {
                java.util.regex.Matcher jm = JSON_OBJECT_PATTERN.matcher(inner);
                if (jm.find()) {
                    String json = jm.group();
                    try { args = com.alibaba.fastjson.JSON.parseObject(json); } catch (Exception ignore) {}
                }
                java.util.regex.Matcher pm = DSML_PARAM_PATTERN.matcher(inner);
                while (pm.find()) {
                    if (args == null) args = new com.alibaba.fastjson.JSONObject();
                    String pName = pm.group(1);
                    String attr = pm.group(2);
                    String valText = pm.group(3);
                    boolean isString = attr != null && attr.toLowerCase().contains("string=\"true\"");
                    String t = valText == null ? "" : valText.trim();
                    if (isString) {
                        args.put(pName, t);
                    } else {
                        if ("true".equalsIgnoreCase(t) || "false".equalsIgnoreCase(t)) {
                            args.put(pName, Boolean.valueOf(t));
                        } else {
                            try {
                                if (t.contains(".")) {
                                    args.put(pName, Double.valueOf(t));
                                } else {
                                    args.put(pName, Long.valueOf(t));
                                }
                            } catch (Exception ex) {
                                args.put(pName, t);
                            }
                        }
                    }
                }
            }
            DsmlInvocation inv = new DsmlInvocation();
            inv.name = name;
            inv.arguments = args;
            list.add(inv);
        }
        return list;
    }
    private static class DsmlInvocation {
        String name;
        com.alibaba.fastjson.JSONObject arguments;
    }
    private List<DsmlInvocation> buildDefaultStatusInvocations() {
        List<DsmlInvocation> list = new ArrayList<>();
        DsmlInvocation crn = new DsmlInvocation();
        crn.name = "device.get_crn_status";
        com.alibaba.fastjson.JSONObject a1 = new com.alibaba.fastjson.JSONObject();
        a1.put("limit", 20);
        crn.arguments = a1;
        list.add(crn);
        DsmlInvocation st = new DsmlInvocation();
        st.name = "device.get_station_status";
        com.alibaba.fastjson.JSONObject a2 = new com.alibaba.fastjson.JSONObject();
        a2.put("limit", 20);
        st.arguments = a2;
        list.add(st);
        DsmlInvocation rgv = new DsmlInvocation();
        rgv.name = "device.get_rgv_status";
        com.alibaba.fastjson.JSONObject a3 = new com.alibaba.fastjson.JSONObject();
        a3.put("limit", 20);
        rgv.arguments = a3;
        list.add(rgv);
        return list;
    }
    private void ensureStatusCoverage(List<DsmlInvocation> invs) {
        if (invs == null) return;
        java.util.Set<String> names = new java.util.HashSet<String>();
        for (DsmlInvocation d : invs) {
            if (d != null && d.name != null) names.add(d.name);
        }
        if (!names.contains("device.get_crn_status") || !names.contains("device.get_station_status") || !names.contains("device.get_rgv_status")) {
            List<DsmlInvocation> defaults = buildDefaultStatusInvocations();
            for (DsmlInvocation d : defaults) {
                if (!names.contains(d.name)) invs.add(d);
            }
        }
    }
    private boolean isConclusionText(String content) {
        if (content == null) return false;
        String c = content;
        int len = c.length();
        boolean longEnough = len >= 200;
        boolean hasAllSections = c.contains("问题概述") && c.contains("可疑设备列表") && c.contains("可能原因") && c.contains("建议排查步骤") && c.contains("风险评估");
        boolean hasExplicitConclusion = (c.contains("结论") || c.contains("诊断结果")) && longEnough;
        return hasAllSections || hasExplicitConclusion;
    }
}
src/main/java/com/zy/ai/utils/AiPromptUtils.java
@@ -6,6 +6,117 @@
public class AiPromptUtils {
    //AI诊断系统Prompt
    public String getAiDiagnosePromptMcp() {
        String prompt = "你是一名资深 WCS(仓储控制系统)与自动化立库专家,熟悉:堆垛机、输送线、提升机、穿梭车等设备的任务分配和运行逻辑,也熟悉常见的系统卡死、任务不执行、设备空闲但无任务等问题模式。\n\n" +
                "你可以按需调用系统提供的工具以获取实时数据与上下文(工具返回 JSON):\n" +
                "- 任务:task_list\n" +
                "- 设备实时状态:device_get_crn_status / device_get_station_status / device_get_rgv_status\n" +
                "- 日志:log_query\n" +
                "- 设备配置:config_get_device_config\n" +
                "- 系统配置:config_get_system_config\n\n" +
                "使用策略:\n" +
                "1)避免臆测。如信息不足,先调用相应工具收集必要数据;可多轮调用。\n" +
                "2)对工具返回的 JSON 先进行结构化归纳,提炼关键字段,再做推理。\n" +
                "3)优先顺序:任务→设备状态→日志→配置;按需调整。\n\n" +
                "你将收到以下几类数据:\n" +
                "1)任务信息(tasks):当前待执行/在执行/挂起任务\n" +
                "2)设备实时数据(deviceRealtimeData):每台设备当前状态、是否在线、当前任务号等\n" +
                "3)设备配置信息(deviceConfigs):设备是否启用、服务区域、允许的任务类型等\n" +
                "4)系统日志(logs):按时间顺序的日志文本\n" +
                "5)额外上下文(extraContext):如仓库代码、WCS 版本等\n\n" +
                "6)系统配置信息(systemConfigs):系统的配置参数,数据库表名sys_config\n\n" +
                "你的目标是:帮助现场运维人员分析,为什么系统当前不执行任务,或者任务执行效率异常,指出可能是哪些设备导致的问题。\n\n" +
                "请按以下结构输出诊断结果(使用简体中文):\n" +
                "1. 问题概述(1-3 句话,概括当前系统状态)\n" +
                "2. 可疑设备列表(列出 1-N 个设备编号,并说明每个设备为什么可疑,例如:配置禁用/长时间空闲/状态异常/任务分配不到它等)\n" +
                "3. 可能原因(从任务分配、设备状态、配置错误、接口/通信异常等角度,列出 3-7 条)\n" +
                "4. 建议排查步骤(步骤 1、2、3...,每步要尽量具体、可操作,例如:在某页面查看某字段、检查某个开关、对比某个状态位等)\n" +
                "5. 风险评估(说明当前问题对业务影响程度:高/中/低,以及是否需要立即人工干预)\n" +
                "如需要额外数据,请先调用合适的工具再继续回答。";
        return prompt;
    }
    //WCS高级专家Prompt
    public String getWcsSensorPromptMcp() {
//        String prompt = "你是一名资深 WCS(仓储控制系统)与自动化立库专家,熟悉:堆垛机、输送线、提升机、穿梭车等设备的任务分配和运行逻辑,也熟悉常见的系统卡死、任务不执行、设备空闲但无任务等问题模式。\n\n" +
//                "你可以按需调用系统提供的工具以获取实时数据与上下文(工具返回 JSON):\n" +
//                "- 任务:task_list\n" +
//                "- 设备实时状态:device_get_crn_status / device_get_station_status / device_get_rgv_status\n" +
//                "- 日志:log_query\n" +
//                "- 设备配置:config_get_device_config\n" +
//                "- 系统配置:config_get_system_config\n\n" +
//                "请先用工具获取必要信息,再以简洁、明确的中文作答,并在需要时给出可执行的排查建议。" +
//                "如需要额外数据,请先调用合适的工具再继续回答。";
        String prompt = "你是一名资深 WCS(仓储控制系统)与自动化立库专家,\n" +
                "精通堆垛机、输送线、提升机、穿梭车、RGV、工位等设备的\n" +
                "任务分配、运行状态流转与异常处理。\n" +
                "\n" +
                "你的职责是:**基于实时数据进行工程级诊断,而不是凭经验猜测。**\n" +
                "\n" +
                "==================== 工作规则(非常重要) ====================\n" +
                "\n" +
                "1. **禁止在未获取实时数据的情况下直接下结论。**\n" +
                "   - 若问题涉及“当前状态 / 是否卡死 / 是否有任务 / 是否异常”,\n" +
                "     你必须先调用工具获取数据,再进行分析。\n" +
                "\n" +
                "2. **优先使用聚合式信息,其次再使用单项查询。**\n" +
                "   - 若需要整体判断系统状态,请优先调用:\n" +
                "     → build_diagnosis_snapshot(如可用)\n" +
                "   - 若只需要局部补充信息,再调用单项工具。\n" +
                "\n" +
                "3. **当信息不足以判断时,不得猜测原因。**\n" +
                "   - 必须明确指出“缺少哪些数据”,并调用对应工具获取。\n" +
                "\n" +
                "4. **工具返回的数据是事实依据,必须引用其关键信息进行推理。**\n" +
                "\n" +
                "==================== 可用工具(返回 JSON) ====================\n" +
                "\n" +
                "【任务相关】\n" +
                "- task_list               —— 查询当前/历史任务及状态\n" +
                "\n" +
                "【设备实时状态】\n" +
                "- device_get_crn_status   —— 堆垛机实时状态\n" +
                "- device_get_station_status —— 工位实时状态\n" +
                "- device_get_rgv_status   —— RGV / 穿梭车实时状态\n" +
                "\n" +
                "【日志】\n" +
                "- log_query               —— 查询系统/设备日志(按时间/关键字)\n" +
                "\n" +
                "【配置】\n" +
                "- config_get_device_config —— 设备配置(启用、模式、策略)\n" +
                "- config_get_system_config —— 系统级调度/策略配置\n" +
                "\n" +
                "==================== 推荐诊断流程 ====================\n" +
                "\n" +
                "当接到诊断请求时,请遵循以下步骤:\n" +
                "\n" +
                "Step 1\uFE0F⃣ 明确诊断目标  \n" +
                "- 当前要判断的是:设备是否异常?任务是否卡死?调度是否阻塞?\n" +
                "\n" +
                "Step 2\uFE0F⃣ 调用必要工具获取事实数据  \n" +
                "- 设备状态 → 是否在线 / 是否空闲 / 当前任务\n" +
                "- 任务状态 → 是否存在待执行/挂起任务\n" +
                "- 日志 → 是否存在关键异常、等待确认、命令未响应等信息\n" +
                "\n" +
                "Step 3\uFE0F⃣ 基于数据进行逻辑分析  \n" +
                "- 使用 WCS 专业知识进行因果判断(而非猜测)\n" +
                "\n" +
                "Step 4\uFE0F⃣ 输出结构化结论  \n" +
                "- 【现象总结】\n" +
                "- 【关键证据(来自工具返回)】\n" +
                "- 【可能原因(按优先级)】\n" +
                "- 【可执行的排查 / 处理建议】\n" +
                "\n" +
                "==================== 输出要求 ====================\n" +
                "\n" +
                "- 使用**简洁、明确的中文**\n" +
                "- 避免泛泛而谈、避免“可能/也许”式空泛描述\n" +
                "- 若需要进一步数据,请**先调用工具,再继续分析**\n";
        return prompt;
    }
    //AI诊断系统Prompt
    public String getAiDiagnosePrompt() {
        String prompt = "你是一名资深 WCS(仓储控制系统)与自动化立库专家,熟悉:堆垛机、输送线、提升机、穿梭车等设备的任务分配和运行逻辑,也熟悉常见的系统卡死、任务不执行、设备空闲但无任务等问题模式。\n\n" +
                "你将收到以下几类数据:\n" +
src/main/java/com/zy/ai/utils/AiUtils.java
@@ -265,4 +265,15 @@
        return sb.toString();
    }
    public String buildDiagnosisUserContentMcp(WcsDiagnosisRequest request) {
        StringBuilder sb = new StringBuilder();
        if (request.getAlarmMessage() != null && !request.getAlarmMessage().isEmpty()) {
            sb.append("【问题描述】\n");
            sb.append(request.getAlarmMessage()).append("\n\n");
        }
        return sb.toString();
    }
}
src/main/resources/application.yml
@@ -78,15 +78,23 @@
  expireDays: 7
llm:
  platform: python
  pythonPlatformUrl: http://127.0.0.1:9000/ai/diagnose/askStream
#  base-url: https://api.siliconflow.cn/v1
#  api-key: sk-sxdtebtquwrugzrmaqqqkzdzmrgzhzmplwwuowysdasccent
#  model: deepseek-ai/DeepSeek-V3.2
  base-url: http://47.76.147.249:9998/e/7g7kqxxt1ei2un71
  api-key: app-mP0O6aY5WpbfaHs7BNnjVkli
  model: deepseek-ai/DeepSeek-V3.2
#  base-url: http://47.76.147.249:9998/e/7g7kqxxt1ei2un71
#  api-key: app-mP0O6aY5WpbfaHs7BNnjVkli
#  model: deepseek-ai/DeepSeek-V3.2
#  base-url: http://34.2.134.223:3000/v1
#  api-key: sk-WabrmtOezCFwVo7XvVOrO3QkmfcKG7T7jy0BaVnmQTWm5GXh
#  model: gemini-3-pro-preview
#  base-url: http://127.0.0.1:8317/v1
#  api-key: WznOjAGJNVFKSe9kBZTr
#  model: gpt-5
  base-url: https://api.xiaomimimo.com/v1
  api-key: sk-cw7e4se9cal8cxdgjml8dmtn4pdmqtvfccg5fcermt0ddtys
  model: mimo-v2-flash
perf:
  methodTiming:
src/main/webapp/views/ai/diagnosis.html
@@ -28,6 +28,26 @@
    .time { font-size: 12px; color: #909399; text-align: right; margin-top: 6px; }
    .output .el-card__body { height: 100%; padding: 0; }
    .assistant-running { display: flex; align-items: center; gap: 8px; color: #909399; }
    details.think-block {
      border: 1px solid #e4e7ed;
      border-radius: 4px;
      padding: 8px;
      margin: 8px 0;
      background-color: #fcfcfc;
    }
    details.think-block summary {
      cursor: pointer;
      color: #909399;
      font-size: 13px;
      font-weight: bold;
      outline: none;
    }
    details.think-block .content {
      margin-top: 8px;
      color: #606266;
      font-size: 13px;
      white-space: pre-wrap;
    }
  </style>
</head>
<body>
@@ -130,6 +150,17 @@
        }
      },
      methods: {
        renderMarkdown: function(md, streaming) {
          if (!md) return '';
          var src = md.replace(/\\n/g, '\n');
          var openAttr = streaming ? ' open' : '';
          src = src.replace(/<think>/g, '<details class="think-block"' + openAttr + '><summary>AI深度思考</summary><div class="content">');
          src = src.replace(/<\/think>/g, '</div></details>');
          if (streaming && src.indexOf('<details class="think-block"') >= 0 && src.indexOf('</div></details>') < 0) {
             src += '</div></details>';
          }
          return DOMPurify.sanitize(marked.parse(src));
        },
        loadChats: function() {
          var self = this;
          fetch(baseUrl + '/ai/diagnose/chats', { headers: { 'token': localStorage.getItem('token') } })
@@ -146,7 +177,7 @@
              var msgs = [];
              for (var i=0;i<arr.length;i++) {
                var m = arr[i];
                if (m.role === 'assistant') msgs.push({ role: 'assistant', md: m.content || '', html: DOMPurify.sanitize(marked.parse((m.content||'').replace(/\\n/g,'\n'))), ts: self.nowStr() });
                if (m.role === 'assistant') msgs.push({ role: 'assistant', md: m.content || '', html: self.renderMarkdown(m.content || '', false), ts: self.nowStr() });
                else msgs.push({ role: 'user', text: m.content || '', ts: self.nowStr() });
              }
              self.messages = msgs;
@@ -274,8 +305,7 @@
              self.lastRenderTs = now;
              var last = self.messages.length > 0 ? self.messages[self.messages.length - 1] : null;
              if (last && last.role === 'assistant') {
                var renderSource = (last.md || '').replace(/\\n/g, '\n');
                last.html = DOMPurify.sanitize(marked.parse(renderSource));
                last.html = self.renderMarkdown(last.md || '', true);
                self.$nextTick(function() { self.scrollToBottom(true); });
              }
            }
@@ -298,8 +328,7 @@
            this.typingTimer = null;
          }
          if (last && last.role === 'assistant') {
            var renderSource = (last.md || '').replace(/\n/g, '\n');
            last.html = DOMPurify.sanitize(marked.parse(renderSource));
            last.html = this.renderMarkdown(last.md || '', false);
          }
          this.$nextTick(function() { this.scrollToBottom(true); }.bind(this));
          this.loadChats();