58dc0727a11481c127fc6111b73fa309b03505b5..a19b1025890eb0f71b44a9d1bb948cb084d4106a
8 小时以前 Junjie
#
a19b10 对比 | 目录
10 小时以前 Junjie
#AI
8e5472 对比 | 目录
11 小时以前 Junjie
#AI
a7e973 对比 | 目录
11 小时以前 Junjie
#AI
b147c1 对比 | 目录
11 小时以前 Junjie
#AI
88fa84 对比 | 目录
12 小时以前 Junjie
#AI
53fa1a 对比 | 目录
13 小时以前 Junjie
#AI
4c09d0 对比 | 目录
14 小时以前 Junjie
#AI
d600bb 对比 | 目录
15 小时以前 Junjie
#AI
3210f0 对比 | 目录
15 小时以前 Junjie
#AI
5aa5ba 对比 | 目录
15 小时以前 Junjie
#AI
0f7621 对比 | 目录
17 小时以前 Junjie
#AI
7fef7f 对比 | 目录
3个文件已添加
2个文件已删除
12个文件已修改
1355 ■■■■ 已修改文件
src/main/java/com/zy/ai/controller/WcsDiagnosisController.java 174 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/log/AiLogAppender.java 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/LlmChatService.java 14 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/WcsDiagnosisService.java 222 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/utils/AiUtils.java 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/utils/Utils.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/service/CommonService.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/enums/RedisKeyType.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/plugin/FakeProcess.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/task/MakeMainProcessPseudocodeScheduler.java 210 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/utils/CrnOperateProcessUtils.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application.yml 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/ai/diagnose.js 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/common.js 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/ai/diagnose.html 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/ai/diagnosis.html 310 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/index.html 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/controller/WcsDiagnosisController.java
@@ -1,31 +1,16 @@
package com.zy.ai.controller;
import com.baomidou.mybatisplus.mapper.EntityWrapper;
import com.zy.ai.entity.DeviceConfigsData;
import com.zy.ai.entity.DeviceRealTimeData;
import com.zy.ai.entity.ChatCompletionRequest;
import com.zy.ai.entity.WcsDiagnosisRequest;
import com.zy.ai.entity.WcsDiagnosisResponse;
import com.zy.ai.log.AiLogAppender;
import com.zy.ai.service.WcsDiagnosisService;
import com.zy.asrs.entity.BasCrnp;
import com.zy.asrs.entity.WrkMast;
import com.zy.asrs.service.BasCrnpService;
import com.zy.asrs.service.WrkMastService;
import com.zy.core.cache.SlaveConnection;
import com.zy.core.enums.SlaveType;
import com.zy.core.model.StationObjModel;
import com.zy.core.model.protocol.CrnProtocol;
import com.zy.core.model.protocol.StationProtocol;
import com.zy.core.thread.CrnThread;
import com.zy.core.thread.StationThread;
import com.zy.ai.utils.AiUtils;
import com.zy.common.web.BaseController;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -33,74 +18,16 @@
@RestController
@RequestMapping("/ai/diagnose")
@RequiredArgsConstructor
public class WcsDiagnosisController {
public class WcsDiagnosisController extends BaseController {
    @Autowired
    private WcsDiagnosisService wcsDiagnosisService;
    @Autowired
    private WrkMastService wrkMastService;
    @Autowired
    private BasCrnpService basCrnpService;
    private AiUtils aiUtils;
    @GetMapping("/runAi")
    public WcsDiagnosisResponse runAi() {
        WcsDiagnosisRequest request = new WcsDiagnosisRequest();
        request.setAlarmMessage("系统不执行任务");
        List<String> logs = AiLogAppender.getRecentLogs(100);
        request.setLogs(logs);
        List<WrkMast> wrkMasts = wrkMastService.selectList(new EntityWrapper<>());
        request.setTasks(wrkMasts);
        List<DeviceRealTimeData> deviceRealTimeDataList = new ArrayList<>();
        List<DeviceConfigsData> deviceConfigsDataList = new ArrayList<>();
        List<BasCrnp> basCrnps = basCrnpService.selectList(new EntityWrapper<>());
        for (BasCrnp basCrnp : basCrnps) {
            CrnThread crnThread = (CrnThread) SlaveConnection.get(SlaveType.Crn, basCrnp.getCrnNo());
            if (crnThread == null) {
                continue;
            }
            CrnProtocol protocol = crnThread.getStatus();
            for (StationObjModel stationObjModel : basCrnp.getInStationList$()) {
                StationThread stationThread = (StationThread) SlaveConnection.get(SlaveType.Devp, stationObjModel.getDeviceNo());
                if (stationThread == null) {
                    continue;
                }
                Map<Integer, StationProtocol> map = stationThread.getStatusMap();
                StationProtocol stationProtocol = map.get(stationObjModel.getStationId());
                if (stationProtocol == null) {
                    continue;
                }
                DeviceRealTimeData stationData = new DeviceRealTimeData();
                stationData.setDeviceNo(stationObjModel.getDeviceNo());
                stationData.setDeviceType(String.valueOf(SlaveType.Devp));
                stationData.setDeviceData(stationProtocol);
                deviceRealTimeDataList.add(stationData);
            }
            DeviceRealTimeData deviceRealTimeData = new DeviceRealTimeData();
            deviceRealTimeData.setDeviceNo(basCrnp.getCrnNo());
            deviceRealTimeData.setDeviceType(String.valueOf(SlaveType.Crn));
            deviceRealTimeData.setDeviceData(protocol);
            deviceRealTimeDataList.add(deviceRealTimeData);
            DeviceConfigsData deviceConfigsData = new DeviceConfigsData();
            deviceConfigsData.setDeviceNo(basCrnp.getCrnNo());
            deviceConfigsData.setDeviceType(String.valueOf(SlaveType.Crn));
            deviceConfigsData.setDeviceData(basCrnp);
            deviceConfigsDataList.add(deviceConfigsData);
        }
        request.setDeviceRealtimeData(deviceRealTimeDataList);
        request.setDeviceConfigs(deviceConfigsDataList);
        WcsDiagnosisRequest request = aiUtils.makeAiRequest(1000, "系统当前不执行任务,但具体原因不明,请根据以下信息帮助判断。\n\n");
        WcsDiagnosisResponse response = diagnose(request);
        return response;
    }
@@ -111,63 +38,7 @@
        new Thread(() -> {
            try {
                WcsDiagnosisRequest request = new WcsDiagnosisRequest();
                request.setAlarmMessage("系统不执行任务");
                List<String> logs = AiLogAppender.getRecentLogs(100);
                request.setLogs(logs);
                List<WrkMast> wrkMasts = wrkMastService.selectList(new EntityWrapper<>());
                request.setTasks(wrkMasts);
                List<DeviceRealTimeData> deviceRealTimeDataList = new ArrayList<>();
                List<DeviceConfigsData> deviceConfigsDataList = new ArrayList<>();
                List<BasCrnp> basCrnps = basCrnpService.selectList(new EntityWrapper<>());
                for (BasCrnp basCrnp : basCrnps) {
                    CrnThread crnThread = (CrnThread) SlaveConnection.get(SlaveType.Crn, basCrnp.getCrnNo());
                    if (crnThread == null) {
                        continue;
                    }
                    CrnProtocol protocol = crnThread.getStatus();
                    for (StationObjModel stationObjModel : basCrnp.getInStationList$()) {
                        StationThread stationThread = (StationThread) SlaveConnection.get(SlaveType.Devp, stationObjModel.getDeviceNo());
                        if (stationThread == null) {
                            continue;
                        }
                        Map<Integer, StationProtocol> map = stationThread.getStatusMap();
                        StationProtocol stationProtocol = map.get(stationObjModel.getStationId());
                        if (stationProtocol == null) {
                            continue;
                        }
                        DeviceRealTimeData stationData = new DeviceRealTimeData();
                        stationData.setDeviceNo(stationObjModel.getDeviceNo());
                        stationData.setDeviceType(String.valueOf(SlaveType.Devp));
                        stationData.setDeviceData(stationProtocol);
                        deviceRealTimeDataList.add(stationData);
                    }
                    DeviceRealTimeData deviceRealTimeData = new DeviceRealTimeData();
                    deviceRealTimeData.setDeviceNo(basCrnp.getCrnNo());
                    deviceRealTimeData.setDeviceType(String.valueOf(SlaveType.Crn));
                    deviceRealTimeData.setDeviceData(protocol);
                    deviceRealTimeDataList.add(deviceRealTimeData);
                    DeviceConfigsData deviceConfigsData = new DeviceConfigsData();
                    deviceConfigsData.setDeviceNo(basCrnp.getCrnNo());
                    deviceConfigsData.setDeviceType(String.valueOf(SlaveType.Crn));
                    deviceConfigsData.setDeviceData(basCrnp);
                    deviceConfigsDataList.add(deviceConfigsData);
                }
                request.setDeviceRealtimeData(deviceRealTimeDataList);
                request.setDeviceConfigs(deviceConfigsDataList);
                WcsDiagnosisRequest request = aiUtils.makeAiRequest(1000, "系统当前不执行任务,但具体原因不明,请根据以下信息帮助判断。\n\n");
                wcsDiagnosisService.diagnoseStream(request, emitter);
            } catch (Exception e) {
                emitter.completeWithError(e);
@@ -177,6 +48,37 @@
        return emitter;
    }
    @GetMapping("/askStream")
    public SseEmitter askStream(@RequestParam("prompt") String prompt,
                                @RequestParam(value = "chatId", required = false) String chatId,
                                @RequestParam(value = "reset", required = false, defaultValue = "false") boolean reset) {
        SseEmitter emitter = new SseEmitter(0L);
        new Thread(() -> {
            try {
                WcsDiagnosisRequest request = aiUtils.makeAiRequest(100, null);
                wcsDiagnosisService.askStream(request, prompt, chatId, reset, emitter);
            } catch (Exception e) {
                emitter.completeWithError(e);
            }
        }).start();
        return emitter;
    }
    @GetMapping("/chats")
    public List<Map<String, Object>> listChats() {
        return wcsDiagnosisService.listChats();
    }
    @DeleteMapping("/chats/{chatId}")
    public Boolean deleteChat(@PathVariable("chatId") String chatId) {
        return wcsDiagnosisService.deleteChat(chatId);
    }
    @GetMapping("/chats/{chatId}/history")
    public List<ChatCompletionRequest.Message> getChatHistory(@PathVariable("chatId") String chatId) {
        return wcsDiagnosisService.getChatHistory(chatId);
    }
    /**
     * POST /api/ai/diagnose/wcs
     */
src/main/java/com/zy/ai/log/AiLogAppender.java
@@ -1,6 +1,7 @@
package com.zy.ai.log;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.classic.spi.ThrowableProxyUtil;
import ch.qos.logback.core.AppenderBase;
import java.time.Instant;
@@ -36,6 +37,11 @@
                message
        );
        String throwable = event.getThrowableProxy() != null ? ThrowableProxyUtil.asString(event.getThrowableProxy()) : null;
        if (throwable != null && !throwable.isEmpty()) {
            logLine = logLine + System.lineSeparator() + throwable;
        }
        // 放进环形缓冲区
        if (LOG_BUFFER.remainingCapacity() == 0) {
            LOG_BUFFER.pollFirst(); // 移除最旧的
@@ -50,4 +56,4 @@
                .skip(skip)
                .collect(Collectors.toList());
    }
}
}
src/main/java/com/zy/ai/service/LlmChatService.java
@@ -91,10 +91,14 @@
                .doOnError(ex -> log.error("调用 LLM 流式失败", ex));
        flux.subscribe(payload -> {
            String s = payload == null ? null : payload.trim();
            String s = payload;
            if (s == null || s.isEmpty()) return;
            if (s.startsWith("data:")) s = s.substring(5).trim();
            if ("[DONE]".equals(s)) return;
            if (s.startsWith("data:")) {
                s = s.substring(5);
                if (s.startsWith(" ")) s = s.substring(1);
            }
            // 保留模型输出中的换行,只在判断结束标记时忽略空白
            if ("[DONE]".equals(s.trim())) return;
            try {
                JSONObject obj = JSON.parseObject(s);
                JSONArray choices = obj.getJSONArray("choices");
@@ -103,6 +107,10 @@
                    JSONObject delta = c0.getJSONObject("delta");
                    if (delta != null) {
                        String content = delta.getString("content");
//                        log.info("chunk = [{}] len = {}", content, content.length());
//                        for (char ch : content.toCharArray()) {
//                            log.info("char: {} ({})", (int) ch, ch == '\n' ? "\\n" : ch);
//                        }
                        if (content != null) onChunk.accept(content);
                    }
                }
src/main/java/com/zy/ai/service/WcsDiagnosisService.java
@@ -3,18 +3,23 @@
import com.alibaba.fastjson.JSON;
import com.zy.ai.entity.ChatCompletionRequest;
import com.zy.ai.entity.WcsDiagnosisRequest;
import com.zy.common.utils.RedisUtil;
import com.zy.core.enums.RedisKeyType;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class WcsDiagnosisService {
    private final LlmChatService llmChatService;
    private final RedisUtil redisUtil;
    private static final long CHAT_TTL_SECONDS = 7L * 24 * 3600;
    /**
     * 针对“系统不执行任务 / 不知道哪个设备没在运行”的通用 AI 诊断
@@ -45,10 +50,9 @@
        );
        messages.add(system);
        // 2. user:把具体的数据组织成文本(JSON 形式方便模型看结构)
        ChatCompletionRequest.Message user = new ChatCompletionRequest.Message();
        user.setRole("user");
        user.setContent(buildUserContent(request));
        user.setContent(buildDiagnosisUserContent(request));
        messages.add(user);
        // 调用大模型
@@ -81,11 +85,17 @@
        ChatCompletionRequest.Message user = new ChatCompletionRequest.Message();
        user.setRole("user");
        user.setContent(buildUserContent(request));
        user.setContent(buildDiagnosisUserContent(request));
        messages.add(user);
        llmChatService.chatStream(messages, 0.2, 2048, s -> {
            try { emitter.send(SseEmitter.event().data(s)); } catch (Exception ignore) {}
            try {
                // SSE 协议不允许原样携带换行,先转为 \n 传输,前端再还原
                String safe = s == null ? "" : s.replace("\r", "").replace("\n", "\\n");
                if (!safe.isEmpty()) {
                    emitter.send(SseEmitter.event().data(safe));
                }
            } catch (Exception ignore) {}
        }, () -> {
            try { emitter.complete(); } catch (Exception ignore) {}
        }, e -> {
@@ -93,20 +103,180 @@
        });
    }
    private String buildUserContent(WcsDiagnosisRequest request) {
    public void askStream(WcsDiagnosisRequest request,
                          String prompt,
                          String chatId,
                          boolean reset,
                          SseEmitter emitter) {
        List<ChatCompletionRequest.Message> base = new ArrayList<>();
        ChatCompletionRequest.Message system = new ChatCompletionRequest.Message();
        system.setRole("system");
        system.setContent(
                "你是一名资深 WCS(仓储控制系统)与自动化立库专家,熟悉:堆垛机、输送线、提升机、穿梭车等设备的任务分配和运行逻辑,也熟悉常见的系统卡死、任务不执行、设备空闲但无任务等问题模式。\n\n" +
                "在回答用户问题时,需要结合下面给出的系统当前上下文信息(任务、设备实时状态、设备配置、系统日志等),以简洁、明确的中文作答,并在需要时给出可执行的排查建议。"
        );
        base.add(system);
        List<ChatCompletionRequest.Message> history = null;
        String historyKey = null;
        String metaKey = null;
        if (chatId != null && !chatId.isEmpty()) {
            historyKey = RedisKeyType.AI_CHAT_HISTORY.key + chatId;
            metaKey = RedisKeyType.AI_CHAT_META.key + chatId;
            if (reset) {
                redisUtil.del(historyKey, metaKey);
            }
            List<Object> stored = redisUtil.lGet(historyKey, 0, -1);
            if (stored != null && !stored.isEmpty()) {
                history = new ArrayList<>(stored.size());
                for (Object o : stored) {
                    ChatCompletionRequest.Message m = convertToMessage(o);
                    if (m != null) history.add(m);
                }
                if (!history.isEmpty()) base.addAll(history);
            } else {
                history = new ArrayList<>();
            }
        }
        ChatCompletionRequest.Message contextMsg = new ChatCompletionRequest.Message();
        contextMsg.setRole("user");
        contextMsg.setContent(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;
        final String finalHistoryKey = historyKey;
        final String finalMetaKey = metaKey;
        final String finalPrompt = prompt;
        llmChatService.chatStream(base, 0.2, 2048, s -> {
            try {
                String safe = s == null ? "" : s.replace("\r", "").replace("\n", "\\n");
                if (!safe.isEmpty()) {
                    emitter.send(SseEmitter.event().data(safe));
                    assistantBuffer.append(s);
                }
            } catch (Exception ignore) {}
        }, () -> {
            try {
                if (finalChatId != null && !finalChatId.isEmpty()) {
                    ChatCompletionRequest.Message q = new ChatCompletionRequest.Message();
                    q.setRole("user");
                    q.setContent(finalPrompt == null ? "" : finalPrompt);
                    ChatCompletionRequest.Message a = new ChatCompletionRequest.Message();
                    a.setRole("assistant");
                    a.setContent(assistantBuffer.toString());
                    redisUtil.lSet(finalHistoryKey, q);
                    redisUtil.lSet(finalHistoryKey, a);
                    redisUtil.expire(finalHistoryKey, CHAT_TTL_SECONDS);
                    Map<Object, Object> old = redisUtil.hmget(finalMetaKey);
                    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", finalChatId);
                    meta.put("title", buildTitleFromPrompt(finalPrompt));
                    meta.put("createdAt", createdAt);
                    meta.put("updatedAt", System.currentTimeMillis());
                    redisUtil.hmset(finalMetaKey, meta, CHAT_TTL_SECONDS);
                }
                emitter.complete();
            } catch (Exception ignore) {}
        }, e -> {
            try { emitter.completeWithError(e); } catch (Exception ignore) {}
        });
    }
    public List<Map<String, Object>> listChats() {
        java.util.Set<String> keys = redisUtil.scanKeys(RedisKeyType.AI_CHAT_META.key, 1000);
        List<Map<String, Object>> resp = new ArrayList<>();
        if (keys != null) {
            for (String key : keys) {
                Map<Object, Object> m = redisUtil.hmget(key);
                if (m != null && !m.isEmpty()) {
                    java.util.HashMap<String, Object> item = new java.util.HashMap<>();
                    for (Map.Entry<Object, Object> e : m.entrySet()) {
                        item.put(String.valueOf(e.getKey()), e.getValue());
                    }
                    String chatId = String.valueOf(item.get("chatId"));
                    String historyKey = RedisKeyType.AI_CHAT_HISTORY.key + chatId;
                    item.put("size", redisUtil.lGetListSize(historyKey));
                    resp.add(item);
                }
            }
        }
        return resp;
    }
    public boolean deleteChat(String chatId) {
        if (chatId == null || chatId.isEmpty()) return false;
        String historyKey = RedisKeyType.AI_CHAT_HISTORY.key + chatId;
        String metaKey = RedisKeyType.AI_CHAT_META.key + chatId;
        redisUtil.del(historyKey, metaKey);
        return true;
    }
    public List<ChatCompletionRequest.Message> getChatHistory(String chatId) {
        if (chatId == null || chatId.isEmpty()) return java.util.Collections.emptyList();
        String historyKey = RedisKeyType.AI_CHAT_HISTORY.key + chatId;
        List<Object> stored = redisUtil.lGet(historyKey, 0, -1);
        List<ChatCompletionRequest.Message> result = new ArrayList<>();
        if (stored != null) {
            for (Object o : stored) {
                ChatCompletionRequest.Message m = convertToMessage(o);
                if (m != null) result.add(m);
            }
        }
        return result;
    }
    private ChatCompletionRequest.Message convertToMessage(Object o) {
        if (o instanceof ChatCompletionRequest.Message) {
            return (ChatCompletionRequest.Message) o;
        }
        if (o instanceof Map) {
            Map<?, ?> map = (Map<?, ?>) o;
            ChatCompletionRequest.Message m = new ChatCompletionRequest.Message();
            Object role = map.get("role");
            Object content = map.get("content");
            m.setRole(role == null ? null : String.valueOf(role));
            m.setContent(content == null ? null : String.valueOf(content));
            return m;
        }
        return null;
    }
    private String buildTitleFromPrompt(String prompt) {
        if (prompt == null || prompt.isEmpty()) return "未命名会话";
        String p = prompt.replaceAll("\n", " ").trim();
        return p.length() > 20 ? p.substring(0, 20) : p;
    }
    private String buildDiagnosisUserContent(WcsDiagnosisRequest request) {
        StringBuilder sb = new StringBuilder();
        sb.append("【问题描述】\n");
        if (request.getAlarmMessage() != null && !request.getAlarmMessage().isEmpty()) {
            sb.append("【问题描述】\n");
            sb.append(request.getAlarmMessage()).append("\n\n");
        } else {
            sb.append("系统当前不执行任务,但具体原因不明,请根据以下信息帮助判断。\n\n");
        }
        sb.append("【设备信息】\n");
        sb.append("关注设备(如果有指定): ")
                .append(request.getCraneNo() != null ? request.getCraneNo() : "未指定,需整体分析")
                .append("\n\n");
        Object pseudo = redisUtil.get(com.zy.core.enums.RedisKeyType.MAIN_PROCESS_PSEUDOCODE.key);
        if (pseudo != null) {
            sb.append("【主流程伪代码 mainProcessPseudo】\n");
            sb.append(String.valueOf(pseudo)).append("\n\n");
        }
        if (request.getExtraContext() != null && !request.getExtraContext().isEmpty()) {
            sb.append("【额外上下文 extraContext】\n");
@@ -153,4 +323,40 @@
        return sb.toString();
    }
    private String buildAskUserContent(WcsDiagnosisRequest request) {
        StringBuilder sb = new StringBuilder();
        if (request.getExtraContext() != null && !request.getExtraContext().isEmpty()) {
            sb.append("【额外上下文 extraContext】\n");
            sb.append(JSON.toJSONString(request.getExtraContext(), true)).append("\n\n");
        }
        if (request.getTasks() != null && !request.getTasks().isEmpty()) {
            sb.append("【任务信息 tasks】\n");
            sb.append("下面是当前相关任务列表的 JSON 数据:\n");
            sb.append(JSON.toJSONString(request.getTasks(), true)).append("\n\n");
        }
        if (request.getDeviceRealtimeData() != null && !request.getDeviceRealtimeData().isEmpty()) {
            sb.append("【设备实时数据 deviceRealtimeData】\n");
            sb.append("下面是各设备当前实时状态的 JSON 数据:\n");
            sb.append(JSON.toJSONString(request.getDeviceRealtimeData(), true)).append("\n\n");
        }
        if (request.getDeviceConfigs() != null && !request.getDeviceConfigs().isEmpty()) {
            sb.append("【设备配置信息 deviceConfigs】\n");
            sb.append("下面是各设备配置的 JSON 数据:\n");
            sb.append(JSON.toJSONString(request.getDeviceConfigs(), true)).append("\n\n");
        }
        sb.append("【系统日志 logs(按时间顺序)】\n");
        if (request.getLogs() != null && !request.getLogs().isEmpty()) {
            for (String logLine : request.getLogs()) {
                sb.append(logLine).append("\n");
            }
        }
        return sb.toString();
    }
}
src/main/java/com/zy/ai/utils/AiUtils.java
New file
@@ -0,0 +1,139 @@
package com.zy.ai.utils;
import com.baomidou.mybatisplus.mapper.EntityWrapper;
import com.zy.ai.entity.DeviceConfigsData;
import com.zy.ai.entity.DeviceRealTimeData;
import com.zy.ai.entity.WcsDiagnosisRequest;
import com.zy.ai.log.AiLogAppender;
import com.zy.asrs.entity.BasCrnp;
import com.zy.asrs.entity.BasDevp;
import com.zy.asrs.entity.WrkMast;
import com.zy.asrs.service.BasCrnpService;
import com.zy.asrs.service.BasDevpService;
import com.zy.asrs.service.WrkMastService;
import com.zy.core.cache.SlaveConnection;
import com.zy.core.enums.SlaveType;
import com.zy.core.model.StationObjModel;
import com.zy.core.model.protocol.CrnProtocol;
import com.zy.core.model.protocol.StationProtocol;
import com.zy.core.thread.CrnThread;
import com.zy.core.thread.StationThread;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Component
public class AiUtils {
    @Autowired
    private WrkMastService wrkMastService;
    @Autowired
    private BasCrnpService basCrnpService;
    @Autowired
    private BasDevpService basDevpService;
    public WcsDiagnosisRequest makeAiRequest(int logLimit, String alarmMessage) {
        WcsDiagnosisRequest request = new WcsDiagnosisRequest();
        request.setAlarmMessage(alarmMessage);
        List<String> logs = AiLogAppender.getRecentLogs(logLimit);
        request.setLogs(logs);
        List<WrkMast> wrkMasts = wrkMastService.selectList(new EntityWrapper<>());
        request.setTasks(wrkMasts);
        List<DeviceRealTimeData> deviceRealTimeDataList = new ArrayList<>();
        List<DeviceConfigsData> deviceConfigsDataList = new ArrayList<>();
        List<BasCrnp> basCrnps = basCrnpService.selectList(new EntityWrapper<>());
        for (BasCrnp basCrnp : basCrnps) {
            CrnThread crnThread = (CrnThread) SlaveConnection.get(SlaveType.Crn, basCrnp.getCrnNo());
            if (crnThread == null) {
                continue;
            }
            CrnProtocol protocol = crnThread.getStatus();
            for (StationObjModel stationObjModel : basCrnp.getInStationList$()) {
                StationThread stationThread = (StationThread) SlaveConnection.get(SlaveType.Devp, stationObjModel.getDeviceNo());
                if (stationThread == null) {
                    continue;
                }
                Map<Integer, StationProtocol> map = stationThread.getStatusMap();
                StationProtocol stationProtocol = map.get(stationObjModel.getStationId());
                if (stationProtocol == null) {
                    continue;
                }
                DeviceRealTimeData stationData = new DeviceRealTimeData();
                stationData.setDeviceNo(stationObjModel.getDeviceNo());
                stationData.setDeviceType(String.valueOf(SlaveType.Devp));
                stationData.setDeviceData(stationProtocol);
                deviceRealTimeDataList.add(stationData);
            }
            DeviceRealTimeData deviceRealTimeData = new DeviceRealTimeData();
            deviceRealTimeData.setDeviceNo(basCrnp.getCrnNo());
            deviceRealTimeData.setDeviceType(String.valueOf(SlaveType.Crn));
            deviceRealTimeData.setDeviceData(protocol);
            deviceRealTimeDataList.add(deviceRealTimeData);
            DeviceConfigsData deviceConfigsData = new DeviceConfigsData();
            deviceConfigsData.setDeviceNo(basCrnp.getCrnNo());
            deviceConfigsData.setDeviceType(String.valueOf(SlaveType.Crn));
            deviceConfigsData.setDeviceData(basCrnp);
            deviceConfigsDataList.add(deviceConfigsData);
        }
        List<BasDevp> basDevps = basDevpService.selectList(new EntityWrapper<>());
        for (BasDevp basDevp : basDevps) {
            StationThread stationThread = (StationThread) SlaveConnection.get(SlaveType.Devp, basDevp.getDevpNo());
            if (stationThread == null) {
                continue;
            }
            Map<Integer, StationProtocol> map = stationThread.getStatusMap();
            for (StationObjModel stationObjModel : basDevp.getInStationList$()) {
                StationProtocol stationProtocol = map.get(stationObjModel.getStationId());
                if (stationProtocol == null) {
                    continue;
                }
                DeviceRealTimeData stationData = new DeviceRealTimeData();
                stationData.setDeviceNo(stationObjModel.getDeviceNo());
                stationData.setDeviceType(String.valueOf(SlaveType.Devp));
                stationData.setDeviceData(stationProtocol);
                deviceRealTimeDataList.add(stationData);
            }
            for (StationObjModel stationObjModel : basDevp.getOutStationList$()) {
                StationProtocol stationProtocol = map.get(stationObjModel.getStationId());
                if (stationProtocol == null) {
                    continue;
                }
                DeviceRealTimeData stationData = new DeviceRealTimeData();
                stationData.setDeviceNo(stationObjModel.getDeviceNo());
                stationData.setDeviceType(String.valueOf(SlaveType.Devp));
                stationData.setDeviceData(stationProtocol);
                deviceRealTimeDataList.add(stationData);
            }
            DeviceConfigsData deviceConfigsData = new DeviceConfigsData();
            deviceConfigsData.setDeviceNo(basDevp.getDevpNo());
            deviceConfigsData.setDeviceType(String.valueOf(SlaveType.Devp));
            deviceConfigsData.setDeviceData(basDevp);
            deviceConfigsDataList.add(deviceConfigsData);
        }
        request.setDeviceRealtimeData(deviceRealTimeDataList);
        request.setDeviceConfigs(deviceConfigsDataList);
        return request;
    }
}
src/main/java/com/zy/asrs/utils/Utils.java
@@ -13,10 +13,12 @@
import com.zy.asrs.service.BasCrnpService;
import com.zy.asrs.service.WrkMastService;
import com.zy.common.utils.RedisUtil;
import com.zy.core.News;
import com.zy.core.cache.SlaveConnection;
import com.zy.core.enums.CrnModeType;
import com.zy.core.enums.RedisKeyType;
import com.zy.core.enums.SlaveType;
import com.zy.core.enums.WrkIoType;
import com.zy.core.model.protocol.CrnProtocol;
import com.zy.core.thread.CrnThread;
@@ -189,6 +191,16 @@
                if (crnProtocol.getMode() != CrnModeType.AUTO.id) {
                    continue;
                }
                List<WrkMast> inWrkMasts = wrkMastService.selectList(new EntityWrapper<WrkMast>()
                        .eq("crn_no", basCrnp.getCrnNo())
                        .eq("io_type", WrkIoType.IN.id)
                );
                // 检查是否超过最大入库任务数
                if(inWrkMasts.size() >= basCrnp.getMaxInTask()){
                    News.info("堆垛机:{} 已达最大入库任务数,当前任务数:{}", basCrnp.getCrnNo(), inWrkMasts.size());
                    continue;
                }
                enabledCrnps.add(basCrnp);
            }
src/main/java/com/zy/common/service/CommonService.java
@@ -264,6 +264,20 @@
            throw new CoolException("未找到输送目标站点可走行路径");
        }
        BasCrnp basCrnp = basCrnpService.selectOne(new EntityWrapper<BasCrnp>().eq("crn_no", crnNo));
        if(basCrnp == null) {
            throw new CoolException("未找到对应堆垛机数据");
        }
        List<WrkMast> outWrkMasts = wrkMastService.selectList(new EntityWrapper<WrkMast>()
                .eq("crn_no", crnNo)
                .eq("io_type", WrkIoType.OUT.id)
        );
        // 检查是否超过最大出库任务数
        if(outWrkMasts.size() >= basCrnp.getMaxOutTask()){
            News.info("堆垛机:{} 已达最大出库任务数,当前任务数:{}", basCrnp.getCrnNo(), outWrkMasts.size());
            throw new CoolException("堆垛机:" + basCrnp.getCrnNo() + "已达最大出库任务数,当前任务数:" + outWrkMasts.size());
        }
        // 获取工作号
        int workNo = getWorkNo(WrkIoType.OUT.id);
        // 保存工作档
@@ -290,7 +304,6 @@
        locMast.setLocSts("R");
        locMast.setModiTime(new Date());
        locMastService.updateById(locMast);
        return true;
    }
src/main/java/com/zy/core/enums/RedisKeyType.java
@@ -32,6 +32,9 @@
    CRN_IO_EXECUTE_FINISH_LIMIT("crn_io_execute_finish_limit_"),
    CURRENT_CIRCLE_TASK_CRN_NO("current_circle_task_crn_no_"),
    AI_CHAT_HISTORY("ai_chat_history_"),
    AI_CHAT_META("ai_chat_meta_"),
    MAIN_PROCESS_PSEUDOCODE("main_process_pseudocode"),
    ;
    public String key;
src/main/java/com/zy/core/plugin/FakeProcess.java
@@ -390,7 +390,7 @@
                            News.error("请求WMS接口失败!!!url:{};request:{};response:{}", wmsUrl + wmsSystemInUrl, JSON.toJSONString(requestParam), response);
                        }
                    } catch (Exception e) {
                        e.printStackTrace();
                        News.error("请求WMS接口异常!!!url:{};request:{};response:{}", wmsUrl + wmsSystemInUrl, JSON.toJSONString(requestParam), response, e);
                    } finally {
                        HttpRequestLog httpRequestLog = new HttpRequestLog();
                        httpRequestLog.setName(wmsUrl + wmsSystemInUrl);
src/main/java/com/zy/core/task/MakeMainProcessPseudocodeScheduler.java
New file
@@ -0,0 +1,210 @@
package com.zy.core.task;
import com.zy.ai.entity.ChatCompletionRequest;
import com.zy.ai.service.LlmChatService;
import com.zy.common.utils.RedisUtil;
import com.zy.core.News;
import com.zy.core.enums.RedisKeyType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;
@Component
public class MakeMainProcessPseudocodeScheduler {
    @Value("${mainProcessPlugin}")
    private String mainProcessPlugin;
    @Autowired
    private LlmChatService llmChatService;
    @Autowired
    private RedisUtil redisUtil;
    @Scheduled(cron = "1 * * * * ? ")
    public void refreshPseudocodeDaily() {
        try {
            initMainProcessPseudocode();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    private void initMainProcessPseudocode(){
        Object object = redisUtil.get(RedisKeyType.MAIN_PROCESS_PSEUDOCODE.key);
        if (object != null) {
            return;
        }
        String plugin = mainProcessPlugin;
        if (plugin == null) plugin = "NormalProcess";
        String className = plugin.contains(".") ? plugin : "com.zy.core.plugin." + plugin;
        String code = null;
        try {
            String rel = className.replace('.', '/') + ".java";
            java.nio.file.Path p = Paths.get(System.getProperty("user.dir"), "src", "main", "java", rel);
            if (Files.exists(p)) {
                code = new String(Files.readAllBytes(p), StandardCharsets.UTF_8);
            }
        } catch (Exception ignore) {}
        String result = null;
        if (code != null && !code.isEmpty()) {
            List<ChatCompletionRequest.Message> messages = new java.util.ArrayList<>();
            ChatCompletionRequest.Message system = new ChatCompletionRequest.Message();
            system.setRole("system");
            system.setContent("你现在是一名高级 Java 架构师兼伪代码转换专家,专门负责把复杂的 Java 代码转换成结构清晰、适合大模型阅读与推理的伪代码。\n" +
                    "\n" +
                    "请严格遵守以下要求工作:\n" +
                    "\n" +
                    "核心目标\n" +
                    "\n" +
                    "输入是一段或多段 Java 代码。\n" +
                    "\n" +
                    "输出是一段人类可读、逻辑清晰、尽量语言中立的伪代码。\n" +
                    "\n" +
                    "这份伪代码将被用作后续大模型提问的“参考描述”,所以要:\n" +
                    "\n" +
                    "保留关键业务逻辑和判断条件;\n" +
                    "\n" +
                    "弱化语言细节(如具体库、注解、框架细节);\n" +
                    "\n" +
                    "用自然语言 + 简洁流程结构,帮助大模型快速理解代码意图。\n" +
                    "\n" +
                    "风格要求\n" +
                    "\n" +
                    "使用中文描述逻辑,但可以保留少量关键英文标识(例如类名、方法名、状态枚举)以便跟代码对应。\n" +
                    "\n" +
                    "伪代码要分层分块,尽量按:\n" +
                    "\n" +
                    "类职责说明\n" +
                    "\n" +
                    "重要字段 / 全局变量说明\n" +
                    "\n" +
                    "每个公开方法 / 核心私有方法的伪代码\n" +
                    "\n" +
                    "逻辑上使用类似:\n" +
                    "\n" +
                    "如果 ... 则 ...\n" +
                    "\n" +
                    "否则如果 ...\n" +
                    "\n" +
                    "循环遍历列表 ...\n" +
                    "\n" +
                    "调用服务/方法: ...\n" +
                    "\n" +
                    "返回 ...\n" +
                    "\n" +
                    "不追求严格语法,只追求易懂和准确。\n" +
                    "\n" +
                    "保留信息 & 抽象信息\n" +
                    "\n" +
                    "必须保留:\n" +
                    "\n" +
                    "关键业务含义(例如“生成入库任务”、“检查堆垛机任务是否完成”)\n" +
                    "\n" +
                    "关键条件判断(状态字段、枚举、重要配置开关)\n" +
                    "\n" +
                    "重要数据流向(从哪里读数据、写到哪里、调用了哪些服务)\n" +
                    "\n" +
                    "与外部系统交互(如 HTTP 调用 WMS、写 Redis 锁、写数据库)\n" +
                    "\n" +
                    "可以抽象或省略:\n" +
                    "\n" +
                    "日志打印的具体格式,只保留“记录日志:xxx”即可;\n" +
                    "\n" +
                    "具体框架注解(如 @Component, @Autowired 等);\n" +
                    "\n" +
                    "泛型、异常栈细节、工具类内部实现;\n" +
                    "\n" +
                    "结构模板(优先遵循)\n" +
                    "\n" +
                    "对于一段较大的 Java 类,请按以下结构输出伪代码:\n" +
                    "\n" +
                    "类整体说明\n" +
                    "\n" +
                    "简要说明这个类的用途和在系统中的角色。\n" +
                    "\n" +
                    "重要字段 / 配置说明\n" +
                    "\n" +
                    "列出关键静态变量 / 配置项 / 状态缓存,并用一行解释它们的含义。\n" +
                    "\n" +
                    "主流程方法(例如 run())\n" +
                    "\n" +
                    "用有序列表或伪代码,按调用顺序描述主要步骤。\n" +
                    "\n" +
                    "每个核心私有方法\n" +
                    "\n" +
                    "对于每个关键方法:\n" +
                    "\n" +
                    "先用一行中文总结功能;\n" +
                    "\n" +
                    "再给出伪代码流程(条件、循环、关键调用);\n" +
                    "\n" +
                    "与外部系统交互的说明\n" +
                    "\n" +
                    "单独强调有哪些地方调用了外部服务(HTTP、消息队列、数据库、Redis 等)。\n" +
                    "\n" +
                    "输出格式要求\n" +
                    "\n" +
                    "使用 Markdown 结构,方便复制给其他大模型:\n" +
                    "\n" +
                    "用 ## 标题区分“类说明”、“主流程伪代码”、“方法伪代码”等部分;\n" +
                    "\n" +
                    "伪代码块可以使用缩进和项目符号,或用 pseudo 代码块 包裹;\n" +
                    "\n" +
                    "不要直接逐行翻译代码,而是做抽象和整理;\n" +
                    "\n" +
                    "不要输出无关文本,例如道歉、寒暄或与任务无关的解释。\n" +
                    "\n" +
                    "伪代码示例风格(示意)\n" +
                    "\n" +
                    "例如当输入一个 run() 方法时,期望你的输出风格类似:\n" +
                    "\n" +
                    "函数 run():\n" +
                    "    读取配置 enableFake, fakeRealTaskRequestWms\n" +
                    "    如果 enableFake == \"Y\":\n" +
                    "        调用 checkInStationHasTask() 检测入库站并生成仿真站点数据\n" +
                    "        如果 fakeRealTaskRequestWms == \"N\":\n" +
                    "            调用 generateFakeInTask() 生成本地仿真入库任务\n" +
                    "            调用 generateFakeOutTask() 生成本地仿真出库任务\n" +
                    "    计算所有站点的停留时间 calcAllStationStayTime()\n" +
                    "    检查出库站点是否超时并重置 checkOutStationStayTimeOut()\n" +
                    "    检查入库站点货物是否已被堆垛机取走 checkInStationCrnTake()\n" +
                    "    如果 fakeRealTaskRequestWms == \"Y\":\n" +
                    "        调用 generateStoreWrkFile() 请求 WMS 生成真实任务\n" +
                    "    调用 crnOperateUtils.crnIoExecute() 执行堆垛机任务\n" +
                    "    调用 crnIoExecuteFinish() 处理堆垛机任务完成后的状态更新和仿真站点生成\n" +
                    "    调用 stationOperateProcessUtils.stationInExecute() 执行输送站入库任务\n" +
                    "    调用 stationOperateProcessUtils.stationOutExecute() 执行输送站出库任务\n" +
                    "    调用 stationOperateProcessUtils.stationOutExecuteFinish() 检查输送站出库任务完成\n" +
                    "\n" +
                    "\n" +
                    "对输入的要求\n" +
                    "\n" +
                    "如果用户给出的是多段代码或只给出片段:\n" +
                    "\n" +
                    "先推断这段代码的职责;\n" +
                    "\n" +
                    "再按你能理解到的范围进行伪代码转换;\n" +
                    "\n" +
                    "如果存在明显缺失的类/方法,只需在伪代码中用“调用 XXX(具体逻辑略)”标记即可。\n" +
                    "\n" +
                    "请始终以「让后续大模型能看懂这段代码逻辑并基于伪代码进行推理和提问」为最高优先级来组织你的输出。");
            messages.add(system);
            ChatCompletionRequest.Message user = new ChatCompletionRequest.Message();
            user.setRole("user");
            user.setContent("主流程插件类源代码:\n\n" + code);
            messages.add(user);
            try {
                result = llmChatService.chat(messages, 0.2, 2048);
            } catch (Exception ignore) {}
        }
        redisUtil.set(RedisKeyType.MAIN_PROCESS_PSEUDOCODE.key, result, 60 * 60 * 24);
        News.info("主流程伪代码已刷新");
    }
}
src/main/java/com/zy/core/utils/CrnOperateProcessUtils.java
@@ -115,16 +115,6 @@
            return;
        }
        List<WrkMast> inWrkMasts = wrkMastService.selectList(new EntityWrapper<WrkMast>()
                .eq("crn_no", basCrnp.getCrnNo())
                .eq("io_type", WrkIoType.IN.id)
        );
        // 检查是否超过最大入库任务数
        if(inWrkMasts.size() >= basCrnp.getMaxInTask()){
            News.info("堆垛机:{} 已达最大入库任务数,当前任务数:{}", basCrnp.getCrnNo(), inWrkMasts.size());
            return;
        }
        Integer crnNo = basCrnp.getCrnNo();
        for (StationObjModel stationObjModel : inStationList) {
@@ -209,16 +199,6 @@
        List<StationObjModel> outStationList = basCrnp.getOutStationList$();
        if(outStationList.isEmpty()){
            News.info("堆垛机:{} 出库站点未设置", basCrnp.getCrnNo());
            return;
        }
        List<WrkMast> outWrkMasts = wrkMastService.selectList(new EntityWrapper<WrkMast>()
                .eq("crn_no", basCrnp.getCrnNo())
                .eq("io_type", WrkIoType.OUT.id)
        );
        // 检查是否超过最大出库任务数
        if(outWrkMasts.size() >= basCrnp.getMaxOutTask()){
            News.info("堆垛机:{} 已达最大出库任务数,当前任务数:{}", basCrnp.getCrnNo(), outWrkMasts.size());
            return;
        }
src/main/resources/application.yml
@@ -68,6 +68,9 @@
  expireDays: 7
llm:
  base-url: https://api.siliconflow.cn/v1
  api-key: sk-sxdtebtquwrugzrmaqqqkzdzmrgzhzmplwwuowysdasccent
  model: Qwen/Qwen3-VL-32B-Instruct
#  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
src/main/webapp/static/js/ai/diagnose.js
File was deleted
src/main/webapp/static/js/common.js
@@ -193,3 +193,57 @@
        if (new RegExp("(" + k + ")").test(fmt)) fmt = fmt.replace(RegExp.$1, (RegExp.$1.length === 1) ? (o[k]) : (("00" + o[k]).substr(("" + o[k]).length)));
    return fmt;
}
/**
 * 获取AI助手SVG图标HTML
 * @param {number} width 宽度,默认48
 * @param {number} height 高度,默认48
 * @returns {string} SVG HTML字符串
 */
function getAiIconHtml(width, height) {
    width = width || 48;
    height = height || 48;
    // 生成唯一ID防止冲突
    var uniqueId = 'ai_icon_' + Math.random().toString(36).substr(2, 9);
    var textGradientId = 'textGradient_' + uniqueId;
    var glowId = 'glow_' + uniqueId;
    var spinName = 'spin_' + uniqueId;
    return '<svg width="' + width + '" height="' + height + '" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" fill="none">' +
        '<defs>' +
            '<linearGradient id="' + textGradientId + '" gradientUnits="userSpaceOnUse" x1="25" y1="50" x2="75" y2="50">' +
                '<stop offset="0%"   stop-color="#8b5cf6"/>' +
                '<stop offset="20%"  stop-color="#f472b6"/>' +
                '<stop offset="40%"  stop-color="#fb923c"/>' +
                '<stop offset="60%"  stop-color="#fbbf24"/>' +
                '<stop offset="80%"  stop-color="#22d3ee"/>' +
                '<stop offset="100%" stop-color="#3b82f6"/>' +
                '<animateTransform attributeName="gradientTransform" type="rotate" from="0 50 50" to="360 50 50" dur="5s" repeatCount="indefinite" />' +
            '</linearGradient>' +
            '<filter id="' + glowId + '" x="-50%" y="-50%" width="200%" height="200%">' +
                '<feGaussianBlur stdDeviation="1.6" result="blur"/>' +
                '<feMerge>' +
                    '<feMergeNode in="blur"/>' +
                    '<feMergeNode in="SourceGraphic"/>' +
                '</feMerge>' +
            '</filter>' +
            '<style>' +
                '.' + spinName + ' { animation: ' + spinName + ' 5s linear infinite; transform-origin: 50px 50px; }' +
                '@keyframes ' + spinName + ' { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }' +
                '.geo-text-' + uniqueId + ' { fill: url(#' + textGradientId + '); stroke: rgba(0,0,0,0.40); stroke-width: 0.45; paint-order: stroke fill; }' +
            '</style>' +
        '</defs>' +
        '<g class="' + spinName + '" filter="url(#' + glowId + ')">' +
            '<g transform="rotate(0 50 50)"><rect x="48" y="18" width="4.5" height="10" rx="2" fill="#8b5cf6"/></g>' +
            '<g transform="rotate(60 50 50)"><rect x="48" y="18" width="4.5" height="10" rx="2" fill="#f472b6"/></g>' +
            '<g transform="rotate(120 50 50)"><rect x="48" y="18" width="4.5" height="10" rx="2" fill="#fb923c"/></g>' +
            '<g transform="rotate(180 50 50)"><rect x="48" y="18" width="4.5" height="10" rx="2" fill="#fbbf24"/></g>' +
            '<g transform="rotate(240 50 50)"><rect x="48" y="18" width="4.5" height="10" rx="2" fill="#22d3ee"/></g>' +
            '<g transform="rotate(300 50 50)"><rect x="48" y="18" width="4.5" height="10" rx="2" fill="#3b82f6"/></g>' +
        '</g>' +
        '<g transform="translate(50 50) scale(0.35) translate(-32.5 -20)" class="geo-text-' + uniqueId + '">' +
            '<path d="M0 40 L20 0 L40 40 Z"/><rect x="12" y="22" width="16" height="4" rx="1"/>' +
            '<rect x="50" y="0"  width="15" height="4" rx="1"/><rect x="55" y="4"  width="5"  height="32" rx="1.5"/><rect x="50" y="36" width="15" height="4" rx="1"/>' +
        '</g>' +
    '</svg>';
}
src/main/webapp/views/ai/diagnose.html
File was deleted
src/main/webapp/views/ai/diagnosis.html
New file
@@ -0,0 +1,310 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>WCS AI 助手</title>
  <link rel="stylesheet" href="../../static/vue/element/element.css" />
  <style>
    body { background: #f5f7fa; }
    .container { max-width: 1100px; margin: 24px auto; }
    .actions { display: flex; gap: 12px; align-items: center; }
    .output { height: 60vh; }
    .markdown-body { font-size: 14px; line-height: 1.4; white-space: pre-wrap; word-break: break-word; }
    .markdown-body p { margin: 4px 0; }
    .markdown-body ul, .markdown-body ol { margin: 4px 0 4px 16px; padding: 0; }
    .markdown-body h1, .markdown-body h2, .markdown-body h3 { margin-top: 8px; }
    .markdown-body pre { background: #f6f8fa; padding: 12px; border-radius: 6px; overflow: auto; }
    .status { color: #909399; }
    .chat { display: flex; flex-direction: column; gap: 10px; height: 100%; overflow-y: auto; padding-right: 8px; }
    .msg { display: flex; align-items: flex-start; }
    .msg.user { justify-content: flex-end; }
    .msg.assistant { justify-content: flex-start; }
    .bubble { max-width: 72%; padding: 10px 12px; border-radius: 16px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
    .assistant .bubble { background: #ffffff; border: 1px solid #ebeef5; color: #303133; }
    .user .bubble { background: #409EFF; color: #ffffff; }
    .composer { display: flex; gap: 10px; align-items: center; margin-top: 12px; }
    .avatar { width: 24px; height: 24px; display: flex; align-items: center; margin-right: 8px; }
    .time { font-size: 12px; color: #909399; text-align: right; margin-top: 6px; }
    .output .el-card__body { height: 100%; padding: 0; }
  </style>
</head>
<body>
  <div id="app" class="container">
    <el-card shadow="hover">
      <div slot="header" class="clearfix" style="display: flex; align-items: center;">
        <div v-html="headerIcon" style="margin-right: 10px; display: flex;"></div>
        <span>WCS AI 助手</span>
      </div>
      <div class="actions" style="flex-wrap: wrap;">
        <el-button type="primary" :loading="loading" :disabled="streaming" @click="start">一键诊断系统</el-button>
        <el-button type="warning" :disabled="!streaming" @click="stop">停止</el-button>
        <el-button @click="clear">清空当前聊天</el-button>
        <span class="status" style="margin-right: 12px;">{{ statusText }}</span>
        <el-select v-model="currentChatId" placeholder="选择会话" style="min-width:240px;" @change="switchChat" :disabled="streaming">
          <el-option v-for="c in chats" :key="c.chatId" :label="(c.title||('会话 '+c.chatId)) + '('+(c.size||0)+')'" :value="c.chatId" />
        </el-select>
        <el-button type="success" plain icon="el-icon-plus" @click="newChat" :disabled="streaming">新会话</el-button>
        <el-button type="danger" plain icon="el-icon-delete" @click="deleteChat" :disabled="!currentChatId || streaming">删除会话</el-button>
      </div>
      <el-divider></el-divider>
      <el-card class="output" shadow="never">
        <div ref="chat" class="chat">
          <div v-for="(m,i) in messages" :key="i" class="msg" :class="m.role">
            <div class="avatar" v-html="m.role === 'assistant' ? assistantIcon : userIcon"></div>
            <div class="bubble">
              <div v-if="m.role === 'assistant'" class="markdown-body" v-html="m.html"></div>
              <div v-else v-text="m.text"></div>
              <div class="time">{{ m.ts }}</div>
            </div>
          </div>
        </div>
      </el-card>
      <div class="composer">
        <el-input v-model="userInput" placeholder="向 AI 助手提问" clearable :disabled="streaming" @keyup.enter.native="ask"></el-input>
        <el-button type="success" :disabled="sendDisabled" @click="ask">发送</el-button>
      </div>
    </el-card>
  </div>
  <script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
  <script type="text/javascript" src="../../static/vue/element/element.js"></script>
  <script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/dompurify@2.4.3/dist/purify.min.js"></script>
  <script>
    marked.setOptions({
      gfm: true,
      breaks: true
    });
    function getUserIconHtml(width, height) {
      width = width || 24; height = height || 24;
      return '<svg width="'+width+'" height="'+height+'" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">\n'
        + '<circle cx="12" cy="7" r="4" fill="#909399"/>\n'
        + '<path d="M4 20c0-4 4-6 8-6s8 2 8 6" fill="#909399" opacity="0.35"/>\n'
        + '</svg>';
    }
    new Vue({
      el: '#app',
      data: function() {
        return {
          headerIcon: getAiIconHtml(50, 50),
          assistantIcon: getAiIconHtml(24, 24),
          userIcon: getUserIconHtml(24, 24),
          loading: false,
          streaming: false,
          source: null,
          messages: [],
          pendingText: '',
          typingTimer: null,
          lastRenderTs: 0,
          renderIntervalMs: 120,
          stepChars: 6,
          userInput: '',
          autoScrollThreshold: 80,
          chats: [],
          currentChatId: '',
          resetting: false
        };
      },
      computed: {
        statusText: function() {
          if (this.streaming) return '诊断进行中';
          if (this.loading) return '连接中';
          return '空闲';
        },
        sendDisabled: function() {
          var t = (this.userInput || '').trim();
          return this.streaming || t.length === 0;
        }
      },
      methods: {
        loadChats: function() {
          var self = this;
          fetch(baseUrl + '/ai/diagnose/chats', { headers: { 'token': localStorage.getItem('token') } })
            .then(function(r){ return r.json(); })
            .then(function(arr){ if (Array.isArray(arr)) { self.chats = arr; } });
        },
        switchChat: function() {
          var self = this;
          if (!self.currentChatId) { self.clear(); return; }
          fetch(baseUrl + '/ai/diagnose/chats/' + encodeURIComponent(self.currentChatId) + '/history', { headers: { 'token': localStorage.getItem('token') } })
            .then(function(r){ return r.json(); })
            .then(function(arr){
              if (!Array.isArray(arr)) return;
              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() });
                else msgs.push({ role: 'user', text: m.content || '', ts: self.nowStr() });
              }
              self.messages = msgs;
              self.$nextTick(function(){ self.scrollToBottom(true); });
            });
        },
        newChat: function() {
          var id = Date.now() + '_' + Math.random().toString(36).substr(2,8);
          this.currentChatId = id;
          this.resetting = true;
          this.clear();
        },
        deleteChat: function() {
          var self = this;
          if (!self.currentChatId) return;
          fetch(baseUrl + '/ai/diagnose/chats/' + encodeURIComponent(self.currentChatId), { method: 'DELETE', headers: { 'token': localStorage.getItem('token') } })
            .then(function(r){ return r.json(); })
            .then(function(ok){ if (ok === true) { self.currentChatId = ''; self.clear(); self.loadChats(); self.newChat(); } });
        },
        shouldAutoScroll: function() {
          var el = this.$refs.chat;
          if (!el) return false;
          return (el.scrollHeight - el.scrollTop - el.clientHeight) <= this.autoScrollThreshold;
        },
        scrollToBottom: function(force) {
          var el = this.$refs.chat;
          if (!el) return;
          if (force || this.streaming || this.shouldAutoScroll()) {
            el.scrollTop = el.scrollHeight;
          }
        },
        nowStr: function() {
          var d = new Date();
          function pad(n) { return (n<10?'0':'') + n; }
          var y = d.getFullYear();
          var m = pad(d.getMonth() + 1);
          var day = pad(d.getDate());
          var hh = pad(d.getHours());
          var mm = pad(d.getMinutes());
          var ss = pad(d.getSeconds());
          return y + '-' + m + '-' + day + ' ' + hh + ':' + mm + ':' + ss;
        },
        ask: function() {
          if (this.streaming) return;
          var msg = (this.userInput || '').trim();
          if (!msg) return;
          this.loading = true;
          this.streaming = true;
          this.messages.push({ role: 'user', text: msg, ts: this.nowStr() });
          this.messages.push({ role: 'assistant', md: '', html: '', ts: this.nowStr() });
          this.scrollToBottom(true);
          var url = baseUrl + '/ai/diagnose/askStream?prompt=' + encodeURIComponent(msg);
          if (this.currentChatId) url += '&chatId=' + encodeURIComponent(this.currentChatId);
          if (this.resetting) url += '&reset=true';
          this.source = new EventSource(url);
          var self = this;
          this.source.onopen = function() {
            self.loading = false;
          };
          this.source.onmessage = function(e) {
            if (!e || !e.data) return;
            var chunk = (e.data || '').replace(/\\n/g, '\n');
            if (!chunk) return;
            var normalized = chunk.replace(/\r/g, '');
            if (/^\n+$/.test(normalized)) return;
            self.pendingText += chunk;
            self.ensureTyping();
            self.scrollToBottom(true);
          };
          this.source.onerror = function() {
            self.stop();
          };
          this.userInput = '';
          this.resetting = false;
        },
        start: function() {
          if (this.streaming) return;
          this.clear();
          this.loading = true;
          this.streaming = true;
          var url = baseUrl + '/ai/diagnose/runAiStream';
          this.source = new EventSource(url);
          this.messages.push({ role: 'assistant', md: '', html: '', ts: this.nowStr() });
          this.scrollToBottom(true);
          var self = this;
          this.source.onopen = function() {
            self.loading = false;
          };
          this.source.onmessage = function(e) {
            if (!e || !e.data) return;
            // 后端把换行转义成 \n,这里还原为真正的换行
            var chunk = (e.data || '').replace(/\\n/g, '\n');
            if (!chunk) return;
            // 如果仅包含换行符(如单独 \n 或 \n\n),丢弃避免空白行
            var normalized = chunk.replace(/\r/g, '');
            if (/^\n+$/.test(normalized)) return;
            self.pendingText += chunk;
            self.ensureTyping();
            self.scrollToBottom(true);
          };
          this.source.onerror = function() {
            self.stop();
          };
        },
        ensureTyping: function() {
          if (this.typingTimer) return;
          var self = this;
          this.typingTimer = setInterval(function() {
            if (!self.streaming && self.pendingText.length === 0) {
              clearInterval(self.typingTimer);
              self.typingTimer = null;
              return;
            }
            if (self.pendingText.length > 0) {
              var n = Math.min(self.stepChars, self.pendingText.length);
              var part = self.pendingText.slice(0, n);
              self.pendingText = self.pendingText.slice(n);
              var last = self.messages.length > 0 ? self.messages[self.messages.length - 1] : null;
              if (last && last.role === 'assistant') {
                last.md = (last.md || '') + part;
              }
            }
            var now = Date.now();
            if (now - self.lastRenderTs > self.renderIntervalMs) {
              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));
                self.$nextTick(function() { self.scrollToBottom(true); });
              }
            }
          }, 50);
        },
        stop: function() {
          if (this.source) {
            this.source.close();
            this.source = null;
          }
          this.streaming = false;
          this.loading = false;
          if (this.typingTimer) {
            clearInterval(this.typingTimer);
            this.typingTimer = null;
          }
          var last = this.messages.length > 0 ? this.messages[this.messages.length - 1] : null;
          if (last && last.role === 'assistant') {
            var renderSource = (last.md || '').replace(/\\n/g, '\n');
            last.html = DOMPurify.sanitize(marked.parse(renderSource));
          }
          this.$nextTick(function() { this.scrollToBottom(true); }.bind(this));
          this.loadChats();
        },
        clear: function() {
          this.messages = [];
          this.pendingText = '';
        }
      }
      ,mounted: function() {
        this.loadChats();
        this.newChat();
      }
    });
  </script>
</body>
</html>
src/main/webapp/views/index.html
@@ -40,6 +40,28 @@
        box-shadow: 0px 0px 20px rgba(0,0,0,0.3);
        text-align: center;
    }
    /* AI助手抽屉动画 */
    @keyframes slideInRight {
      from { transform: translate3d(100%, 0, 0); opacity: 0; }
      to { transform: translate3d(0, 0, 0); opacity: 1; }
    }
    @keyframes slideOutRight {
      from { transform: translate3d(0, 0, 0); opacity: 1; }
      to { transform: translate3d(100%, 0, 0); opacity: 0; }
    }
    .ai-drawer-layer {
      box-shadow: -8px 0 24px rgba(0, 0, 0, 0.15) !important;
      border-radius: 8px 0 0 8px !important;
      overflow: hidden;
      animation: slideInRight 0.5s cubic-bezier(0.16, 1, 0.3, 1);
    }
    .ai-drawer-layer-close {
      animation: slideOutRight 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards !important;
    }
  </style>
</head>
<body class="layui-layout-body">
@@ -122,6 +144,10 @@
    </div>
</div>
<!-- 右下角SVG动画 -->
<div id="ai-assistant-btn" style="position: fixed; bottom: 40px; right: 20px; z-index: 9999; cursor: pointer;">
</div>
<script type="text/javascript" src="../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../static/layui/layui.js"></script>
<script type="text/javascript" src="../static/js/handlebars/handlebars-v4.5.3.js"></script>
@@ -129,6 +155,9 @@
<script>
  console.log('%c 中扬立库平台 %c 1.0.0','background-color:rgb(53,73,94);color: #fff;border-radius:2px 0 0 2px;padding:2px 4px;','background-color:rgb(25,190,107);color: #fff;border-radius:0 2px 2px 0;padding:2px 4px;font: 9pt "Apercu Regular", Georgia, "Times New Roman", Times, serif;');
  $(function () {
    // 注入AI助手图标
    $('#ai-assistant-btn').html(getAiIconHtml(60, 60));
    if ("" === localStorage.getItem('token')) {
      top.location.href = baseUrl + "/login";
    }
@@ -306,6 +335,48 @@
    var url = logout.getAttribute('href');
    logout.setAttribute('href', baseUrl + "/login");
    // AI助手图标悬浮提示
    $('#ai-assistant-btn').on('mouseenter', function(){
        this.index = layer.tips('AI助手', this, {
            tips: [1, '#333'], // 上方显示,深色背景
            time: -1 // 不自动关闭
        });
    }).on('mouseleave', function(){
        layer.close(this.index);
    }).on('click', function () {
        layer.open({
            type: 2,
            title: false, // 隐藏默认标题栏,更简洁
            closeBtn: 0, // 隐藏关闭按钮,点击遮罩关闭
            shadeClose: false, // 改为手动控制关闭,以便播放动画
            shade: 0.1,
            area: ['600px', '100%'],
            offset: 'r', // 右侧悬浮
            anim: -1, // 禁用默认动画,使用CSS动画
            isOutAnim: false,
            skin: 'ai-drawer-layer', // 自定义皮肤
            content: 'ai/diagnosis.html',
            success: function(layero, index){
                // 背景模糊效果
                var shadeId = layero.attr('id').replace('layui-layer', 'layui-layer-shade');
                var $shade = $('#' + shadeId);
                $shade.css({
                    'backdrop-filter': 'blur(3px)',
                    'transition': 'opacity 0.8s'
                });
                // 点击遮罩关闭(带动画)
                $shade.on('click', function() {
                    layero.addClass('ai-drawer-layer-close');
                    $shade.css('opacity', 0);
                    setTimeout(function(){
                        layer.close(index);
                    }, 400);
                });
            }
        });
    });
  });
</script>
<script type="text/html" id="menuTpl">