Browse Source

优化ai助手体验

cq.tianyong 2 hours ago
parent
commit
b5e8800869
1 changed files with 157 additions and 33 deletions
  1. 157 33
      src/components/AIChat/index.vue

+ 157 - 33
src/components/AIChat/index.vue

@@ -75,7 +75,7 @@
                 size="mini"
                 @click.stop
               >
-                <template #icon><icon-more /></template>
+                <template #icon><icon-delete /></template>
               </a-button>
             </a-popconfirm>
           </div>
@@ -106,11 +106,11 @@
             </div>
           </div>
 
-          <a-popconfirm v-if="canDeleteCurrentConversation" content="是否删除该对话?" @ok="deleteCurrentConversation">
-            <a-button type="text" shape="circle" status="danger">
-              <template #icon><icon-delete /></template>
+          <a-tooltip v-if="isMobile" content="关闭">
+            <a-button type="text" shape="circle" class="chat-toolbar__close" @click="close">
+              <template #icon><icon-close /></template>
             </a-button>
-          </a-popconfirm>
+          </a-tooltip>
         </div>
 
         <div ref="messageContainer" class="message-list">
@@ -168,7 +168,13 @@
                 </div>
 
                 <div class="message-bubble">
-                  <div v-if="message.content" class="message-content" v-html="renderMarkdown(message.content)"></div>
+                  <div v-if="message.thinking" class="thinking-indicator">
+                    <span>思考中</span>
+                    <i></i>
+                    <i></i>
+                    <i></i>
+                  </div>
+                  <div v-else-if="message.content" class="message-content" v-html="renderMarkdown(message.content)"></div>
                   <div v-if="message.images?.length" class="message-images">
                     <a-image
                       v-for="url in message.images"
@@ -210,7 +216,7 @@
               :max-length="1000"
               :auto-size="{ minRows: 1, maxRows: 5 }"
               allow-clear
-              @keydown.enter.exact.prevent="sendMessage"
+              @keydown.enter.exact.prevent="sendMessage()"
             />
             <div class="composer__actions">
               <div class="composer__left">
@@ -221,7 +227,7 @@
                 </a-tooltip>
               </div>
               <div class="composer__right">
-                <a-button type="primary" shape="circle" :loading="sending" :disabled="!canSend" class="composer__send" @click="sendMessage">
+                <a-button type="primary" shape="circle" :loading="sending" :disabled="!canSend" class="composer__send" @click="sendMessage()">
                   <template #icon><icon-arrow-up /></template>
                 </a-button>
               </div>
@@ -311,6 +317,44 @@ const formatShortTime = (time) => {
   if (!time) return ''
   return new Date(Number(time)).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
 }
+const getLastRemoteMessageId = () => {
+  const remoteMessage = [...messages.value].reverse().find(item => !item.local && Number(item.id) > 0)
+  return remoteMessage?.id || 0
+}
+const removeThinkingMessages = () => {
+  messages.value = messages.value.filter(item => !item.thinking)
+}
+const normalizeMessageImages = (images = []) => JSON.stringify([...(images || [])].sort())
+const isSameOptimisticUserMessage = (incoming, localMessage) => {
+  return Boolean(
+    incoming?.role === 'user' &&
+    localMessage?.local &&
+    localMessage.role === 'user' &&
+    localMessage.sendStatus !== 'failed' &&
+    String(incoming.content || '') === String(localMessage.content || '') &&
+    normalizeMessageImages(incoming.images) === normalizeMessageImages(localMessage.images)
+  )
+}
+const mergeIncomingMessages = (incoming = []) => {
+  const nextIncoming = []
+  incoming.forEach((item) => {
+    if (messages.value.some(message => Number(message.id) === Number(item.id))) return
+    const localIndex = messages.value.findIndex(message => isSameOptimisticUserMessage(item, message))
+    if (localIndex !== -1) {
+      messages.value[localIndex] = {
+        ...item,
+        sendStatus: 'sent'
+      }
+      return
+    }
+    nextIncoming.push(item)
+  })
+  if (nextIncoming.some(item => item.role === 'assistant' || item.role === 'system')) {
+    removeThinkingMessages()
+  }
+  if (nextIncoming.length) messages.value.push(...nextIncoming)
+  return nextIncoming.length > 0
+}
 
 const loadSeen = () => storage.get(seenKey.value) || {}
 const saveSeen = (seen) => storage.set(seenKey.value, seen)
