| | |
| | | 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; } |
| | | .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> |
| | |
| | | <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 = ''; |
| | | } |
| | | } |