From 3bead79c63a2e85b53a449ba6f282a503eab0258 Mon Sep 17 00:00:00 2001
From: lipenghui <mrkezhi@163.com>
Date: Fri, 19 Jan 2024 19:52:18 +0800
Subject: [PATCH] =?UTF-8?q?feat:=E5=9F=BA=E6=9C=AC=E5=AE=8C=E6=88=90?=
 =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=8A=9F=E8=83=BD=EF=BC=9B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 src/api/base/message.ts                |   5 +-
 src/assets/svg/again.svg               |  22 ++
 src/assets/svg/copy.svg                |  16 ++
 src/assets/svg/stop.svg                |  20 ++
 src/components/AppMessage/index.vue    | 283 ++++++++++++++++---------
 src/design/public.scss                 |  11 +-
 src/enums/messageEnum.ts               |   2 +-
 src/hooks/useMqtt.ts                   |  63 +++++-
 src/store/moules/messageStore/index.ts |   6 +-
 src/utils/copyTextToClipboard.ts       |  38 ++++
 src/utils/mqtt.ts                      |  12 +-
 src/views/conversation/index.vue       | 113 +++++++---
 12 files changed, 434 insertions(+), 157 deletions(-)
 create mode 100644 src/assets/svg/again.svg
 create mode 100644 src/assets/svg/copy.svg
 create mode 100644 src/assets/svg/stop.svg
 create mode 100644 src/utils/copyTextToClipboard.ts