@@ -384,7 +428,7 @@ const loadMessages = async () => {
 
 const pollNewMessages = async () => {
   if (!currentConversationId.value || !visible.value || isLocalConversation(currentConversationId.value)) return
-  const lastId = messages.value[messages.value.length - 1]?.id || 0
+  const lastId = getLastRemoteMessageId()
   const res = await listAIChatMessages({ conversation_id: currentConversationId.value, after_id: lastId })
   if (!res || res.code !== 0) return
   const incoming = res.data || []
@@ -392,7 +436,8 @@ const pollNewMessages = async () => {
   const localIds = new Set(messages.value.filter(item => item.local).map(item => item.id))
   const filteredIncoming = incoming.filter(item => !localIds.has(item.client_id))
   if (!filteredIncoming.length) return
-  messages.value.push(...filteredIncoming)
+  const hasNewMessages = mergeIncomingMessages(filteredIncoming)
+  if (!hasNewMessages) return
   // if (hasAssistant) Notification.info({ title: '小妍助理有新回复', content: incoming[incoming.length - 1]?.content || '收到新消息' })
   markCurrentSeen()
   scrollToBottom()
@@ -514,6 +559,7 @@ const sendMessage = async (overrideContent = '') => {
   const content = presetContent || input.value.trim()
   const images = uploadingImages.value.map(item => item.url)
   const localMessageId = `local-message-${Date.now()}`
+  const thinkingMessageId = `local-thinking-${Date.now()}`
   const now = Date.now()
   const optimisticMessage = {
     id: localMessageId,
@@ -528,10 +574,23 @@ const sendMessage = async (overrideContent = '') => {
     update_time: now,
     local: true
   }
+  const thinkingMessage = {
+    id: thinkingMessageId,
+    conversation_id: previousConversationId,
+    role: 'assistant',
+    content: '',
+    images: [],
+    status: 'thinking',
+    error_msg: '',
+    create_time: now + 1,
+    update_time: now + 1,
+    thinking: true,
+    local: true
+  }
 
   input.value = ''
   uploadingImages.value = []
-  messages.value.push(optimisticMessage)
+  messages.value.push(optimisticMessage, thinkingMessage)
   const current = currentConversation.value
   if (current) {
     current.last_message_preview = content || (images.length ? `[图片] x${images.length}` : '')
@@ -556,6 +615,7 @@ const sendMessage = async (overrideContent = '') => {
         localMessage.sendStatus = 'failed'
         localMessage.error_msg = res?.msg || '发送失败'
       }
+      messages.value = messages.value.filter(item => item.id !== thinkingMessageId)
       input.value = content
       uploadingImages.value = images.map(url => ({ id: `${Date.now()}-${Math.random()}`, url }))
       return
@@ -563,6 +623,8 @@ const sendMessage = async (overrideContent = '') => {
     if (res.data?.conversation) {
       upsertConversation(res.data.conversation, previousConversationId)
       currentConversationId.value = res.data.conversation.id
+      const thinkingMessage = messages.value.find(item => item.id === thinkingMessageId)
+      if (thinkingMessage) thinkingMessage.conversation_id = res.data.conversation.id
     }
     const localIndex = messages.value.findIndex(item => item.id === localMessageId)
     if (localIndex !== -1) {
@@ -571,6 +633,10 @@ const sendMessage = async (overrideContent = '') => {
         sendStatus: 'sent'
       }
     }
+    const thinkingIndex = messages.value.findIndex(item => item.id === thinkingMessageId)
+    if (thinkingIndex !== -1 && Array.isArray(res.data?.assistant_messages) && res.data.assistant_messages.length) {
+      messages.value.splice(thinkingIndex, 1, ...res.data.assistant_messages)
+    }
     await loadConversations()
     markCurrentSeen()
     scrollToBottom()
@@ -744,28 +810,6 @@ onUnmounted(() => {
   overflow-y: auto;
   overscroll-behavior: contain;
   padding: 0 12px 18px;
-  scrollbar-width: thin;
-  scrollbar-color: rgba(143, 118, 255, 0.42) transparent;
-
-  &::-webkit-scrollbar {
-    width: 8px;
-  }
-
-  &::-webkit-scrollbar-track {
-    background: transparent;
-  }
-
-  &::-webkit-scrollbar-thumb {
-    border: 2px solid transparent;
-    border-radius: 999px;
-    background: rgba(143, 118, 255, 0.34);
-    background-clip: content-box;
-  }
-
-  &::-webkit-scrollbar-thumb:hover {
-    background: rgba(143, 118, 255, 0.58);
-    background-clip: content-box;
-  }
 }
 
 .conversation-card {
@@ -951,6 +995,42 @@ onUnmounted(() => {
   padding: 22px 22px 18px;
 }
 
+.conversation-list,
+.message-list {
+  scrollbar-width: thin;
+  scrollbar-color: rgba(143, 118, 255, 0.5) rgba(244, 247, 255, 0.55);
+
+  &::-webkit-scrollbar {
+    width: 10px;
+    height: 10px;
+  }
+
+  &::-webkit-scrollbar-track {
+    border-radius: 999px;
+    background: rgba(244, 247, 255, 0.55);
+  }
+
+  &::-webkit-scrollbar-thumb {
+    min-height: 44px;
+    border: 3px solid rgba(244, 247, 255, 0.9);
+    border-radius: 999px;
+    background:
+      linear-gradient(180deg, rgba(198, 171, 255, 0.95), rgba(143, 118, 255, 0.72));
+    background-clip: padding-box;
+    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
+  }
+
+  &::-webkit-scrollbar-thumb:hover {
+    background:
+      linear-gradient(180deg, rgba(178, 145, 255, 1), rgba(116, 89, 255, 0.86));
+    background-clip: padding-box;
+  }
+
+  &::-webkit-scrollbar-corner {
+    background: transparent;
+  }
+}
+
 .message-loading {
   display: flex;
   justify-content: center;
@@ -1106,6 +1186,36 @@ onUnmounted(() => {
   word-break: break-word;
 }
 
+.thinking-indicator {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  color: #4e5969;
+  font-size: 13px;
+  line-height: 1.4;
+
+  span {
+    margin-right: 2px;
+  }
+
+  i {
+    width: 5px;
+    height: 5px;
+    border-radius: 50%;
+    background: #8f7cff;
+    opacity: 0.35;
+    animation: ai-chat-thinking 1.2s ease-in-out infinite;
+
+    &:nth-of-type(2) {
+      animation-delay: 0.16s;
+    }
+
+    &:nth-of-type(3) {
+      animation-delay: 0.32s;
+    }
+  }
+}
+
 .message-status {
   display: inline-flex;
   align-items: center;
@@ -1311,6 +1421,20 @@ onUnmounted(() => {
   }
 }
 
+@keyframes ai-chat-thinking {
+  0%,
+  80%,
+  100% {
+    opacity: 0.35;
+    transform: translateY(0);
+  }
+
+  40% {
+    opacity: 1;
+    transform: translateY(-3px);
+  }
+}
+
 @media (max-width: 768px) {
   .ai-chat-shell {
     grid-template-columns: 1fr;