Add DeepSeek AI MCP assistant

This commit is contained in:
meswork
2026-05-27 14:39:35 +08:00
parent 120a18a651
commit 035970880d
23 changed files with 148240 additions and 0 deletions

View File

@@ -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

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
},
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>