Quellcode durchsuchen

feat: 添加金源信通助手功能

johnsen vor 3 Tagen
Ursprung
Commit
598bfedeee

+ 557 - 0
src/layout/header-aside/components/components/ai-assistant/index.vue

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

+ 5 - 4
src/layout/header-aside/layout.vue

@@ -25,7 +25,7 @@
       <div
         :style="{ opacity: searchActive ? 0.5 : 1 }"
         class="ibps-theme-header"
-        style=" margin-bottom: 5px;"
+        style="margin-bottom: 5px"
         flex-box="0"
         flex
       >
@@ -193,6 +193,7 @@
       </div>
     </div>
     <!-- <FloatBall></FloatBall> -->
+    <AiAssistant />
   </div>
 </template>
 
@@ -203,6 +204,7 @@ import IbpsMenuHeader from './components/menu-header/index.js'
 import IbpsTabs from './components/tabs'
 import IbpsHeaderSearch from './components/header-search'
 import FloatBall from './components/components/float-ball'
+import AiAssistant from './components/components/ai-assistant'
 import IbpsHeaderFullscreen from './components/header-fullscreen'
 // import IbpsHeaderLocking from './components/header-locking'
 // import IbpsHeaderLanguage from './components/header-language'
@@ -237,6 +239,7 @@ export default {
     IbpsHeaderSearch,
     FloatBall,
     panle,
+    AiAssistant,
     IbpsHeaderFullscreen,
     // IbpsHeaderLocking,
     // IbpsHeaderLanguage,
@@ -375,9 +378,7 @@ export default {
 }
 .layout-border-left {
   float: left;
-  box-shadow:
-    0px 2px 4px #e78c45,
-    0 0 5px rgba(155, 155, 0, 0.04);
+  box-shadow: 0px 2px 4px #e78c45, 0 0 5px rgba(155, 155, 0, 0.04);
   width: 100%;
   height: 50px;
   line-height: 50px;