#AI
Junjie
2 天以前 7fef7f8c7007eee9a54c7f6255391f7e46885825
#AI
1个文件已添加
2个文件已删除
3个文件已修改
265 ■■■■■ 已修改文件
src/main/java/com/zy/ai/service/LlmChatService.java 14 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/WcsDiagnosisService.java 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application.yml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/ai/diagnose.js 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/ai/diagnose.html 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/ai/diagnosis.html 149 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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
@@ -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 -> {
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
src/main/webapp/static/js/ai/diagnose.js
File was deleted
src/main/webapp/views/ai/diagnose.html
File was deleted
src/main/webapp/views/ai/diagnosis.html
New file
@@ -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 诊断(SSE 流式输出)</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>