| New file |
| | |
| | | <!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> |