From 7fef7f8c7007eee9a54c7f6255391f7e46885825 Mon Sep 17 00:00:00 2001
From: Junjie <DELL@qq.com>
Date: 星期五, 12 十二月 2025 08:28:00 +0800
Subject: [PATCH] #AI

---
 src/main/webapp/views/ai/diagnosis.html                  |  149 +++++++++++++++++++++++++++++++++++++
 /dev/null                                                |   49 ------------
 src/main/java/com/zy/ai/service/LlmChatService.java      |   14 ++
 src/main/java/com/zy/ai/service/WcsDiagnosisService.java |    8 +
 src/main/resources/application.yml                       |    2 
 5 files changed, 168 insertions(+), 54 deletions(-)

diff --git a/src/main/java/com/zy/ai/service/LlmChatService.java b/src/main/java/com/zy/ai/service/LlmChatService.java
index e8c7a77..5e709af 100644
--- a/src/main/java/com/zy/ai/service/LlmChatService.java
+++ b/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);
                     }
                 }
diff --git a/src/main/java/com/zy/ai/service/WcsDiagnosisService.java b/src/main/java/com/zy/ai/service/WcsDiagnosisService.java
index 64ad536..93cecbb 100644
--- a/src/main/java/com/zy/ai/service/WcsDiagnosisService.java
+++ b/src/main/java/com/zy/ai/service/WcsDiagnosisService.java
@@ -85,7 +85,13 @@
         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 -> {
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index e85c675..011d12e 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -70,4 +70,4 @@
 llm:
   base-url: https://api.siliconflow.cn/v1
   api-key: sk-sxdtebtquwrugzrmaqqqkzdzmrgzhzmplwwuowysdasccent
-  model: Qwen/Qwen3-VL-32B-Instruct
+  model: deepseek-ai/DeepSeek-V3.2
diff --git a/src/main/webapp/static/js/ai/diagnose.js b/src/main/webapp/static/js/ai/diagnose.js
deleted file mode 100644
index 0b23ae0..0000000
--- a/src/main/webapp/static/js/ai/diagnose.js
+++ /dev/null
@@ -1,43 +0,0 @@
-var sse;
-
-function startDiagnosis() {
-    if (sse) {
-        sse.close();
-    }
-    $('#ai-output').text('');
-    $('#start-btn').attr('disabled', true);
-    $('#stop-btn').attr('disabled', false);
-
-    var url = baseUrl + '/ai/diagnose/runAiStream';
-    sse = new EventSource(url);
-
-    sse.onmessage = function (e) {
-        var curr = $('#ai-output').text();
-        $('#ai-output').text(curr + e.data);
-    };
-
-    sse.onerror = function () {
-        $('#start-btn').attr('disabled', false);
-        $('#stop-btn').attr('disabled', true);
-        if (sse) {
-            sse.close();
-        }
-        layer.msg('杩炴帴宸插叧闂垨鍙戠敓閿欒');
-    };
-}
-
-function stopDiagnosis() {
-    if (sse) {
-        sse.close();
-        sse = null;
-    }
-    $('#start-btn').attr('disabled', false);
-    $('#stop-btn').attr('disabled', true);
-}
-
-$(function () {
-    $('#stop-btn').attr('disabled', true);
-    $('#start-btn').on('click', startDiagnosis);
-    $('#stop-btn').on('click', stopDiagnosis);
-    $('#clear-btn').on('click', function () { $('#ai-output').text(''); });
-});
diff --git a/src/main/webapp/views/ai/diagnose.html b/src/main/webapp/views/ai/diagnose.html
deleted file mode 100644
index 67e952b..0000000
--- a/src/main/webapp/views/ai/diagnose.html
+++ /dev/null
@@ -1,49 +0,0 @@
-<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-    <meta charset="utf-8">
-    <title>AI璇婃柇</title>
-    <meta name="renderer" content="webkit">
-    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
-    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
-    <link rel="stylesheet" href="../../static/layui/css/layui.css" media="all">
-    <link rel="stylesheet" href="../../static/css/admin.css" media="all">
-    <link rel="stylesheet" href="../../static/css/cool.css" media="all">
-</head>
-<body>
-
-<div class="layui-fluid">
-    <div class="layui-row">
-        <div class="layui-col-md12">
-            <div class="layui-card">
-                <div class="layui-card-header">AI璇婃柇</div>
-                <div class="layui-card-body">
-                    <div class="layui-form toolbar" id="ai-toolbar">
-                        <button id="start-btn" type="button" class="layui-btn layui-btn-normal">寮�濮嬭瘖鏂�</button>
-                        <button id="stop-btn" type="button" class="layui-btn layui-btn-danger">鍋滄</button>
-                        <button id="clear-btn" type="button" class="layui-btn">娓呯┖</button>
-                    </div>
-                    <hr class="layui-bg-gray">
-                    <div id="ai-output" style="white-space: pre-wrap; font-family: Menlo, Monaco, Consolas, monospace; min-height: 240px; padding: 12px; border: 1px solid #e6e6e6; border-radius: 4px;"></div>
-                </div>
-            </div>
-        </div>
-    </div>
-    <div class="layui-row">
-        <div class="layui-col-md12">
-            <div class="layui-card">
-                <div class="layui-card-header">璇存槑</div>
-                <div class="layui-card-body">
-                    <p>鐐瑰嚮鈥滃紑濮嬭瘖鏂�濆悗锛屽墠绔皢閫氳繃 SSE 涓庡悗绔缓绔嬭繛鎺ワ紝骞堕�愬瓧鏄剧ず AI 鐨勫垎鏋愮粨鏋溿��</p>
-                </div>
-            </div>
-        </div>
-    </div>
-</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" charset="utf-8"></script>
-<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
-<script type="text/javascript" src="../../static/js/ai/diagnose.js" charset="utf-8"></script>
-</body>
-</html>
diff --git a/src/main/webapp/views/ai/diagnosis.html b/src/main/webapp/views/ai/diagnosis.html
new file mode 100644
index 0000000..f046639
--- /dev/null
+++ b/src/main/webapp/views/ai/diagnosis.html
@@ -0,0 +1,149 @@
+<!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 { min-height: 360px; }
+    .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; }
+  </style>
+</head>
+<body>
+  <div id="app" class="container">
+    <el-card shadow="hover">
+      <div slot="header" class="clearfix">
+        <span>WCS AI 璇婃柇锛圫SE 娴佸紡杈撳嚭锛�</span>
+      </div>
+
+      <div class="actions">
+        <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">{{ statusText }}</span>
+      </div>
+
+      <el-divider></el-divider>
+
+      <el-card class="output" shadow="never">
+        <div class="markdown-body" v-html="renderedHtml"></div>
+      </el-card>
+    </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
+    });
+
+    new Vue({
+      el: '#app',
+      data: function() {
+        return {
+          loading: false,
+          streaming: false,
+          source: null,
+          markdownBuffer: '',
+          renderedHtml: '',
+          pendingText: '',
+          typingTimer: null,
+          lastRenderTs: 0,
+          renderIntervalMs: 120,
+          stepChars: 6
+        };
+      },
+      computed: {
+        statusText: function() {
+          if (this.streaming) return '璇婃柇杩涜涓紙娴佸紡杈撳嚭锛�';
+          if (this.loading) return '杩炴帴涓�';
+          return '绌洪棽';
+        }
+      },
+      methods: {
+        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);
+          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();
+          };
+          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);
+              self.markdownBuffer += self.pendingText.slice(0, n);
+              self.pendingText = self.pendingText.slice(n);
+            }
+            var now = Date.now();
+            if (now - self.lastRenderTs > self.renderIntervalMs) {
+              self.lastRenderTs = now;
+              var renderSource = self.markdownBuffer.replace(/\\n/g, '\n');
+              self.renderedHtml = DOMPurify.sanitize(marked.parse(renderSource));
+            }
+          }, 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 renderSource = this.markdownBuffer.replace(/\\n/g, '\n');
+          this.renderedHtml = DOMPurify.sanitize(marked.parse(renderSource));
+        },
+        clear: function() {
+          this.markdownBuffer = '';
+          this.renderedHtml = '';
+          this.pendingText = '';
+        }
+      }
+    });
+  </script>
+</body>
+</html>

--
Gitblit v1.9.1