diff --git a/src/api/base/message.ts b/src/api/base/message.ts
index bdb17fd..8c9f002 100644
--- a/src/api/base/message.ts
+++ b/src/api/base/message.ts
@@ -12,14 +12,14 @@ export async function conversationList() {
 /**
  * @description 对话历史记录
  */
-export async function historyMessage(data: {
+export async function historyMessage(params: {
   conversationId: string
   current: number
   size: number
 }) {
   return defHttp.get({
     url: `/open-chat/chat/chatMessageLog/page`,
-    data,
+    params,
   })
 }
 
@@ -34,6 +34,7 @@ export async function sendMessage(data: {
   return defHttp.post({
     url: `/open-chat/chat/session`,
     data,
+    timeout: 30 * 1000,
   })
 }
 
diff --git a/src/assets/svg/again.svg b/src/assets/svg/again.svg
new file mode 100644
index 0000000..e5ac650
--- /dev/null
+++ b/src/assets/svg/again.svg
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="22px" height="22px" viewBox="0 0 22 22" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>编组 6</title>
+    <defs>
+        <rect id="path-1" x="0" y="0" width="22" height="22"></rect>
+    </defs>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="回答结束" transform="translate(-639.000000, -692.000000)">
+            <g id="编组-6" transform="translate(639.000000, 692.000000)">
+                <mask id="mask-2" fill="white">
+                    <use xlink:href="#path-1"></use>
+                </mask>
+                <g id="矩形"></g>
+                <g id="tizhibianbiezhongxinceshi" mask="url(#mask-2)" fill="#4670E3" fill-rule="nonzero">
+                    <g transform="translate(0.000000, 0.004246)" id="路径">
+                        <path d="M3.18539434,11.6316828 L6.37084384,8.24859031 L4.50456772,8.24859031 C4.52177325,8.19915198 4.53895121,8.14979637 4.55756296,8.10107495 C5.6647278,5.12189882 8.39897328,3.01358719 11.5972718,3.01358719 C15.7752653,3.01358719 19.162604,6.61218997 19.162604,11.050169 C19.162604,15.4887822 15.7752653,19.0874952 11.5972718,19.0874952 C8.52354796,19.0874952 5.87811497,17.1409266 4.69506935,14.3451659 C4.68359899,14.3186407 4.67499624,14.2899924 4.66498724,14.2627503 L2.63545158,16.4047836 C2.64832815,16.4298199 2.65833714,16.4585509 2.67267509,16.483532 C2.67554268,16.4892948 2.67841027,16.4950024 2.68125028,16.5007376 C4.47161804,19.7856979 7.81602567,21.9957537 11.6473719,21.9957537 C17.3651225,21.9957537 22,17.0715531 22,10.9971186 C22,4.92351133 17.3651225,6.8598109e-16 11.6473719,6.8598109e-16 C6.86784656,6.8598109e-16 2.84453738,3.44038912 1.65286142,8.11679155 C1.64139106,8.15980538 1.63852347,8.20488716 1.62848692,8.24856275 L-6.61481765e-16,8.24856275 L3.18539434,11.6316828 Z"></path>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>
\ No newline at end of file
diff --git a/src/assets/svg/copy.svg b/src/assets/svg/copy.svg
new file mode 100644
index 0000000..8b76137
--- /dev/null
+++ b/src/assets/svg/copy.svg
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="22px" height="24px" viewBox="0 0 22 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>fuzhi</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="回答结束" transform="translate(-849.000000, -691.000000)">
+            <g id="fuzhi" transform="translate(849.000000, 692.000000)">
+                <rect id="矩形" x="0" y="0" width="22" height="22"></rect>
+                <g id="编组-5" transform="translate(1.000000, 0.000000)" fill="#4670E3" fill-rule="nonzero" stroke="#4670E3" stroke-width="0.4">
+                    <path d="M16.8235294,19.4117647 L0,19.4117647 L0,0 L16.8235294,0 L16.8235294,19.4117647 Z M1.52941176,18.0252101 L15.2941176,18.0252101 L15.2941176,1.38655462 L1.52941176,1.38655462 L1.52941176,18.0252101 Z" id="形状"></path>
+                    <path d="M3.4,7.6 L13.4,7.6 L13.4,9 L3.4,9 L3.4,7.6 Z M3.4,10.6 L13.4,10.6 L13.4,12 L3.4,12 L3.4,10.6 Z M3.4,13.6 L13.4,13.6 L13.4,15 L3.4,15 L3.4,13.6 Z M3.4,4.6 L8.4,4.6 L8.4,6 L3.4,6 L3.4,4.6 Z" id="形状"></path>
+                    <polygon id="路径" points="19.4117647 22 6.47058824 22 6.47058824 20.6134454 18.1792717 20.6134454 18.1792717 3.97478992 16.977591 3.97478992 16.977591 2.58823529 19.4117647 2.58823529"></polygon>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>
\ No newline at end of file
diff --git a/src/assets/svg/stop.svg b/src/assets/svg/stop.svg
new file mode 100644
index 0000000..5345601
--- /dev/null
+++ b/src/assets/svg/stop.svg
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="22px" height="22px" viewBox="0 0 22 22" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>编组 6</title>
+    <defs>
+        <rect id="path-1" x="0" y="0" width="22" height="22"></rect>
+    </defs>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="回答中" transform="translate(-641.000000, -668.000000)">
+            <g id="编组-6" transform="translate(641.000000, 668.000000)">
+                <mask id="mask-2" fill="white">
+                    <use xlink:href="#path-1"></use>
+                </mask>
+                <g id="矩形"></g>
+                <g id="tingzhi" mask="url(#mask-2)" fill="#4670E3" fill-rule="nonzero">
+                    <path d="M8.27047258,8.27376029 L13.7620168,8.27376029 L13.7620168,13.7653042 L8.27047258,13.7653042 L8.27047258,8.27376029 Z M20.5028874,16.5385339 L18.4572872,14.4970525 C20.1853307,10.8423285 19.0340567,6.47431111 15.7291045,4.14612872 C12.4241523,1.81794634 7.92352899,2.20446963 5.06394361,5.06207587 C2.20435824,7.91968212 1.8147193,12.4200365 4.1406133,15.7265995 C6.46650729,19.0331625 10.8337269,20.1874601 14.4896465,18.4619472 L16.5352467,20.5048015 C11.758152,23.2868757 5.65835199,22.09116 2.28515453,17.7114216 C-1.08804293,13.3316833 -0.686533687,7.12877414 3.22314327,3.22045025 C7.13282022,-0.687873643 13.3358683,-1.08723612 17.7144393,2.2874767 C22.0930102,5.66218951 23.2866148,11.7624026 20.5028874,16.5385339 Z" id="形状"></path>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>
\ No newline at end of file
diff --git a/src/components/AppMessage/index.vue b/src/components/AppMessage/index.vue
index 361229d..b88d89f 100644
--- a/src/components/AppMessage/index.vue
+++ b/src/components/AppMessage/index.vue
@@ -1,50 +1,134 @@
 <script setup lang="ts">
-import { onMounted, ref } from 'vue'
+import { nextTick, onMounted, ref, watch } from 'vue'
 import type { MessageItem } from './index.d'
+import { SvgIcon } from '@/components/SvgIcon'
 import { MessageStatusEnum, MessageTypeEnum } from '@/enums/messageEnum'
+import { copyText } from '@/utils/copyTextToClipboard'
 
-defineProps<{
+const props = defineProps<{
   list: MessageItem[]
 }>()
 
+const emit = defineEmits(['onScrollTop', 'reloadMessage'])
+
+defineExpose({ getScrollTopDistance, seamlessScrollToTop })
+
 const messageRef = ref<Element | null>(null)
+const elHeight = ref(0) // 旧列表的高度
+const defaultScrollTop = ref(0) // 默认滚动距离
+const isStop = ref(false) // 是否停止自动滚动
 
-onMounted(async () => {
+watch(
+  () => props.list[props.list.length - 1],
+  () => {
+    isStop.value && scrollToBottom()
+  },
+  { immediate: true },
+)
+
+/**
+ * @description: 滚动到底部
+ */
+async function scrollToBottom() {
+  if (!messageRef.value) {
+    return
+  }
+  await nextTick()
+  defaultScrollTop.value = messageRef.value.scrollTop
+  messageRef.value.scrollTo(0, messageRef.value.scrollHeight)
+}
+
+/**
+ * @description: 监听滚动
+ */
+function onScroll() {
+  if (!messageRef.value) {
+    return
+  }
+
+  // 滚动距离大于20px,停止自动滚动
+  if (messageRef.value.scrollTop - defaultScrollTop.value > 20) {
+    isStop.value = true
+  }
+  else {
+    isStop.value = false
+  }
+  emit('onScrollTop', messageRef.value.scrollTop)
+}
+
+/**
+ * @description: 上滑加载无缝滚动
+ */
+async function seamlessScrollToTop() {
+  if (!messageRef.value) {
+    return
+  }
+  await getScrollTopDistance()
+  messageRef.value.scrollTo(0, elHeight.value)
+}
+
+/**
+ * @description: 获取前10个元素的总高度,用于无缝滚动顶部的距离
+ * @param size 获取的元素个数,默认为10
+ */
+async function getScrollTopDistance(size = 10) {
+  if (!messageRef.value || messageRef.value.children.length < size) {
+    return
+  }
+  await nextTick()
+  elHeight.value = 0
+  Array.from(messageRef.value.children).slice(0, size).forEach((item) => {
+    elHeight.value = elHeight.value + item.clientHeight
+  })
 
+  // +130 主要是做了个补充
+  elHeight.value += 130
+}
+
+/**
+ * @description: 重新回答
+ */
+function reloadMessage() {
+  emit('reloadMessage')
+}
+
+onMounted(async () => {
+  scrollToBottom()
 })
 </script>
 
 <template>
-  <div ref="messageRef" class="app-message">
-    <div class="content-wrap">
-      <div v-for="(item, index) in list" :key="item.time + index" class="item">
-        <div v-if="item.messageType === MessageTypeEnum.USER" class="user">
-          <div class="content">
-            {{ item.content }}
-          </div>
-          <img class="icon-user" src="@/assets/images/conversation/user.png" alt="">
+  <div ref="messageRef" class="app-message" @scroll="onScroll">
+    <div v-for="(item, index) in list" :key="item.time + index" class="item">
+      <div v-if="item.messageType === MessageTypeEnum.USER" class="user">
+        <div class="content">
+          {{ item.content }}
         </div>
-        <div v-if="item.messageType === MessageTypeEnum.AI" class="ai">
-          <img class="icon-ai" src="@/assets/images/conversation/logo.png" alt="">
-          <div class="content">
-            {{ item.content }}
-          </div>
-          <div v-if="item.messageStatus === MessageStatusEnum.ERROR" class="error">
-            哎呀,出问题了...
-          </div>
-          <div
-            v-if="item.messageStatus === MessageStatusEnum.ACTICON"
-            class="loading"
-          >
-            正在回答中...
+        <img class="icon-user" src="@/assets/images/conversation/user.png" alt="">
+      </div>
+      <div v-if="item.messageType === MessageTypeEnum.AI" class="ai">
+        <img class="icon-ai" src="@/assets/images/conversation/logo.png" alt="">
+        <div class="content">
+          {{ item.content }}
+        </div>
+        <div v-if="item.messageStatus === MessageStatusEnum.ERROR" class="error">
+          哎呀,出问题了...
+        </div>
+        <div
+          v-if="item.messageStatus === MessageStatusEnum.ACTICON"
+          class="stop"
+        >
+          <SvgIcon class="icon" name="stop"></SvgIcon>
+          停止回答
+        </div>
+        <div v-if="item.messageStatus === MessageStatusEnum.END" class="btns">
+          <div class="copy" @click="copyText(item.content)">
+            <SvgIcon class="icon" name="copy"></SvgIcon>
+            复制
           </div>
-          <div v-if="item.messageStatus === MessageStatusEnum.END" class="btns">
-            <div class="copy" @click="copyText(item.content)">
-              复制
-            </div>
-            <div v-if="index === 0" class="reload" @click="reloadMessage">
-              重新回答
-            </div>
+          <div v-if="index === list.length - 1" class="reload" @click="reloadMessage">
+            <SvgIcon class="icon" name="again"></SvgIcon>
+            重新回答
           </div>
         </div>
       </div>
@@ -56,80 +140,77 @@ onMounted(async () => {
 @include app('message') {
   height: calc(100% - 120px);
   overflow: auto;
-
-  .content-wrap {
-    box-sizing: border-box;
-    transform: rotate(180deg);
-    .item {
-      padding: 0 15px;
-      margin-top: 15px;
-      transform: rotate(180deg);
-      .user,
-      .ai {
-        display: flex;
+  box-sizing: border-box;
+  .item {
+    padding: 0 15px;
+    margin-top: 15px;
+    .user,
+    .ai {
+      display: flex;
+    }
+    .user {
+      justify-content: flex-end;
+      .content {
+        max-width: calc(100% - 110px);
+        color: #ffffff;
+        padding: 10px 15px;
+        background: linear-gradient(131deg, #009bfc 0%, #00eadb 100%);
+        border-radius: 15px 15px 0px 15px;
       }
-      .user {
-        justify-content: flex-end;
-        .content {
-          color: #ffffff;
-          padding: 10px 15px;
-          background: linear-gradient(131deg, #009bfc 0%, #00eadb 100%);
-          border-radius: 15px 15px 0px 15px;
-        }
-        .icon-user {
-          width: 40px;
-          height: 40px;
-          margin-left: 15px;
-        }
+      .icon-user {
+        width: 40px;
+        height: 40px;
+        margin-left: 15px;
+      }
+    }
+    .ai {
+      position: relative;
+      margin-bottom: 15px;
+      display: flex;
+      flex-wrap: wrap;
+      .icon-ai {
+        width: 40px;
+        height: 40px;
+        margin-right: 15px;
+      }
+      .icon {
+        margin-right: 5px;
+      }
+      .content {
+        width: calc(100% - 110px);
+        padding: 10px 15px;
+        background: #ffffff;
+        border-radius: 0px 15px 15px 15px;
+        border: 1px solid #e7edef;
       }
-      .ai {
-        position: relative;
-        margin-bottom: 15px;
+      .error {
+        color: red;
+        height: 30px;
+        padding-left: 60px;
+        margin-top: 10px;
+      }
+      .stop {
+        height: 30px;
+        color: #4670e3;
+        padding-left: 60px;
+        margin-top: 10px;
+        cursor: pointer;
+      }
+      .btns {
+        width: 100%;
+        height: 30px;
+        padding-left: 60px;
+        margin: 10px auto 0 auto;
         display: flex;
-        flex-wrap: wrap;
-        .icon-ai {
-          width: 40px;
-          height: 40px;
-          margin-right: 15px;
-        }
-        .content {
-          width: calc(100% - 55px);
-          padding: 10px 15px;
-          background: #ffffff;
-          border-radius: 0rpx 15px 15px 15px;
-          border: 1px solid #e7edef;
-        }
-        .error {
-          color: red;
-          height: 30px;
-          padding-left: 60px;
-          margin: 10px auto 0 auto;
-        }
-        .loading {
-          height: 30px;
-          color: #009dfb;
-          padding-left: 60px;
-          margin: 20rpx auto 0 auto;
+        align-items: center;
+        .copy {
+          color: #4670e3;
+          margin-right: 20px;
+          cursor: pointer;
         }
-        .btns {
-          width: 100%;
-          height: 30px;
-          padding-left: 60px;
-          margin: 10px auto 0 auto;
-          display: flex;
-          justify-content: space-between;
-          align-items: center;
-          .copy {
-            color: #009dfb;
-            padding: 5px 15px;
-            background: #c1effb;
-            border-radius: 15px;
-            text-align: center;
-            margin-right: 20px;
-          }
-          .reload {
-            color: #009dfb;
-          }
+        .reload {
+          color: #4670e3;
+          cursor: pointer;
         }
       }
     }
diff --git a/src/design/public.scss b/src/design/public.scss
index 805c4f7..d8f0547 100644
--- a/src/design/public.scss
+++ b/src/design/public.scss
@@ -12,19 +12,14 @@
     height: 8px;
 }
 
-// ::-webkit-scrollbar-track {
-//   background: transparent;
-// }
-
 ::-webkit-scrollbar-track {
-    background-color: rgba($color: #000000, $alpha: 0.1);
+    background-color: rgba($color: #eeeeee, $alpha: 0.6);
 }
 
 ::-webkit-scrollbar-thumb {
-    background-color: rgba($color: #bdc1c94d, $alpha: 0.2);
+    background-color: rgba($color: #cccccc, $alpha: 0.4);
     border-radius: 2px;
-    box-shadow: inset 0 0 6px rgba($color: #000000, $alpha: 0.2);
-    cursor: pointer;
+    // box-shadow: inset 0 0 6px rgba($color: #000000, $alpha: 0.2);
 }
 
 ::-webkit-scrollbar-thumb:hover {
diff --git a/src/enums/messageEnum.ts b/src/enums/messageEnum.ts
index 6e191d2..7755b53 100644
--- a/src/enums/messageEnum.ts
+++ b/src/enums/messageEnum.ts
@@ -2,7 +2,7 @@
  * @description: 消息类型:ai/user
  */
 export enum MessageTypeEnum {
-  AI = 'ai',
+  AI = 'bot',
   USER = 'user',
 }
 
diff --git a/src/hooks/useMqtt.ts b/src/hooks/useMqtt.ts
index c5e8572..7c5742b 100644
--- a/src/hooks/useMqtt.ts
+++ b/src/hooks/useMqtt.ts
@@ -3,6 +3,7 @@ import { MqttService } from '@/utils/mqtt'
 import { useUserStore } from '@/store/moules/userStore/index'
 import { useMessageStore } from '@/store/moules/messageStore/index'
 import { MessageStatusEnum, MessageTypeEnum } from '@/enums/messageEnum'
+import { sendMessage as sendMessageApi } from '@/api/base/message'
 
 export function useMqtt() {
   const userStore = useUserStore()
@@ -30,11 +31,9 @@ export function useMqtt() {
       .subscribe(topicKey)
       .then(() => {
         mqttService.onMessage(topicKey, (messageData: any) => {
-          console.log(messageData)
-
           if (messageData.message_type === MessageStatusEnum.ACTICON) {
             messageStore.setMessageStatus(MessageStatusEnum.ACTICON)
-            messageStore.setMessageFirstItem({
+            messageStore.setMessageLastItem({
               messageType: MessageTypeEnum.AI,
               content: messageData.message_content,
               time: String(new Date().getTime()),
@@ -44,7 +43,7 @@ export function useMqtt() {
           }
           if (messageData.message_type === MessageStatusEnum.END) {
             messageStore.setMessageStatus(MessageStatusEnum.END)
-            messageStore.setMessageFirstItem({
+            messageStore.setMessageLastItem({
               messageType: MessageTypeEnum.AI,
               content: messageData.message_content,
               time: String(new Date().getTime()),
@@ -54,9 +53,6 @@ export function useMqtt() {
           }
         })
       })
-      .catch(() => {
-        // uni.hideLoading()
-      })
   }
 
   /**
@@ -79,8 +75,61 @@ export function useMqtt() {
     })
   }
 
+  /**
+   * @description: 取消订阅
+   */
+  const unsubscribe = (topicKey: string) => {
+    mqttService.unsubscribe(topicKey)
+  }
+
+  /**
+   * @description: 关闭连接
+   */
+  const end = () => {
+    console.log('123123123123')
+
+    mqttService.end()
+  }
+
+  /**
+   * @description: 发送消息hook
+   */
+  const sendMessage = async (roleId: number, conversationId: string, question: string): Promise<void> => {
+    messageStore.setMessageStatus(MessageStatusEnum.LOADING)
+    messageStore.setMessagePushItem({
+      messageType: MessageTypeEnum.USER,
+      content: question,
+      time: String(new Date().getTime()),
+      avatar: '',
+    })
+    messageStore.setMessagePushItem({
+      messageType: MessageTypeEnum.AI,
+      content: '正在思考中...',
+      time: String(new Date().getTime()),
+      avatar: '',
+      messageStatus: MessageStatusEnum.LOADING,
+    })
+    if (!messageStore.getConversationData) {
+      return
+    }
+
+    try {
+      await sendMessageApi({
+        roleId,
+        conversationId,
+        question,
+      })
+    }
+    catch (error: any) {
+      messageStore.setMessageStatus(MessageStatusEnum.END)
+    }
+  }
+
   return {
     connect,
     subscribe,
+    unsubscribe,
+    end,
+    sendMessage,
   }
 }
diff --git a/src/store/moules/messageStore/index.ts b/src/store/moules/messageStore/index.ts
index 91dcbd1..976cdc6 100644
--- a/src/store/moules/messageStore/index.ts
+++ b/src/store/moules/messageStore/index.ts
@@ -39,10 +39,14 @@ export const useMessageStore = defineStore('useMessageStore', {
       this.messageList.push(item)
     },
 
-    setMessageFirstItem(item: MessageItem) {
+    setMessageUnshiftItem(item: MessageItem) {
       this.messageList.unshift(item)
     },
 
+    setMessageLastItem(item: MessageItem) {
+      this.messageList[this.messageList.length - 1] = item
+    },
+
     setTopicKey(key: string) {
       this.topicKey = key
     },
diff --git a/src/utils/copyTextToClipboard.ts b/src/utils/copyTextToClipboard.ts
new file mode 100644
index 0000000..5c5ddc3
--- /dev/null
+++ b/src/utils/copyTextToClipboard.ts
@@ -0,0 +1,38 @@
+import { message } from 'ant-design-vue'
+
+export function copyText(text: string, prompt: string | null = '已成功复制到剪切板!') {
+  // 浏览器禁用了非安全域的 navigator.clipboard 对象
+  // 在线上环境会报错 TypeError: Cannot read properties of undefined (reading 'writeText')
+  if (navigator.clipboard && window.isSecureContext) {
+    navigator.clipboard.writeText(text).then(
+      () => {
+        prompt && message.success(prompt)
+      },
+      (error: Error) => {
+        message.error(`复制失败!${error.message}`)
+      },
+    )
+  }
+  else {
+    const textarea = document.createElement('textarea')
+    textarea.value = text
+    document.body.appendChild(textarea)
+    textarea.select()
+
+    try {
+    // 尝试执行复制操作
+      const success = document.execCommand('copy')
+      if (success) {
+        message.success('复制成功')
+      }
+      else {
+        message.error('复制失败')
+      }
+    }
+    catch (error) {
+      message.error(`复制失败:${error}`)
+    }
+
+    document.body.removeChild(textarea)
+  }
+}
diff --git a/src/utils/mqtt.ts b/src/utils/mqtt.ts
index 195758e..901062c 100644
--- a/src/utils/mqtt.ts
+++ b/src/utils/mqtt.ts
@@ -1,4 +1,5 @@
 import mqtt from 'mqtt'
+import { message } from 'ant-design-vue'
 
 export interface Options {
   host: string
@@ -24,13 +25,11 @@ export class MqttService {
       const { protocol, host, port } = this.options
       const connectUrl = `${protocol}://${host}:${port}/mqtt`
       this.client = mqtt.connect(connectUrl, this.options)
-      console.log(this.client)
       this.client.on('connect', () => {
-        console.log('连接成功')
         resolve(true)
       })
       this.client.on('error', (error: any) => {
-        console.log('连接失败!', error)
+        message.error('连接失败!')
         reject(error)
       })
     })
@@ -42,13 +41,12 @@ export class MqttService {
     }
 
     return new Promise((resolve, reject) => {
-      this.client.subscribe(topic, (err: any) => {
+      this.client?.subscribe(topic, (err: any) => {
         if (err) {
-          console.log('订阅失败!', err)
+          message.error('订阅失败!请稍后重试')
           reject(err)
         }
         else {
-          console.log('订阅成功!', topic)
           resolve(true)
         }
       })
@@ -76,7 +74,7 @@ export class MqttService {
 
     this.client.unsubscribe(topic, (err: any) => {
       if (err) {
-        console.log('取消订阅失败!', err)
+        message.error('取消订阅失败!!请联系管理员')
       }
       else {
         console.log('取消订阅成功!', topic)
diff --git a/src/views/conversation/index.vue b/src/views/conversation/index.vue
index 5784e56..516b689 100644
--- a/src/views/conversation/index.vue
+++ b/src/views/conversation/index.vue
@@ -1,6 +1,7 @@
 <script setup lang="ts">
-import { computed, onMounted, ref } from 'vue'
-import { Button } from 'ant-design-vue'
+import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
+import { onBeforeRouteLeave } from 'vue-router'
+import { Button, message } from 'ant-design-vue'
 import { AppContainerBox } from '@/components/AppContainerBox'
 import { AppSubMenuTitle } from '@/components/AppSubMenuTitle'
 import { AppSubMenuList } from '@/components/AppSubMenuList'
@@ -11,7 +12,7 @@ import { AppMessage } from '@/components/AppMessage'
 import type { MessageItem } from '@/components/AppMessage/index.d'
 import { useMessageStore } from '@/store/moules/messageStore/index'
 import { MessageStatusEnum, MessageTypeEnum } from '@/enums/messageEnum'
-import { conversationList, historyMessage, sendMessage } from '@/api/base/message'
+import { conversationList, historyMessage } from '@/api/base/message'
 import { useMqtt } from '@/hooks/useMqtt'
 
 const messageStore = useMessageStore()
@@ -19,10 +20,12 @@ const messageStore = useMessageStore()
 const sendBtnLoading = ref(false)
 const subMenuActive = ref(0)
 const subMenuList = ref<SubMenuItem[]>([])
+const appMessage = ref()
 const messageList = computed(() => messageStore.getMessageList)
+const messageStatus = computed(() => messageStore.getMessageStatus)
 const conversationData = computed(() => messageStore.getConversationData)
 const historyMessageParams = ref({
-  conversationId: conversationData.value ? conversationData.value.id : '',
+  conversationId: '',
   current: 1,
   size: 10,
   total: 0,
@@ -30,40 +33,33 @@ const historyMessageParams = ref({
 
 const conversationDefaultShow = ref(true)
 
+watch(
+  () => messageStatus.value,
+  (val) => {
+    if (val === MessageStatusEnum.END) {
+      sendBtnLoading.value = false
+    }
+  },
+)
+
 function handleSubMenuChange(index: number) {
+  if (messageStatus.value !== MessageStatusEnum.END) {
+    message.warn('请先结束对话')
+    return
+  }
   subMenuActive.value = index
 }
 
+/**
+ * @description: 发送消息
+ */
 function handleSend(value: string) {
-  console.log('handleSend', value)
   sendBtnLoading.value = true
   conversationDefaultShow.value = false
-  messageStore.setMessageStatus(MessageStatusEnum.LOADING)
-  messageStore.setMessageFirstItem({
-    messageType: MessageTypeEnum.USER,
-    content: value,
-    time: String(new Date().getTime()),
-    avatar: '',
-  })
-  messageStore.setMessageFirstItem({
-    messageType: MessageTypeEnum.AI,
-    content: '正在思考中...',
-    time: String(new Date().getTime()),
-    avatar: '',
-    messageStatus: MessageStatusEnum.LOADING,
-  })
   if (!messageStore.getConversationData) {
     return
   }
-  sendMessage({
-    roleId: messageStore.getConversationData.roleId,
-    conversationId: messageStore.getConversationData.id,
-    question: value,
-  }).then(() => {
-
-  }).catch(() => {
-    messageStore.setMessageStatus(MessageStatusEnum.END)
-  })
+  useMqtt().sendMessage(messageStore.getConversationData.roleId, messageStore.getConversationData.id, value)
 }
 
 /**
@@ -86,6 +82,10 @@ async function getConversationList() {
  * @description: 获取历史对话记录
  */
 async function getHistoryMessage() {
+  if (!conversationData.value) {
+    return
+  }
+  historyMessageParams.value.conversationId = conversationData.value.id
   const res = await historyMessage(historyMessageParams.value)
   if (!res || !res.records || !res.records.length) {
     return
@@ -99,14 +99,59 @@ async function getHistoryMessage() {
       messageStatus: MessageStatusEnum.END,
     }
     historyMessageParams.value.total = res.total
-    messageStore.setMessagePushItem(itemData)
+    messageStore.setMessageUnshiftItem(itemData)
   })
+
   conversationDefaultShow.value = false
 }
 
+/**
+ * @description: 滚动监听
+ */
+async function onScrollTop(scrollTop: number) {
+  if (scrollTop !== 0) {
+    return
+  }
+
+  if (historyMessageParams.value.current * historyMessageParams.value.size > historyMessageParams.value.total) {
+    return
+  }
+
+  if (historyMessageParams.value.current < historyMessageParams.value.total) {
+    historyMessageParams.value.current++
+    await getHistoryMessage()
+
+    // 无缝滚动
+    appMessage.value.seamlessScrollToTop()
+  }
+}
+
+/**
+ * @description: 重新回答
+ */
+function reloadMessage() {
+  if (!messageStore.getConversationData) {
+    return
+  }
+  const question = messageList.value[messageList.value.length - 2]?.content
+  useMqtt().sendMessage(messageStore.getConversationData.roleId, messageStore.getConversationData.id, question)
+}
+
 onMounted(() => {
   getConversationList()
 })
+
+onUnmounted(() => {
+  useMqtt().end()
+})
+
+// 路由离开时的操作
+onBeforeRouteLeave(() => {
+  if (messageStatus.value !== MessageStatusEnum.END) {
+    message.warn('请先结束对话')
+    return false
+  }
+})
 </script>
 
 <template>
@@ -139,7 +184,15 @@ onMounted(() => {
       </AppConversationDefault>
 
       <!-- 消息列表 -->
-      <AppMessage v-else :list="messageList"></AppMessage>
+      <AppMessage
+        v-else
+        ref="appMessage"
+        class="pl-27 pr-5"
+        :list="messageList"
+        @on-scroll-top="onScrollTop"
+        @reload-message="reloadMessage"
+      >
+      </AppMessage>
 
       <!-- 发送框 -->
       <AppTextarea