| | |
| | | <head> |
| | | <meta charset="UTF-8" /> |
| | | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| | | <title>WCS AI 诊断</title> |
| | | <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; } |
| | | .output { height: 65vh; } |
| | | .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; } |
| | | |
| | | /* 整个图标旋转动画 */ |
| | | .spinner { |
| | | animation: spin 2.8s linear infinite; |
| | | transform-origin: 50% 50%; |
| | | } |
| | | |
| | | @keyframes spin { |
| | | from { transform: rotate(0deg); } |
| | | to { transform: rotate(360deg); } |
| | | } |
| | | .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;"> |
| | | <svg |
| | | width="32" |
| | | height="32" |
| | | viewBox="0 0 64 64" |
| | | style="margin-right: 10px;" |
| | | xmlns="http://www.w3.org/2000/svg"> |
| | | |
| | | <!-- 透明背景:不画任何底色即可 --> |
| | | |
| | | <!-- 一点柔和发光效果 --> |
| | | <defs> |
| | | <filter id="glow" x="-50%" y="-50%" width="200%" height="200%"> |
| | | <feGaussianBlur stdDeviation="2.5" result="blur"/> |
| | | <feMerge> |
| | | <feMergeNode in="blur"/> |
| | | <feMergeNode in="SourceGraphic"/> |
| | | </feMerge> |
| | | </filter> |
| | | </defs> |
| | | |
| | | <!-- 旋转组 --> |
| | | <g class="spinner" filter="url(#glow)"> |
| | | <!-- 每一条彩色「小弯条」 --> |
| | | <!-- 可以通过调整 rx/ry、width/height 来改粗细和弯度 --> |
| | | <!-- 1. 紫色 --> |
| | | <rect x="30" y="10" width="6" height="14" rx="3" ry="3" fill="#8b5cf6" /> |
| | | |
| | | <!-- 2. 粉色(顺时针旋转60°) --> |
| | | <g transform="rotate(60 32 32)"> |
| | | <rect x="30" y="10" width="6" height="14" rx="3" ry="3" fill="#f472b6" /> |
| | | </g> |
| | | |
| | | <!-- 3. 橙色 --> |
| | | <g transform="rotate(120 32 32)"> |
| | | <rect x="30" y="10" width="6" height="14" rx="3" ry="3" fill="#fb923c" /> |
| | | </g> |
| | | |
| | | <!-- 4. 金黄 --> |
| | | <g transform="rotate(180 32 32)"> |
| | | <rect x="30" y="10" width="6" height="14" rx="3" ry="3" fill="#fbbf24" /> |
| | | </g> |
| | | |
| | | <!-- 5. 青色 --> |
| | | <g transform="rotate(240 32 32)"> |
| | | <rect x="30" y="10" width="6" height="14" rx="3" ry="3" fill="#22d3ee" /> |
| | | </g> |
| | | |
| | | <!-- 6. 蓝色 --> |
| | | <g transform="rotate(300 32 32)"> |
| | | <rect x="30" y="10" width="6" height="14" rx="3" ry="3" fill="#3b82f6" /> |
| | | </g> |
| | | </g> |
| | | </svg> |
| | | <span>WCS AI 诊断</span> |
| | | <div v-html="headerIcon" style="margin-right: 10px; display: flex;"></div> |
| | | <span>WCS AI 助手</span> |
| | | </div> |
| | | |
| | | <div class="actions"> |
| | | <el-button type="primary" :loading="loading" :disabled="streaming" @click="start">开始诊断</el-button> |
| | | <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> |
| | |
| | | <el-divider></el-divider> |
| | | |
| | | <el-card class="output" shadow="never"> |
| | | <div class="markdown-body" v-html="renderedHtml"></div> |
| | | <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> |
| | | |
| | |
| | | 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, |
| | | markdownBuffer: '', |
| | | renderedHtml: '', |
| | | messages: [], |
| | | pendingText: '', |
| | | typingTimer: null, |
| | | lastRenderTs: 0, |
| | | renderIntervalMs: 120, |
| | | stepChars: 6 |
| | | stepChars: 6, |
| | | userInput: '', |
| | | autoScrollThreshold: 80 |
| | | }; |
| | | }, |
| | | computed: { |
| | |
| | | if (this.streaming) return '诊断进行中'; |
| | | if (this.loading) return '连接中'; |
| | | return '空闲'; |
| | | }, |
| | | sendDisabled: function() { |
| | | var t = (this.userInput || '').trim(); |
| | | return this.streaming || t.length === 0; |
| | | } |
| | | }, |
| | | methods: { |
| | | 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); |
| | | 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 = ''; |
| | | }, |
| | | start: function() { |
| | | if (this.streaming) return; |
| | | this.clear(); |
| | |
| | | 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; |
| | |
| | | if (/^\n+$/.test(normalized)) return; |
| | | self.pendingText += chunk; |
| | | self.ensureTyping(); |
| | | self.scrollToBottom(true); |
| | | }; |
| | | this.source.onerror = function() { |
| | | self.stop(); |
| | |
| | | } |
| | | if (self.pendingText.length > 0) { |
| | | var n = Math.min(self.stepChars, self.pendingText.length); |
| | | self.markdownBuffer += self.pendingText.slice(0, n); |
| | | 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 renderSource = self.markdownBuffer.replace(/\\n/g, '\n'); |
| | | self.renderedHtml = DOMPurify.sanitize(marked.parse(renderSource)); |
| | | 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); |
| | | }, |
| | |
| | | clearInterval(this.typingTimer); |
| | | this.typingTimer = null; |
| | | } |
| | | var renderSource = this.markdownBuffer.replace(/\\n/g, '\n'); |
| | | this.renderedHtml = DOMPurify.sanitize(marked.parse(renderSource)); |
| | | 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)); |
| | | }, |
| | | clear: function() { |
| | | this.markdownBuffer = ''; |
| | | this.renderedHtml = ''; |
| | | this.messages = []; |
| | | this.pendingText = ''; |
| | | } |
| | | } |