|
|
@@ -0,0 +1,557 @@
|
|
|
+<template>
|
|
|
+ <div class="ai-assistant">
|
|
|
+ <!-- 右下角悬浮图标 -->
|
|
|
+ <div
|
|
|
+ class="ai-assistant__fab"
|
|
|
+ :style="fabStyle"
|
|
|
+ @mousedown.prevent="startDrag"
|
|
|
+ @touchstart.prevent="startDragTouch"
|
|
|
+ @click="onFabClick"
|
|
|
+ title="金源信通助手"
|
|
|
+ >
|
|
|
+ <i class="el-icon-chat-dot-round" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 抽屉 -->
|
|
|
+ <el-drawer
|
|
|
+ custom-class="ai-assistant__drawer"
|
|
|
+ :visible.sync="drawerVisible"
|
|
|
+ title="金源信通助手"
|
|
|
+ direction="rtl"
|
|
|
+ size="800px"
|
|
|
+ :before-close="handleClose"
|
|
|
+ :close-on-press-escape="false"
|
|
|
+ >
|
|
|
+ <div class="ai-assistant__chat">
|
|
|
+ <!-- 消息列表 -->
|
|
|
+ <div ref="msgList" class="ai-assistant__messages">
|
|
|
+ <div
|
|
|
+ v-for="(msg, idx) in messages"
|
|
|
+ :key="idx"
|
|
|
+ :class="['ai-assistant__msg', `ai-assistant__msg--${msg.role}`]"
|
|
|
+ >
|
|
|
+ <div class="ai-assistant__msg-avatar">
|
|
|
+ <i
|
|
|
+ :class="
|
|
|
+ msg.role === 'user'
|
|
|
+ ? 'el-icon-user'
|
|
|
+ : 'el-icon-chat-dot-round'
|
|
|
+ "
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="ai-assistant__msg-content">
|
|
|
+ <div class="ai-assistant__msg-text">{{ msg.content }}</div>
|
|
|
+ <div class="ai-assistant__msg-time">{{ msg.time }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <!-- 加载中 / 光标闪烁 -->
|
|
|
+ <!-- <div
|
|
|
+ v-if="loading && !streamingContent"
|
|
|
+ class="ai-assistant__msg ai-assistant__msg--assistant"
|
|
|
+ >
|
|
|
+ <div class="ai-assistant__msg-avatar">
|
|
|
+ <i class="el-icon-chat-dot-round" />
|
|
|
+ </div>
|
|
|
+ <div class="ai-assistant__msg-content">
|
|
|
+ <div class="ai-assistant__msg-typing">正在思考中...</div>
|
|
|
+ </div>
|
|
|
+ </div> -->
|
|
|
+ <!-- 优先显示流式返回的实时内容 -->
|
|
|
+ <div
|
|
|
+ v-if="streamingContent"
|
|
|
+ class="ai-assistant__msg ai-assistant__msg--assistant"
|
|
|
+ >
|
|
|
+ <div class="ai-assistant__msg-avatar">
|
|
|
+ <i class="el-icon-chat-dot-round" />
|
|
|
+ </div>
|
|
|
+ <div class="ai-assistant__msg-content">
|
|
|
+ <div class="ai-assistant__msg-text">{{ streamingContent }}</div>
|
|
|
+ <div class="ai-assistant__msg-time">{{ formatTime() }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 没有流内容时显示“正在思考中...” -->
|
|
|
+ <div
|
|
|
+ v-else-if="loading"
|
|
|
+ class="ai-assistant__msg ai-assistant__msg--assistant"
|
|
|
+ >
|
|
|
+ <div class="ai-assistant__msg-avatar">
|
|
|
+ <i class="el-icon-chat-dot-round" />
|
|
|
+ </div>
|
|
|
+ <div class="ai-assistant__msg-content">
|
|
|
+ <div class="ai-assistant__msg-typing">正在思考中...</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 输入区 -->
|
|
|
+ <div class="ai-assistant__input">
|
|
|
+ <el-input
|
|
|
+ v-model="inputText"
|
|
|
+ type="textarea"
|
|
|
+ :rows="3"
|
|
|
+ placeholder="输入您的问题..."
|
|
|
+ :disabled="loading"
|
|
|
+ resize="none"
|
|
|
+ @keydown.native="handleKeydown"
|
|
|
+ />
|
|
|
+ <div class="ai-assistant__input-actions">
|
|
|
+ <el-button
|
|
|
+ @keydown="sendMessage"
|
|
|
+ type="primary"
|
|
|
+ size="small"
|
|
|
+ :disabled="!inputText.trim() || loading"
|
|
|
+ :loading="loading"
|
|
|
+ @click="sendMessage"
|
|
|
+ >
|
|
|
+ 发送
|
|
|
+ </el-button>
|
|
|
+ <el-button size="small" @click="clearMessages">清空</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-drawer>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { computed } from 'vue'
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'AiAssistant',
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ drawerVisible: false,
|
|
|
+ inputText: '',
|
|
|
+ loading: false,
|
|
|
+ streamingContent: '',
|
|
|
+ fabX: 0,
|
|
|
+ fabY: 0,
|
|
|
+ fabWidth: 52,
|
|
|
+ fabHeight: 52,
|
|
|
+ dragging: false,
|
|
|
+ dragStartX: 0,
|
|
|
+ dragStartY: 0,
|
|
|
+ startFabX: 0,
|
|
|
+ startFabY: 0,
|
|
|
+ dragMoved: false,
|
|
|
+ messages: [
|
|
|
+ {
|
|
|
+ role: 'assistant',
|
|
|
+ content: '您好!我是金源信通助手,有什么可以帮您的?',
|
|
|
+ time: this.formatTime()
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ fabStyle() {
|
|
|
+ return {
|
|
|
+ position: 'fixed',
|
|
|
+ left: `${this.fabX}px`,
|
|
|
+ top: `${this.fabY}px`,
|
|
|
+ width: `${this.fabWidth}px`,
|
|
|
+ height: `${this.fabHeight}px`,
|
|
|
+ borderRadius: '50%'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ mounted() {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ const vw = window.innerWidth
|
|
|
+ const vh = window.innerHeight
|
|
|
+ this.fabX = vw - 24 - this.fabWidth
|
|
|
+ this.fabY = vh - 80 - this.fabHeight
|
|
|
+ })
|
|
|
+ window.addEventListener('resize', this._handleWindowResize)
|
|
|
+ },
|
|
|
+ beforeDestroy() {
|
|
|
+ window.removeEventListener('resize', this._handleWindowResize)
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ _handleWindowResize() {
|
|
|
+ const vw = window.innerWidth
|
|
|
+ const vh = window.innerHeight
|
|
|
+ this.fabX = Math.min(Math.max(0, this.fabX), vw - this.fabWidth)
|
|
|
+ this.fabY = Math.min(Math.max(0, this.fabY), vh - this.fabHeight)
|
|
|
+ },
|
|
|
+
|
|
|
+ onFabClick() {
|
|
|
+ if (this.dragMoved) {
|
|
|
+ this.dragMoved = false
|
|
|
+ return
|
|
|
+ }
|
|
|
+ this.toggleDrawer()
|
|
|
+ },
|
|
|
+
|
|
|
+ startDrag(e) {
|
|
|
+ this.dragging = true
|
|
|
+ this.dragMoved = false
|
|
|
+ this.dragStartX = e.clientX
|
|
|
+ this.dragStartY = e.clientY
|
|
|
+ this.startFabX = this.fabX
|
|
|
+ this.startFabY = this.fabY
|
|
|
+ this._onMouseMove = this.onDrag.bind(this)
|
|
|
+ this._onMouseUp = this.stopDrag.bind(this)
|
|
|
+ window.addEventListener('mousemove', this._onMouseMove)
|
|
|
+ window.addEventListener('mouseup', this._onMouseUp)
|
|
|
+ },
|
|
|
+
|
|
|
+ onDrag(e) {
|
|
|
+ if (!this.dragging) return
|
|
|
+ const dx = e.clientX - this.dragStartX
|
|
|
+ const dy = e.clientY - this.dragStartY
|
|
|
+ const vw = window.innerWidth
|
|
|
+ const vh = window.innerHeight
|
|
|
+ this.fabX = Math.min(Math.max(0, this.startFabX + dx), vw - this.fabWidth)
|
|
|
+ this.fabY = Math.min(
|
|
|
+ Math.max(0, this.startFabY + dy),
|
|
|
+ vh - this.fabHeight
|
|
|
+ )
|
|
|
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this.dragMoved = true
|
|
|
+ },
|
|
|
+
|
|
|
+ stopDrag() {
|
|
|
+ this.dragging = false
|
|
|
+ window.removeEventListener('mousemove', this._onMouseMove)
|
|
|
+ window.removeEventListener('mouseup', this._onMouseUp)
|
|
|
+ setTimeout(() => (this.dragMoved = false), 100)
|
|
|
+ },
|
|
|
+
|
|
|
+ // touch
|
|
|
+ startDragTouch(e) {
|
|
|
+ const touch = e.touches && e.touches[0]
|
|
|
+ if (!touch) return
|
|
|
+ this.dragging = true
|
|
|
+ this.dragMoved = false
|
|
|
+ this.dragStartX = touch.clientX
|
|
|
+ this.dragStartY = touch.clientY
|
|
|
+ this.startFabX = this.fabX
|
|
|
+ this.startFabY = this.fabY
|
|
|
+ this._onTouchMove = this.onDragTouch.bind(this)
|
|
|
+ this._onTouchEnd = this.stopDragTouch.bind(this)
|
|
|
+ window.addEventListener('touchmove', this._onTouchMove)
|
|
|
+ window.addEventListener('touchend', this._onTouchEnd)
|
|
|
+ },
|
|
|
+
|
|
|
+ onDragTouch(e) {
|
|
|
+ if (!this.dragging) return
|
|
|
+ const touch = e.touches && e.touches[0]
|
|
|
+ if (!touch) return
|
|
|
+ const dx = touch.clientX - this.dragStartX
|
|
|
+ const dy = touch.clientY - this.dragStartY
|
|
|
+ const vw = window.innerWidth
|
|
|
+ const vh = window.innerHeight
|
|
|
+ this.fabX = Math.min(Math.max(0, this.startFabX + dx), vw - this.fabWidth)
|
|
|
+ this.fabY = Math.min(
|
|
|
+ Math.max(0, this.startFabY + dy),
|
|
|
+ vh - this.fabHeight
|
|
|
+ )
|
|
|
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this.dragMoved = true
|
|
|
+ },
|
|
|
+
|
|
|
+ stopDragTouch() {
|
|
|
+ this.dragging = false
|
|
|
+ window.removeEventListener('touchmove', this._onTouchMove)
|
|
|
+ window.removeEventListener('touchend', this._onTouchEnd)
|
|
|
+ setTimeout(() => (this.dragMoved = false), 100)
|
|
|
+ },
|
|
|
+ toggleDrawer() {
|
|
|
+ this.drawerVisible = !this.drawerVisible
|
|
|
+ },
|
|
|
+ handleClose() {
|
|
|
+ this.drawerVisible = false
|
|
|
+ },
|
|
|
+ formatTime() {
|
|
|
+ const d = new Date()
|
|
|
+ return `${d.getHours().toString().padStart(2, '0')}:${d
|
|
|
+ .getMinutes()
|
|
|
+ .toString()
|
|
|
+ .padStart(2, '0')}`
|
|
|
+ },
|
|
|
+ handleKeydown(e) {
|
|
|
+ // Ctrl/Cmd + Enter 插入换行
|
|
|
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
|
+ const el = e.target
|
|
|
+ if (el && typeof el.selectionStart === 'number') {
|
|
|
+ const start = el.selectionStart
|
|
|
+ const end = el.selectionEnd
|
|
|
+ const v = this.inputText || ''
|
|
|
+ this.inputText = v.slice(0, start) + '\n' + v.slice(end)
|
|
|
+ this.$nextTick(() => {
|
|
|
+ el.selectionStart = el.selectionEnd = start + 1
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 单独按 Enter 发送消息
|
|
|
+ if (e.key === 'Enter') {
|
|
|
+ e.preventDefault()
|
|
|
+ this.sendMessage()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async sendMessage() {
|
|
|
+ const text = this.inputText.trim()
|
|
|
+ if (!text || this.loading) return
|
|
|
+
|
|
|
+ // 添加用户消息
|
|
|
+ this.messages.push({
|
|
|
+ role: 'user',
|
|
|
+ content: text,
|
|
|
+ time: this.formatTime()
|
|
|
+ })
|
|
|
+ this.inputText = ''
|
|
|
+ this.scrollToBottom()
|
|
|
+
|
|
|
+ this.loading = true
|
|
|
+ this.streamingContent = ''
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await fetch('/ollama/api/chat', {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ model: 'qwen2.5:latest',
|
|
|
+ messages: this.buildMessages(),
|
|
|
+ stream: true
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ if (!response.ok) {
|
|
|
+ throw new Error(`HTTP ${response.status}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ const reader = response.body.getReader()
|
|
|
+ const decoder = new TextDecoder()
|
|
|
+ let buffer = ''
|
|
|
+ let content = ''
|
|
|
+
|
|
|
+ while (true) {
|
|
|
+ const { done, value } = await reader.read()
|
|
|
+ if (done) break
|
|
|
+
|
|
|
+ buffer += decoder.decode(value, { stream: true })
|
|
|
+ const lines = buffer.split('\n')
|
|
|
+ buffer = lines.pop() || ''
|
|
|
+
|
|
|
+ for (const line of lines) {
|
|
|
+ if (!line.trim()) continue
|
|
|
+ try {
|
|
|
+ const chunk = JSON.parse(line)
|
|
|
+ if (chunk.message?.content) {
|
|
|
+ content += chunk.message.content
|
|
|
+ this.streamingContent = content
|
|
|
+ await this.$nextTick() // 让出线程,Vue 刷新 DOM
|
|
|
+ this.scrollToBottom()
|
|
|
+ }
|
|
|
+ if (chunk.done) {
|
|
|
+ // 流结束,正式添加到消息列表
|
|
|
+ this.messages.push({
|
|
|
+ role: 'assistant',
|
|
|
+ content: content,
|
|
|
+ time: this.formatTime()
|
|
|
+ })
|
|
|
+ this.streamingContent = ''
|
|
|
+ this.scrollToBottom()
|
|
|
+ }
|
|
|
+ } catch (_) {
|
|
|
+ // 忽略
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果流没返回 done 字段但结束了,补充提交
|
|
|
+ if (content && this.streamingContent) {
|
|
|
+ this.messages.push({
|
|
|
+ role: 'assistant',
|
|
|
+ content: content,
|
|
|
+ time: this.formatTime()
|
|
|
+ })
|
|
|
+ this.streamingContent = ''
|
|
|
+ this.scrollToBottom()
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('AI助手请求失败:', e)
|
|
|
+ this.streamingContent = ''
|
|
|
+ this.messages.push({
|
|
|
+ role: 'assistant',
|
|
|
+ content: '请求失败,请检查网络连接或稍后重试。',
|
|
|
+ time: this.formatTime()
|
|
|
+ })
|
|
|
+ this.scrollToBottom()
|
|
|
+ } finally {
|
|
|
+ this.loading = false
|
|
|
+ this.scrollToBottom()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ /** 构建 Ollama api/chat 格式的消息 */
|
|
|
+ buildMessages() {
|
|
|
+ return this.messages.map((m) => ({
|
|
|
+ role: m.role,
|
|
|
+ content: m.content
|
|
|
+ }))
|
|
|
+ },
|
|
|
+ scrollToBottom() {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ const el = this.$refs.msgList
|
|
|
+ if (el) {
|
|
|
+ el.scrollTop = el.scrollHeight
|
|
|
+ }
|
|
|
+ })
|
|
|
+ },
|
|
|
+ clearMessages() {
|
|
|
+ this.messages = [
|
|
|
+ {
|
|
|
+ role: 'assistant',
|
|
|
+ content: '您好!我是金源信通助手,有什么可以帮您的?',
|
|
|
+ time: this.formatTime()
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+<style lang="scss">
|
|
|
+.ai-assistant__drawer {
|
|
|
+ .el-drawer__header {
|
|
|
+ margin-bottom: 16px;
|
|
|
+ color: #333;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|
|
|
+<style lang="scss" scoped>
|
|
|
+.ai-assistant {
|
|
|
+ &__fab {
|
|
|
+ position: fixed;
|
|
|
+ right: 24px;
|
|
|
+ bottom: 80px;
|
|
|
+ width: 52px;
|
|
|
+ height: 52px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: #409eff;
|
|
|
+ color: #fff;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ cursor: pointer;
|
|
|
+ z-index: 999;
|
|
|
+ box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4);
|
|
|
+ transition: transform 0.2s, box-shadow 0.2s;
|
|
|
+ font-size: 24px;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ transform: scale(1.1);
|
|
|
+ box-shadow: 0 6px 20px rgba(64, 158, 255, 0.6);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &__chat {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ height: 100%;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__messages {
|
|
|
+ flex: 1;
|
|
|
+ overflow-y: auto;
|
|
|
+ padding: 16px;
|
|
|
+ background: #f5f7fa;
|
|
|
+ border-radius: 8px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__msg {
|
|
|
+ display: flex;
|
|
|
+ margin-bottom: 16px;
|
|
|
+ gap: 10px;
|
|
|
+
|
|
|
+ &--user {
|
|
|
+ flex-direction: row-reverse;
|
|
|
+
|
|
|
+ .ai-assistant__msg-text {
|
|
|
+ background: #409eff;
|
|
|
+ color: #fff;
|
|
|
+ }
|
|
|
+
|
|
|
+ .ai-assistant__msg-time {
|
|
|
+ text-align: right;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &--assistant {
|
|
|
+ .ai-assistant__msg-text {
|
|
|
+ background: #fff;
|
|
|
+ color: #303133;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &__msg-avatar {
|
|
|
+ width: 32px;
|
|
|
+ height: 32px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: #e4e7ed;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ flex-shrink: 0;
|
|
|
+ font-size: 16px;
|
|
|
+ color: #909399;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__msg-content {
|
|
|
+ max-width: 70%;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__msg-text {
|
|
|
+ padding: 10px 14px;
|
|
|
+ border-radius: 12px;
|
|
|
+ font-size: 14px;
|
|
|
+ line-height: 1.6;
|
|
|
+ word-break: break-word;
|
|
|
+ white-space: pre-wrap;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__msg-time {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #c0c4cc;
|
|
|
+ margin-top: 4px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__msg-typing {
|
|
|
+ padding: 10px 14px;
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 12px;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #909399;
|
|
|
+ animation: blink 1.4s infinite;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__input {
|
|
|
+ border-top: 1px solid #ebeef5;
|
|
|
+ padding: 12px;
|
|
|
+
|
|
|
+ ::v-deep .el-textarea__inner {
|
|
|
+ border-radius: 8px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &__input-actions {
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+ gap: 8px;
|
|
|
+ margin-top: 8px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes blink {
|
|
|
+ 0%,
|
|
|
+ 100% {
|
|
|
+ opacity: 1;
|
|
|
+ }
|
|
|
+ 50% {
|
|
|
+ opacity: 0.4;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|