Add DeepSeek AI MCP assistant
This commit is contained in:
@@ -1,12 +1,18 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view v-if="isRouterAlive"/>
|
||||
<deep-seek-assistant/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DeepSeekAssistant from '@/components/DeepSeekAssistant'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
DeepSeekAssistant
|
||||
},
|
||||
provide() {
|
||||
return {
|
||||
reload: this.reload
|
||||
|
||||
415
MES-Manager_View/src/components/DeepSeekAssistant/index.vue
Normal file
415
MES-Manager_View/src/components/DeepSeekAssistant/index.vue
Normal file
@@ -0,0 +1,415 @@
|
||||
<template>
|
||||
<div
|
||||
:class="assistantClass"
|
||||
:style="{ left: position.left + 'px', top: position.top + 'px' }"
|
||||
class="ai-assistant">
|
||||
<div class="ai-header" @mousedown="startDrag">
|
||||
<div class="ai-title">
|
||||
<span class="ai-dot"/>
|
||||
<span v-if="!collapsed">DeepSeekV4Pro AI助手</span>
|
||||
</div>
|
||||
<div class="ai-actions">
|
||||
<el-tooltip v-if="!collapsed" :content="'窗口尺寸:' + sizeLabel" effect="dark" placement="top">
|
||||
<el-button class="ai-icon-button" type="text" icon="el-icon-rank" @mousedown.stop @click.stop="toggleSize"/>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="!collapsed" effect="dark" content="清空聊天" placement="top">
|
||||
<el-button class="ai-icon-button" type="text" icon="el-icon-delete" @mousedown.stop @click.stop="clearMessages"/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="collapsed ? '显示助手' : '隐藏助手'" effect="dark" placement="top">
|
||||
<el-button :icon="collapsed ? 'el-icon-plus' : 'el-icon-minus'" class="ai-icon-button" type="text" @mousedown.stop @click.stop="toggleCollapsed"/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="!collapsed" class="ai-body">
|
||||
<div ref="messages" class="ai-messages">
|
||||
<div v-for="item in messages" :key="item.id" :class="'is-' + item.role" class="ai-message">
|
||||
<div class="ai-message-title">{{ item.role === 'user' ? '问题' : '回答' }}</div>
|
||||
<div v-if="item.role === 'assistant'" class="ai-message-content">
|
||||
<div v-html="item.content"/>
|
||||
<div v-for="chart in item.charts" :key="chart.id" class="ai-chart-wrap">
|
||||
<div :ref="'chart_' + chart.id" class="ai-chart"/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="ai-message-content">{{ item.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-input-row">
|
||||
<el-input
|
||||
v-model="question"
|
||||
:autosize="{ minRows: 2, maxRows: 4 }"
|
||||
type="textarea"
|
||||
resize="none"
|
||||
placeholder="询问质量数据、SPC趋势图、项目页面或功能..."
|
||||
@keydown.native="handleInputKeydown"/>
|
||||
<el-button :loading="loading" type="primary" icon="el-icon-position" @click="sendQuestion">发送</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
|
||||
function welcomeMessage() {
|
||||
return {
|
||||
id: 1,
|
||||
role: 'assistant',
|
||||
content: '<table class="ai-result-table"><tbody><tr><th>状态</th><td>AI助手已就绪</td></tr><tr><th>能力</th><td>可查询项目功能、质量管理模块、SPC趋势/直方/正态/过程能力/X-R/X-S图</td></tr><tr><th>输入</th><td>按 Enter 发送,Shift + Enter 换行</td></tr></tbody></table>',
|
||||
charts: []
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'DeepSeekAssistant',
|
||||
data() {
|
||||
return {
|
||||
collapsed: false,
|
||||
size: 'medium',
|
||||
question: '',
|
||||
loading: false,
|
||||
position: {
|
||||
left: Math.max(0, window.innerWidth - 470),
|
||||
top: 96
|
||||
},
|
||||
drag: {
|
||||
active: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
left: 0,
|
||||
top: 0
|
||||
},
|
||||
messages: [welcomeMessage()],
|
||||
messageId: 2,
|
||||
chartInstances: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
assistantClass() {
|
||||
const result = { 'is-collapsed': this.collapsed }
|
||||
result['is-' + this.size] = true
|
||||
return result
|
||||
},
|
||||
sizeLabel() {
|
||||
return ({ small: '小', medium: '中', large: '大' })[this.size] || '中'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.keepInViewport()
|
||||
window.addEventListener('mousemove', this.onDrag)
|
||||
window.addEventListener('mouseup', this.stopDrag)
|
||||
window.addEventListener('resize', this.handleResize)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('mousemove', this.onDrag)
|
||||
window.removeEventListener('mouseup', this.stopDrag)
|
||||
window.removeEventListener('resize', this.handleResize)
|
||||
Object.keys(this.chartInstances).forEach(key => {
|
||||
this.chartInstances[key].dispose()
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
getBaseUrl() {
|
||||
return (window.g && window.g.aiMcpURL) || 'http://127.0.0.1:3100'
|
||||
},
|
||||
toggleCollapsed() {
|
||||
this.collapsed = !this.collapsed
|
||||
this.$nextTick(() => {
|
||||
this.keepInViewport()
|
||||
this.resizeCharts()
|
||||
})
|
||||
},
|
||||
toggleSize() {
|
||||
const sizes = ['small', 'medium', 'large']
|
||||
const index = sizes.indexOf(this.size)
|
||||
this.size = sizes[(index + 1) % sizes.length]
|
||||
this.$nextTick(() => {
|
||||
this.keepInViewport()
|
||||
this.resizeCharts()
|
||||
})
|
||||
},
|
||||
clearMessages() {
|
||||
Object.keys(this.chartInstances).forEach(key => {
|
||||
this.chartInstances[key].dispose()
|
||||
})
|
||||
this.chartInstances = {}
|
||||
this.messages = [welcomeMessage()]
|
||||
this.messageId = 2
|
||||
this.question = ''
|
||||
this.scrollToBottom()
|
||||
},
|
||||
startDrag(event) {
|
||||
this.drag.active = true
|
||||
this.drag.startX = event.clientX
|
||||
this.drag.startY = event.clientY
|
||||
this.drag.left = this.position.left
|
||||
this.drag.top = this.position.top
|
||||
},
|
||||
onDrag(event) {
|
||||
if (!this.drag.active) return
|
||||
this.position.left = this.drag.left + event.clientX - this.drag.startX
|
||||
this.position.top = this.drag.top + event.clientY - this.drag.startY
|
||||
this.keepInViewport()
|
||||
},
|
||||
stopDrag() {
|
||||
this.drag.active = false
|
||||
},
|
||||
getPanelSize() {
|
||||
const sizes = {
|
||||
small: { width: 360, height: 460 },
|
||||
medium: { width: 440, height: 580 },
|
||||
large: { width: 760, height: 700 }
|
||||
}
|
||||
return this.collapsed ? { width: 56, height: 48 } : sizes[this.size] || sizes.medium
|
||||
},
|
||||
keepInViewport() {
|
||||
const panelSize = this.getPanelSize()
|
||||
this.position.left = Math.max(0, Math.min(this.position.left, window.innerWidth - panelSize.width - 8))
|
||||
this.position.top = Math.max(0, Math.min(this.position.top, window.innerHeight - panelSize.height - 8))
|
||||
},
|
||||
handleResize() {
|
||||
this.keepInViewport()
|
||||
this.resizeCharts()
|
||||
},
|
||||
scrollToBottom() {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.messages) this.$refs.messages.scrollTop = this.$refs.messages.scrollHeight
|
||||
})
|
||||
},
|
||||
sanitizeHtml(html) {
|
||||
return String(html || '')
|
||||
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/\son[a-z]+\s*=\s*"[^"]*"/gi, '')
|
||||
.replace(/\son[a-z]+\s*=\s*'[^']*'/gi, '')
|
||||
.replace(/javascript:/gi, '')
|
||||
},
|
||||
escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
},
|
||||
handleInputKeydown(event) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
this.sendQuestion()
|
||||
}
|
||||
},
|
||||
sendQuestion() {
|
||||
const content = this.question.trim()
|
||||
if (!content || this.loading) return
|
||||
this.messages.push({ id: this.messageId++, role: 'user', content })
|
||||
this.question = ''
|
||||
this.loading = true
|
||||
this.scrollToBottom()
|
||||
axios.post(this.getBaseUrl() + '/api/assistant/chat', { question: content }).then(response => {
|
||||
const payload = response.data || {}
|
||||
const message = {
|
||||
id: this.messageId++,
|
||||
role: 'assistant',
|
||||
content: this.sanitizeHtml(payload.html || '<p>未返回内容</p>'),
|
||||
charts: Array.isArray(payload.charts) ? payload.charts : []
|
||||
}
|
||||
this.messages.push(message)
|
||||
this.handleAssistantActions(payload.actions)
|
||||
this.$nextTick(() => this.renderCharts(message))
|
||||
}).catch(error => {
|
||||
this.messages.push({
|
||||
id: this.messageId++,
|
||||
role: 'assistant',
|
||||
content: '<table class="ai-result-table"><tbody><tr><th>错误</th><td>' + this.escapeHtml(error.message) + '</td></tr><tr><th>检查项</th><td>确认 MCP 服务是否已启动,默认地址 http://127.0.0.1:3100</td></tr></tbody></table>',
|
||||
charts: []
|
||||
})
|
||||
}).then(() => {
|
||||
this.loading = false
|
||||
this.scrollToBottom()
|
||||
})
|
||||
},
|
||||
handleAssistantActions(actions) {
|
||||
if (!Array.isArray(actions)) return
|
||||
actions.forEach(action => {
|
||||
if (!action || action.type !== 'navigate' || !action.path) return
|
||||
this.$router.push({ path: action.path }).catch(() => {})
|
||||
})
|
||||
},
|
||||
renderCharts(message) {
|
||||
if (!message || !Array.isArray(message.charts)) return
|
||||
message.charts.forEach(chart => {
|
||||
const ref = this.$refs['chart_' + chart.id]
|
||||
const el = Array.isArray(ref) ? ref[0] : ref
|
||||
if (!el || !chart.option || !this.$echarts) return
|
||||
const instance = this.$echarts.init(el)
|
||||
instance.setOption(chart.option)
|
||||
this.chartInstances[chart.id] = instance
|
||||
})
|
||||
},
|
||||
resizeCharts() {
|
||||
Object.keys(this.chartInstances).forEach(key => {
|
||||
this.chartInstances[key].resize()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style rel="stylesheet/scss" lang="scss">
|
||||
.ai-assistant {
|
||||
position: fixed;
|
||||
z-index: 3000;
|
||||
background: #fff;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 21, 41, 0.16);
|
||||
overflow: hidden;
|
||||
transition: width .18s ease, height .18s ease, box-shadow .18s ease;
|
||||
|
||||
&.is-small {
|
||||
width: 360px;
|
||||
height: 460px;
|
||||
}
|
||||
|
||||
&.is-medium {
|
||||
width: 440px;
|
||||
height: 580px;
|
||||
}
|
||||
|
||||
&.is-large {
|
||||
width: 760px;
|
||||
height: 700px;
|
||||
}
|
||||
|
||||
&.is-collapsed {
|
||||
width: 56px;
|
||||
height: 48px;
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 4px 14px rgba(24, 144, 255, 0.24);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-header {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px;
|
||||
cursor: move;
|
||||
color: #fff;
|
||||
background: #304156;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ai-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-right: 8px;
|
||||
border-radius: 50%;
|
||||
background: #67c23a;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ai-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ai-icon-button {
|
||||
color: #fff;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.ai-icon-button:hover,
|
||||
.ai-icon-button:focus {
|
||||
color: #d9ecff;
|
||||
}
|
||||
|
||||
.ai-body {
|
||||
height: calc(100% - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.ai-messages {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.ai-message {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ai-message.is-user {
|
||||
background: #ecf5ff;
|
||||
border-color: #d9ecff;
|
||||
}
|
||||
|
||||
.ai-message-title {
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.ai-message-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #303133;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ai-input-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 72px;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ai-result-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 6px 0 10px;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-result-table th,
|
||||
.ai-result-table td {
|
||||
border: 1px solid #dcdfe6;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.ai-result-table th {
|
||||
background: #f2f6fc;
|
||||
color: #606266;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-chart-wrap {
|
||||
margin: 10px 0;
|
||||
border: 1px solid #ebeef5;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ai-chart {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user