Browse Source

优化ai助手部分功能

cq.tianyong 6 hours ago
parent
commit
83a2e5220a
4 changed files with 511 additions and 133 deletions
  1. 0 39
      package-lock.json
  2. 0 8
      src/api/aiChat.js
  3. 511 85
      src/components/AIChat/index.vue
  4. 0 1
      src/pages/admin/aiChat/list.vue

+ 0 - 39
package-lock.json

@@ -686,9 +686,6 @@
         "arm"
         "arm"
       ],
       ],
       "dev": true,
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "license": "MIT",
       "optional": true,
       "optional": true,
       "os": [
       "os": [
@@ -703,9 +700,6 @@
         "arm"
         "arm"
       ],
       ],
       "dev": true,
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "license": "MIT",
       "optional": true,
       "optional": true,
       "os": [
       "os": [
@@ -720,9 +714,6 @@
         "arm64"
         "arm64"
       ],
       ],
       "dev": true,
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "license": "MIT",
       "optional": true,
       "optional": true,
       "os": [
       "os": [
@@ -737,9 +728,6 @@
         "arm64"
         "arm64"
       ],
       ],
       "dev": true,
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "license": "MIT",
       "optional": true,
       "optional": true,
       "os": [
       "os": [
@@ -754,9 +742,6 @@
         "loong64"
         "loong64"
       ],
       ],
       "dev": true,
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "license": "MIT",
       "optional": true,
       "optional": true,
       "os": [
       "os": [
@@ -771,9 +756,6 @@
         "loong64"
         "loong64"
       ],
       ],
       "dev": true,
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "license": "MIT",
       "optional": true,
       "optional": true,
       "os": [
       "os": [
@@ -788,9 +770,6 @@
         "ppc64"
         "ppc64"
       ],
       ],
       "dev": true,
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "license": "MIT",
       "optional": true,
       "optional": true,
       "os": [
       "os": [
@@ -805,9 +784,6 @@
         "ppc64"
         "ppc64"
       ],
       ],
       "dev": true,
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "license": "MIT",
       "optional": true,
       "optional": true,
       "os": [
       "os": [
@@ -822,9 +798,6 @@
         "riscv64"
         "riscv64"
       ],
       ],
       "dev": true,
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "license": "MIT",
       "optional": true,
       "optional": true,
       "os": [
       "os": [
@@ -839,9 +812,6 @@
         "riscv64"
         "riscv64"
       ],
       ],
       "dev": true,
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "license": "MIT",
       "optional": true,
       "optional": true,
       "os": [
       "os": [
@@ -856,9 +826,6 @@
         "s390x"
         "s390x"
       ],
       ],
       "dev": true,
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "license": "MIT",
       "optional": true,
       "optional": true,
       "os": [
       "os": [
@@ -873,9 +840,6 @@
         "x64"
         "x64"
       ],
       ],
       "dev": true,
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "license": "MIT",
       "optional": true,
       "optional": true,
       "os": [
       "os": [
@@ -890,9 +854,6 @@
         "x64"
         "x64"
       ],
       ],
       "dev": true,
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "license": "MIT",
       "optional": true,
       "optional": true,
       "os": [
       "os": [

+ 0 - 8
src/api/aiChat.js

@@ -17,14 +17,6 @@ export function listAIChatConversations(parameter) {
   })
   })
 }
 }
 
 
-export function createAIChatConversation(parameter) {
-  return request({
-    url: api.Conversation,
-    method: 'post',
-    data: parameter
-  })
-}
-
 export function deleteAIChatConversation(parameter) {
 export function deleteAIChatConversation(parameter) {
   return request({
   return request({
     url: api.Conversation,
     url: api.Conversation,

+ 511 - 85
src/components/AIChat/index.vue

@@ -12,7 +12,7 @@
     <template #title>
     <template #title>
       <div class="ai-chat-title">
       <div class="ai-chat-title">
         <a-avatar :size="36" class="ai-chat-title__avatar">
         <a-avatar :size="36" class="ai-chat-title__avatar">
-          <img src="@/assets/img/ai.png" alt="小妍助理" />
+          <img src="https://lepao-api.xxoo365.top/uploads/9aca0644-f1f8-4773-b25e-4cd5d268cf0e.png" alt="小妍助理" />
         </a-avatar>
         </a-avatar>
         <div class="ai-chat-title__copy">
         <div class="ai-chat-title__copy">
           <div class="ai-chat-title__name">小妍助理</div>
           <div class="ai-chat-title__name">小妍助理</div>
@@ -25,6 +25,11 @@
     </template>
     </template>
 
 
     <div class="ai-chat-shell" @paste="handlePaste">
     <div class="ai-chat-shell" @paste="handlePaste">
+      <div
+        v-if="isMobile && showConversationPanel"
+        class="conversation-backdrop"
+        @click="showConversationPanel = false"
+      ></div>
       <aside class="conversation-panel" :class="{ 'conversation-panel--open': showConversationPanel }">
       <aside class="conversation-panel" :class="{ 'conversation-panel--open': showConversationPanel }">
         <div class="conversation-panel__head">
         <div class="conversation-panel__head">
           <div>
           <div>
@@ -39,27 +44,45 @@
         </div>
         </div>
 
 
         <div class="conversation-list">
         <div class="conversation-list">
-          <button
+          <div
             v-for="item in conversations"
             v-for="item in conversations"
             :key="item.id"
             :key="item.id"
-            class="conversation-item"
-            :class="{ 'conversation-item--active': item.id === currentConversationId }"
-            @click="openConversation(item.id)"
+            class="conversation-card"
           >
           >
-            <span class="conversation-item__top">
-              <span class="conversation-item__title">{{ item.title || '小妍助理对话' }}</span>
-              <span v-if="unreadMap[item.id]" class="conversation-item__dot"></span>
-            </span>
-            <span class="conversation-item__preview">{{ item.last_message_preview || '尚未开始' }}</span>
-            <span class="conversation-item__foot">
-              <span>{{ formatShortTime(item.update_time) || '新对话' }}</span>
-              <span v-if="item.conversation_no">NO.{{ item.conversation_no }}</span>
-            </span>
-          </button>
+            <button
+              class="conversation-item"
+              :class="{ 'conversation-item--active': item.id === currentConversationId, 'conversation-item--local': item.local }"
+              @click="openConversation(item.id)"
+            >
+              <span class="conversation-item__top">
+                <span class="conversation-item__title">{{ item.title || '新对话' }}</span>
+                <span v-if="unreadMap[item.id]" class="conversation-item__dot"></span>
+              </span>
+              <span class="conversation-item__preview">{{ item.last_message_preview || '尚未开始' }}</span>
+              <span class="conversation-item__foot">
+                <span>{{ formatShortTime(item.update_time) || '新对话' }}</span>
+              </span>
+            </button>
+            <a-popconfirm
+              v-if="canShowConversationActions(item)"
+              content="是否删除该对话?"
+              @ok="deleteConversationById(item.id)"
+            >
+              <a-button
+                class="conversation-card__more"
+                type="text"
+                shape="circle"
+                size="mini"
+                @click.stop
+              >
+                <template #icon><icon-more /></template>
+              </a-button>
+            </a-popconfirm>
+          </div>
 
 
           <div v-if="!conversations.length" class="conversation-empty">
           <div v-if="!conversations.length" class="conversation-empty">
             <a-avatar :size="42" class="conversation-empty__avatar">
             <a-avatar :size="42" class="conversation-empty__avatar">
-              <img src="@/assets/img/ai.png" alt="小妍助理" />
+              <img src="https://lepao-api.xxoo365.top/uploads/9aca0644-f1f8-4773-b25e-4cd5d268cf0e.png" alt="小妍助理" />
             </a-avatar>
             </a-avatar>
             <div>暂无会话</div>
             <div>暂无会话</div>
           </div>
           </div>
@@ -81,11 +104,11 @@
 
 
           <div class="chat-toolbar__identity">
           <div class="chat-toolbar__identity">
             <a-avatar :size="34" class="chat-toolbar__avatar">
             <a-avatar :size="34" class="chat-toolbar__avatar">
-              <img src="@/assets/img/ai.png" alt="小妍助理" />
+              <img src="https://lepao-api.xxoo365.top/uploads/9aca0644-f1f8-4773-b25e-4cd5d268cf0e.png" alt="小妍助理" />
             </a-avatar>
             </a-avatar>
             <div class="chat-toolbar__text">
             <div class="chat-toolbar__text">
               <strong>{{ currentConversation?.title || '新对话' }}</strong>
               <strong>{{ currentConversation?.title || '新对话' }}</strong>
-              <span>{{ currentConversation?.conversation_no ? `NO.${currentConversation.conversation_no}` : '准备开始' }}</span>
+              <!-- <span>{{ currentConversation?.conversation_no ? `NO.${currentConversation.conversation_no}` : '准备开始' }}</span>  -->
             </div>
             </div>
           </div>
           </div>
 
 
@@ -99,7 +122,7 @@
         <div ref="messageContainer" class="message-list">
         <div ref="messageContainer" class="message-list">
           <div v-if="!currentConversationId" class="empty-state">
           <div v-if="!currentConversationId" class="empty-state">
             <a-avatar :size="64" class="empty-state__avatar">
             <a-avatar :size="64" class="empty-state__avatar">
-              <img src="@/assets/img/ai.png" alt="小妍助理" />
+              <img src="https://lepao-api.xxoo365.top/uploads/9aca0644-f1f8-4773-b25e-4cd5d268cf0e.png" alt="小妍助理" />
             </a-avatar>
             </a-avatar>
             <div class="empty-state__title">开启一段新对话</div>
             <div class="empty-state__title">开启一段新对话</div>
           </div>
           </div>
@@ -111,12 +134,24 @@
 
 
             <div v-if="!messagesLoading && !messages.length" class="welcome-card">
             <div v-if="!messagesLoading && !messages.length" class="welcome-card">
               <a-avatar :size="52" class="welcome-card__avatar">
               <a-avatar :size="52" class="welcome-card__avatar">
-                <img src="@/assets/img/ai.png" alt="小妍助理" />
+                <img src="https://lepao-api.xxoo365.top/uploads/9aca0644-f1f8-4773-b25e-4cd5d268cf0e.png" alt="小妍助理" />
               </a-avatar>
               </a-avatar>
               <div>
               <div>
                 <div class="welcome-card__title">我是小妍助理</div>
                 <div class="welcome-card__title">我是小妍助理</div>
                 <div class="welcome-card__text">可以开始输入问题。</div>
                 <div class="welcome-card__text">可以开始输入问题。</div>
               </div>
               </div>
+              <div class="welcome-prompts">
+                <button
+                  v-for="prompt in examplePrompts"
+                  :key="prompt"
+                  class="welcome-prompt"
+                  type="button"
+                  :disabled="sending"
+                  @click="sendExamplePrompt(prompt)"
+                >
+                  {{ prompt }}
+                </button>
+              </div>
             </div>
             </div>
 
 
             <div
             <div
@@ -126,7 +161,8 @@
               :class="`message-row--${message.role}`"
               :class="`message-row--${message.role}`"
             >
             >
               <a-avatar :size="36" class="message-avatar">
               <a-avatar :size="36" class="message-avatar">
-                <img v-if="message.role === 'assistant'" src="@/assets/img/ai.png" alt="小妍助理" />
+                <img v-if="message.role === 'assistant'" src="https://lepao-api.xxoo365.top/uploads/9aca0644-f1f8-4773-b25e-4cd5d268cf0e.png" alt="小妍助理" />
+                <img v-else-if="message.role === 'user' && avatar" :src="avatar"/>
                 <icon-user v-else-if="message.role === 'user'" />
                 <icon-user v-else-if="message.role === 'user'" />
                 <icon-info-circle v-else />
                 <icon-info-circle v-else />
               </a-avatar>
               </a-avatar>
@@ -135,7 +171,6 @@
                 <div class="message-meta">
                 <div class="message-meta">
                   <span>{{ roleLabel(message.role) }}</span>
                   <span>{{ roleLabel(message.role) }}</span>
                   <span>{{ formatFullTime(message.create_time) }}</span>
                   <span>{{ formatFullTime(message.create_time) }}</span>
-                  <a-tag v-if="message.status === 'error'" size="small" color="red">异常</a-tag>
                 </div>
                 </div>
 
 
                 <div class="message-bubble">
                 <div class="message-bubble">
@@ -152,6 +187,12 @@
                   </div>
                   </div>
                   <div v-if="message.error_msg" class="message-error">{{ message.error_msg }}</div>
                   <div v-if="message.error_msg" class="message-error">{{ message.error_msg }}</div>
                 </div>
                 </div>
+                <div v-if="message.role === 'user'" class="message-status" :class="`message-status--${message.sendStatus || message.status || 'done'}`">
+                  <icon-loading v-if="message.sendStatus === 'sending'" />
+                  <icon-check-circle v-else-if="message.sendStatus === 'sent' || message.status === 'done'" />
+                  <icon-refresh v-else-if="message.sendStatus === 'failed'" />
+                  <span>{{ messageStatusText(message) }}</span>
+                </div>
               </div>
               </div>
             </div>
             </div>
           </template>
           </template>
@@ -169,22 +210,28 @@
         <div class="composer-wrap">
         <div class="composer-wrap">
           <div class="composer">
           <div class="composer">
             <input ref="fileInput" type="file" accept="image/png,image/jpeg,image/jpg,image/gif" multiple hidden @change="handleFileInput" />
             <input ref="fileInput" type="file" accept="image/png,image/jpeg,image/jpg,image/gif" multiple hidden @change="handleFileInput" />
-            <a-tooltip content="添加图片">
-              <a-button :loading="uploading" shape="circle" class="composer__tool" @click="fileInput?.click()">
-                <template #icon><icon-image /></template>
-              </a-button>
-            </a-tooltip>
             <a-textarea
             <a-textarea
               v-model="input"
               v-model="input"
-              placeholder="输入消息"
+              placeholder="请输入"
               :max-length="1000"
               :max-length="1000"
               :auto-size="{ minRows: 1, maxRows: 5 }"
               :auto-size="{ minRows: 1, maxRows: 5 }"
               allow-clear
               allow-clear
               @keydown.enter.exact.prevent="sendMessage"
               @keydown.enter.exact.prevent="sendMessage"
             />
             />
-            <a-button type="primary" shape="circle" :loading="sending" :disabled="!canSend" class="composer__send" @click="sendMessage">
-              <template #icon><icon-send /></template>
-            </a-button>
+            <div class="composer__actions">
+              <div class="composer__left">
+                <a-tooltip content="添加图片">
+                  <a-button :loading="uploading" type="text" class="composer__tool" @click="fileInput?.click()">
+                    <template #icon><icon-image /></template>
+                  </a-button>
+                </a-tooltip>
+              </div>
+              <div class="composer__right">
+                <a-button type="primary" shape="circle" :loading="sending" :disabled="!canSend" class="composer__send" @click="sendMessage">
+                  <template #icon><icon-arrow-up /></template>
+                </a-button>
+              </div>
+            </div>
           </div>
           </div>
         </div>
         </div>
       </main>
       </main>
@@ -200,7 +247,6 @@ import storage from 'store'
 import { useUserStore } from '@/store/modules/user'
 import { useUserStore } from '@/store/modules/user'
 import { uploadImg } from '@/api/upload'
 import { uploadImg } from '@/api/upload'
 import {
 import {
-  createAIChatConversation,
   deleteAIChatConversation,
   deleteAIChatConversation,
   listAIChatConversations,
   listAIChatConversations,
   listAIChatMessages,
   listAIChatMessages,
@@ -215,8 +261,16 @@ const props = defineProps({
 })
 })
 const emit = defineEmits(['close', 'unread-change'])
 const emit = defineEmits(['close', 'unread-change'])
 
 
+const user = ref(null)
 const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
 const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
 const userStore = useUserStore()
 const userStore = useUserStore()
+const MAX_UPLOAD_IMAGES = 3
+const examplePrompts = [
+  '登录器怎么下载?',
+  '乐跑账号绑定失败怎么办?',
+  '帮我添加一个乐跑账号',
+  '如何查看我的订单和售后进度?'
+]
 const isMobile = ref(window.innerWidth <= 768)
 const isMobile = ref(window.innerWidth <= 768)
 const showConversationPanel = ref(false)
 const showConversationPanel = ref(false)
 const conversations = ref([])
 const conversations = ref([])
@@ -228,6 +282,7 @@ const messagesLoading = ref(false)
 const sending = ref(false)
 const sending = ref(false)
 const uploading = ref(false)
 const uploading = ref(false)
 const dragging = ref(false)
 const dragging = ref(false)
+const assistantMode = ref('default')
 const messageContainer = ref(null)
 const messageContainer = ref(null)
 const fileInput = ref(null)
 const fileInput = ref(null)
 const unreadMap = ref({})
 const unreadMap = ref({})
@@ -240,8 +295,21 @@ const currentConversation = computed(() => conversations.value.find(item => item
 const canSend = computed(() => currentConversationId.value && (input.value.trim() || uploadingImages.value.length > 0))
 const canSend = computed(() => currentConversationId.value && (input.value.trim() || uploadingImages.value.length > 0))
 const unreadTotal = computed(() => Object.values(unreadMap.value).reduce((sum, value) => sum + Number(value || 0), 0))
 const unreadTotal = computed(() => Object.values(unreadMap.value).reduce((sum, value) => sum + Number(value || 0), 0))
 const seenKey = computed(() => `aiChatSeen:${userStore.uuid || 'guest'}`)
 const seenKey = computed(() => `aiChatSeen:${userStore.uuid || 'guest'}`)
+const avatar = computed(() => {
+  return userStore.avatar
+})
 
 
 const roleLabel = (role) => ({ user: '我', assistant: '小妍助理', system: '系统' }[role] || role)
 const roleLabel = (role) => ({ user: '我', assistant: '小妍助理', system: '系统' }[role] || role)
+const isLocalConversation = (id) => String(id || '').startsWith('local-')
+const canShowConversationActions = (conversation) => {
+  return Boolean(conversation?.id && !conversation.local && Number(conversation.last_message_time || 0) > 0)
+}
+const messageStatusText = (message) => {
+  if (message.sendStatus === 'sending') return '发送中'
+  if (message.sendStatus === 'failed') return '发送失败'
+  if (message.sendStatus === 'sent' || message.status === 'done') return '已发送'
+  return ''
+}
 const renderMarkdown = (text) => md.render(text || '')
 const renderMarkdown = (text) => md.render(text || '')
 const formatFullTime = (time) => time ? new Date(Number(time)).toLocaleString('zh-CN', { hour12: false }) : ''
 const formatFullTime = (time) => time ? new Date(Number(time)).toLocaleString('zh-CN', { hour12: false }) : ''
 const formatShortTime = (time) => {
 const formatShortTime = (time) => {
@@ -288,7 +356,8 @@ const scrollToBottom = () => {
 const loadConversations = async () => {
 const loadConversations = async () => {
   const res = await listAIChatConversations({ current: 1, pagesize: 30 })
   const res = await listAIChatConversations({ current: 1, pagesize: 30 })
   if (!res || res.code !== 0) return
   if (!res || res.code !== 0) return
-  conversations.value = res.data || []
+  const localItems = conversations.value.filter(item => item.local)
+  conversations.value = [...localItems, ...(res.data || [])]
   updateUnreadByConversations(conversations.value)
   updateUnreadByConversations(conversations.value)
   if (visible.value && !currentConversationId.value && conversations.value.length) {
   if (visible.value && !currentConversationId.value && conversations.value.length) {
     await openConversation(conversations.value[0].id)
     await openConversation(conversations.value[0].id)
@@ -297,6 +366,11 @@ const loadConversations = async () => {
 
 
 const loadMessages = async () => {
 const loadMessages = async () => {
   if (!currentConversationId.value) return
   if (!currentConversationId.value) return
+  if (isLocalConversation(currentConversationId.value)) {
+    messages.value = []
+    messagesLoading.value = false
+    return
+  }
   messagesLoading.value = true
   messagesLoading.value = true
   try {
   try {
     const res = await listAIChatMessages({ conversation_id: currentConversationId.value, current: 1, pagesize: 80 })
     const res = await listAIChatMessages({ conversation_id: currentConversationId.value, current: 1, pagesize: 80 })
@@ -310,15 +384,17 @@ const loadMessages = async () => {
 }
 }
 
 
 const pollNewMessages = async () => {
 const pollNewMessages = async () => {
-  if (!currentConversationId.value || !visible.value) return
+  if (!currentConversationId.value || !visible.value || isLocalConversation(currentConversationId.value)) return
   const lastId = messages.value[messages.value.length - 1]?.id || 0
   const lastId = messages.value[messages.value.length - 1]?.id || 0
   const res = await listAIChatMessages({ conversation_id: currentConversationId.value, after_id: lastId })
   const res = await listAIChatMessages({ conversation_id: currentConversationId.value, after_id: lastId })
   if (!res || res.code !== 0) return
   if (!res || res.code !== 0) return
   const incoming = res.data || []
   const incoming = res.data || []
   if (!incoming.length) return
   if (!incoming.length) return
-  messages.value.push(...incoming)
-  const hasAssistant = incoming.some(item => item.role === 'assistant' || item.role === 'system')
-  if (hasAssistant) Notification.info({ title: '小妍助理有新回复', content: incoming[incoming.length - 1]?.content || '收到新消息' })
+  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)
+  // if (hasAssistant) Notification.info({ title: '小妍助理有新回复', content: incoming[incoming.length - 1]?.content || '收到新消息' })
   markCurrentSeen()
   markCurrentSeen()
   scrollToBottom()
   scrollToBottom()
   await loadConversations()
   await loadConversations()
@@ -331,31 +407,66 @@ const openConversation = async (id) => {
 }
 }
 
 
 const createConversationAndOpen = async () => {
 const createConversationAndOpen = async () => {
-  const blank = conversations.value.find(item => Number(item.last_message_time || 0) === 0)
+  const blank = conversations.value.find(item => item.local || Number(item.last_message_time || 0) === 0)
   if (blank) {
   if (blank) {
     await openConversation(blank.id)
     await openConversation(blank.id)
     return
     return
   }
   }
 
 
-  const res = await createAIChatConversation({ title: '小妍助理对话' })
-  if (!res || res.code !== 0) return Message.error(res?.msg || '创建会话失败')
-  currentConversationId.value = res.data.id
+  const now = Date.now()
+  const localConversation = {
+    id: `local-${now}`,
+    title: '新对话',
+    last_message_preview: '',
+    last_message_time: 0,
+    create_time: now,
+    update_time: now,
+    local: true
+  }
+  conversations.value = [localConversation, ...conversations.value]
+  currentConversationId.value = localConversation.id
   messages.value = []
   messages.value = []
-  await loadConversations()
+  showConversationPanel.value = false
   scrollToBottom()
   scrollToBottom()
 }
 }
 
 
-const deleteCurrentConversation = async () => {
-  if (!currentConversationId.value) return
-  const res = await deleteAIChatConversation({ id: currentConversationId.value })
-  if (!res || res.code !== 0) return Message.error(res?.msg || '删除失败')
+const clearCurrentConversationIfNeeded = (id) => {
+  if (currentConversationId.value !== id) return
   currentConversationId.value = null
   currentConversationId.value = null
   messages.value = []
   messages.value = []
+}
+
+const deleteConversationById = async (id) => {
+  if (!id) return
+  if (isLocalConversation(id)) {
+    conversations.value = conversations.value.filter(item => item.id !== id)
+    clearCurrentConversationIfNeeded(id)
+    await loadConversations()
+    return
+  }
+  const res = await deleteAIChatConversation({ id })
+  if (!res || res.code !== 0) return Message.error(res?.msg || '删除失败')
+  conversations.value = conversations.value.filter(item => item.id !== id)
+  clearCurrentConversationIfNeeded(id)
   await loadConversations()
   await loadConversations()
 }
 }
 
 
+const deleteCurrentConversation = async () => {
+  if (!currentConversationId.value) return
+  await deleteConversationById(currentConversationId.value)
+}
+
 const uploadFiles = async (files) => {
 const uploadFiles = async (files) => {
-  const imageFiles = Array.from(files || []).filter(file => file.type?.startsWith('image/')).slice(0, 6 - uploadingImages.value.length)
+  const filesList = Array.from(files || []).filter(file => file.type?.startsWith('image/'))
+  const remaining = MAX_UPLOAD_IMAGES - uploadingImages.value.length
+  if (remaining <= 0) {
+    Message.warning(`最多上传 ${MAX_UPLOAD_IMAGES} 张图片`)
+    return
+  }
+  if (filesList.length > remaining) {
+    Message.warning(`最多上传 ${MAX_UPLOAD_IMAGES} 张图片,已自动保留前 ${remaining} 张`)
+  }
+  const imageFiles = filesList.slice(0, remaining)
   if (!imageFiles.length) return
   if (!imageFiles.length) return
   uploading.value = true
   uploading.value = true
   try {
   try {
@@ -391,28 +502,88 @@ const removeImage = (id) => {
   uploadingImages.value = uploadingImages.value.filter(item => item.id !== id)
   uploadingImages.value = uploadingImages.value.filter(item => item.id !== id)
 }
 }
 
 
-const sendMessage = async () => {
-  if (!canSend.value || sending.value) return
-  const content = input.value.trim()
+const upsertConversation = (conversation, previousId = null) => {
+  if (!conversation?.id) return
+  const filtered = conversations.value.filter(item => item.id !== conversation.id && item.id !== previousId)
+  conversations.value = [conversation, ...filtered]
+}
+
+const sendMessage = async (overrideContent = '') => {
+  const presetContent = String(overrideContent || '').trim()
+  if ((!presetContent && !canSend.value) || sending.value) return
+  const previousConversationId = currentConversationId.value
+  const content = presetContent || input.value.trim()
   const images = uploadingImages.value.map(item => item.url)
   const images = uploadingImages.value.map(item => item.url)
+  const localMessageId = `local-message-${Date.now()}`
+  const now = Date.now()
+  const optimisticMessage = {
+    id: localMessageId,
+    conversation_id: previousConversationId,
+    role: 'user',
+    content,
+    images,
+    status: 'sending',
+    sendStatus: 'sending',
+    error_msg: '',
+    create_time: now,
+    update_time: now,
+    local: true
+  }
+
   input.value = ''
   input.value = ''
   uploadingImages.value = []
   uploadingImages.value = []
+  messages.value.push(optimisticMessage)
+  const current = currentConversation.value
+  if (current) {
+    current.last_message_preview = content || (images.length ? `[图片] x${images.length}` : '')
+    current.last_message_time = now
+    current.update_time = now
+    if (!current.title || current.title === '新对话') current.title = current.last_message_preview?.slice(0, 40) || '新对话'
+  }
+  scrollToBottom()
   sending.value = true
   sending.value = true
   try {
   try {
-    const res = await sendAIChatMessage({ conversation_id: currentConversationId.value, content, images })
+    const payload = {
+      content,
+      images
+    }
+    if (!isLocalConversation(previousConversationId)) payload.conversation_id = previousConversationId
+    const res = await sendAIChatMessage(payload)
     if (!res || res.code !== 0) {
     if (!res || res.code !== 0) {
       Message.error(res?.msg || '发送失败')
       Message.error(res?.msg || '发送失败')
+      const localMessage = messages.value.find(item => item.id === localMessageId)
+      if (localMessage) {
+        localMessage.status = 'error'
+        localMessage.sendStatus = 'failed'
+        localMessage.error_msg = res?.msg || '发送失败'
+      }
       input.value = content
       input.value = content
       uploadingImages.value = images.map(url => ({ id: `${Date.now()}-${Math.random()}`, url }))
       uploadingImages.value = images.map(url => ({ id: `${Date.now()}-${Math.random()}`, url }))
       return
       return
     }
     }
-    await loadMessages()
+    if (res.data?.conversation) {
+      upsertConversation(res.data.conversation, previousConversationId)
+      currentConversationId.value = res.data.conversation.id
+    }
+    const localIndex = messages.value.findIndex(item => item.id === localMessageId)
+    if (localIndex !== -1) {
+      messages.value[localIndex] = {
+        ...(res.data?.message || messages.value[localIndex]),
+        sendStatus: 'sent'
+      }
+    }
     await loadConversations()
     await loadConversations()
+    markCurrentSeen()
+    scrollToBottom()
   } finally {
   } finally {
     sending.value = false
     sending.value = false
   }
   }
 }
 }
 
 
+const sendExamplePrompt = async (prompt) => {
+  await sendMessage(prompt)
+}
+
 const close = () => emit('close')
 const close = () => emit('close')
 const syncMobile = () => { isMobile.value = window.innerWidth <= 768 }
 const syncMobile = () => { isMobile.value = window.innerWidth <= 768 }
 
 
@@ -483,6 +654,18 @@ onUnmounted(() => {
   }
   }
 }
 }
 
 
+:global(.ai-chat-drawer .arco-drawer-header) {
+  height: 48px;
+  padding: 7px 16px;
+  border-bottom-color: rgba(29, 33, 41, 0.08);
+}
+
+:global(.ai-chat-drawer .arco-drawer-body) {
+  height: calc(100% - 48px);
+  padding: 0;
+  overflow: hidden;
+}
+
 .status-dot {
 .status-dot {
   width: 7px;
   width: 7px;
   height: 7px;
   height: 7px;
@@ -492,25 +675,38 @@ onUnmounted(() => {
 }
 }
 
 
 .ai-chat-shell {
 .ai-chat-shell {
+  position: relative;
   display: grid;
   display: grid;
   grid-template-columns: 270px minmax(0, 1fr);
   grid-template-columns: 270px minmax(0, 1fr);
-  height: calc(100vh - 92px);
+  height: 100%;
   min-height: 0;
   min-height: 0;
-  margin: -12px -8px -16px;
-  background:
-    radial-gradient(circle at 18% 6%, rgba(36, 99, 235, 0.08), transparent 30%),
-    linear-gradient(135deg, #f7faff 0%, #ffffff 46%, #f8fbf8 100%);
+  overflow: hidden;
+  margin: 0;
+  background: linear-gradient(135deg, #f7f9ff 0%, #ffffff 45%, #f6fbf8 100%);
   border-top: 1px solid rgba(29, 33, 41, 0.08);
   border-top: 1px solid rgba(29, 33, 41, 0.08);
 }
 }
 
 
+.conversation-backdrop {
+  position: absolute;
+  inset: 0;
+  z-index: 9;
+  background: rgba(15, 23, 42, 0.18);
+  backdrop-filter: blur(1px);
+}
+
 .conversation-panel {
 .conversation-panel {
+  display: flex;
+  flex-direction: column;
   min-width: 0;
   min-width: 0;
+  min-height: 0;
+  overflow: hidden;
   border-right: 1px solid rgba(29, 33, 41, 0.08);
   border-right: 1px solid rgba(29, 33, 41, 0.08);
-  background: rgba(255, 255, 255, 0.72);
+  background: rgba(255, 255, 255, 0.78);
   backdrop-filter: blur(18px);
   backdrop-filter: blur(18px);
 }
 }
 
 
 .conversation-panel__head {
 .conversation-panel__head {
+  flex: 0 0 auto;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   justify-content: space-between;
   justify-content: space-between;
@@ -530,9 +726,67 @@ onUnmounted(() => {
 }
 }
 
 
 .conversation-list {
 .conversation-list {
-  height: calc(100% - 76px);
+  flex: 1;
+  min-height: 0;
   overflow-y: auto;
   overflow-y: auto;
+  overscroll-behavior: contain;
   padding: 0 12px 18px;
   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 {
+  position: relative;
+  margin-top: 5px;
+  margin-bottom: 10px;
+
+  &:hover .conversation-card__more,
+  &:focus-within .conversation-card__more {
+    opacity: 1;
+    transform: translateY(0);
+    pointer-events: auto;
+  }
+
+  &__more {
+    position: absolute;
+    top: 8px;
+    right: 8px;
+    z-index: 2;
+    width: 26px;
+    height: 26px;
+    color: #4e5969;
+    background: rgba(255, 255, 255, 0.94);
+    box-shadow: 0 8px 18px rgba(15, 23, 42, 0.1);
+    opacity: 0;
+    pointer-events: none;
+    transform: translateY(-3px);
+    transition: opacity 0.16s ease, transform 0.16s ease, color 0.16s ease, background 0.16s ease;
+
+    &:hover {
+      color: #f53f3f;
+      background: #fff1f0;
+    }
+  }
 }
 }
 
 
 .conversation-item {
 .conversation-item {
@@ -540,7 +794,6 @@ onUnmounted(() => {
   display: grid;
   display: grid;
   width: 100%;
   width: 100%;
   gap: 7px;
   gap: 7px;
-  margin-bottom: 10px;
   padding: 13px 14px;
   padding: 13px 14px;
   border: 1px solid rgba(29, 33, 41, 0.07);
   border: 1px solid rgba(29, 33, 41, 0.07);
   border-radius: 8px;
   border-radius: 8px;
@@ -552,12 +805,16 @@ onUnmounted(() => {
 
 
   &:hover,
   &:hover,
   &--active {
   &--active {
-    border-color: rgba(37, 99, 235, 0.36);
+    border-color: rgba(116, 89, 255, 0.36);
     background: #ffffff;
     background: #ffffff;
-    box-shadow: 0 12px 28px rgba(37, 99, 235, 0.12);
+    box-shadow: 0 12px 28px rgba(82, 71, 190, 0.12);
     transform: translateY(-1px);
     transform: translateY(-1px);
   }
   }
 
 
+  &--local {
+    border-style: dashed;
+  }
+
   &__top,
   &__top,
   &__foot {
   &__foot {
     display: flex;
     display: flex;
@@ -618,6 +875,8 @@ onUnmounted(() => {
   display: grid;
   display: grid;
   grid-template-rows: auto minmax(0, 1fr) auto auto;
   grid-template-rows: auto minmax(0, 1fr) auto auto;
   min-width: 0;
   min-width: 0;
+  min-height: 0;
+  overflow: hidden;
 
 
   &--dragging::after {
   &--dragging::after {
     content: '释放上传图片';
     content: '释放上传图片';
@@ -640,8 +899,8 @@ onUnmounted(() => {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   gap: 12px;
   gap: 12px;
-  min-height: 68px;
-  padding: 14px 18px;
+  min-height: 56px;
+  padding: 10px 16px;
   border-bottom: 1px solid rgba(29, 33, 41, 0.08);
   border-bottom: 1px solid rgba(29, 33, 41, 0.08);
   background: rgba(255, 255, 255, 0.62);
   background: rgba(255, 255, 255, 0.62);
   backdrop-filter: blur(16px);
   backdrop-filter: blur(16px);
@@ -686,7 +945,9 @@ onUnmounted(() => {
 }
 }
 
 
 .message-list {
 .message-list {
+  min-height: 0;
   overflow-y: auto;
   overflow-y: auto;
+  overscroll-behavior: contain;
   padding: 22px 22px 18px;
   padding: 22px 22px 18px;
 }
 }
 
 
@@ -731,6 +992,41 @@ onUnmounted(() => {
   font-size: 13px;
   font-size: 13px;
 }
 }
 
 
+.welcome-prompts {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: center;
+  gap: 9px;
+  width: min(560px, 100%);
+  margin-top: 8px;
+}
+
+.welcome-prompt {
+  max-width: 100%;
+  padding: 8px 12px;
+  border: 1px solid rgba(116, 89, 255, 0.2);
+  border-radius: 999px;
+  background: rgba(255, 255, 255, 0.82);
+  color: #344054;
+  font-size: 13px;
+  line-height: 1.2;
+  box-shadow: 0 8px 18px rgba(82, 71, 190, 0.08);
+  cursor: pointer;
+  transition: border-color 0.16s ease, color 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
+
+  &:hover:not(:disabled) {
+    border-color: rgba(116, 89, 255, 0.48);
+    color: #5f49d8;
+    box-shadow: 0 10px 22px rgba(82, 71, 190, 0.12);
+    transform: translateY(-1px);
+  }
+
+  &:disabled {
+    cursor: not-allowed;
+    opacity: 0.58;
+  }
+}
+
 .message-row {
 .message-row {
   display: flex;
   display: flex;
   align-items: flex-start;
   align-items: flex-start;
@@ -750,8 +1046,8 @@ onUnmounted(() => {
 
 
     .message-bubble {
     .message-bubble {
       color: #ffffff;
       color: #ffffff;
-      background: linear-gradient(135deg, #2563eb, #0f766e);
-      box-shadow: 0 12px 28px rgba(37, 99, 235, 0.2);
+      background: linear-gradient(135deg, #6f63ff, #22a08f);
+      box-shadow: 0 12px 28px rgba(82, 71, 190, 0.18);
     }
     }
 
 
     .message-content :deep(a) {
     .message-content :deep(a) {
@@ -763,6 +1059,7 @@ onUnmounted(() => {
     .message-avatar {
     .message-avatar {
       background: #fff7e8;
       background: #fff7e8;
       color: #d46b08;
       color: #d46b08;
+      user-select: none;
     }
     }
 
 
     .message-bubble {
     .message-bubble {
@@ -776,6 +1073,7 @@ onUnmounted(() => {
   flex: 0 0 36px;
   flex: 0 0 36px;
   background: #eef4ff;
   background: #eef4ff;
   box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
   box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
+  user-select: none;
 }
 }
 
 
 .message-stack {
 .message-stack {
@@ -800,13 +1098,45 @@ onUnmounted(() => {
   max-width: 100%;
   max-width: 100%;
   padding: 12px 14px;
   padding: 12px 14px;
   border: 1px solid rgba(29, 33, 41, 0.07);
   border: 1px solid rgba(29, 33, 41, 0.07);
-  border-radius: 8px;
+  border-radius: 14px;
   background: rgba(255, 255, 255, 0.86);
   background: rgba(255, 255, 255, 0.86);
   box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
   box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
   line-height: 1.72;
   line-height: 1.72;
   word-break: break-word;
   word-break: break-word;
 }
 }
 
 
+.message-status {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  margin-top: 5px;
+  color: #86909c;
+  font-size: 12px;
+  line-height: 1;
+
+  svg {
+    font-size: 13px;
+  }
+
+  &--sending {
+    color: #6b7280;
+
+    svg {
+      animation: ai-chat-spin 1s linear infinite;
+    }
+  }
+
+  &--sent,
+  &--done {
+    color: #16a34a;
+  }
+
+  &--failed,
+  &--error {
+    color: #f53f3f;
+  }
+}
+
 .message-content {
 .message-content {
   :deep(p) {
   :deep(p) {
     margin: 0 0 7px;
     margin: 0 0 7px;
@@ -873,47 +1203,118 @@ onUnmounted(() => {
 }
 }
 
 
 .composer-wrap {
 .composer-wrap {
-  padding: 12px 18px 18px;
-  background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.9) 32%);
+  padding: 12px 18px 20px;
+  background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(250, 252, 255, 0.96) 32%);
 }
 }
 
 
 .composer {
 .composer {
-  display: grid;
-  grid-template-columns: auto minmax(0, 1fr) auto;
-  align-items: end;
-  gap: 10px;
-  padding: 10px;
-  border: 1px solid rgba(29, 33, 41, 0.08);
-  border-radius: 8px;
-  background: rgba(255, 255, 255, 0.92);
-  box-shadow: 0 16px 36px rgba(15, 23, 42, 0.1);
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  min-height: 98px;
+  padding: 12px 14px 10px;
+  border: 1px solid rgba(143, 118, 255, 0.42);
+  border-radius: 22px;
+  background: rgba(255, 255, 255, 0.96);
+  box-shadow:
+    0 18px 44px rgba(50, 42, 130, 0.1),
+    0 0 0 3px rgba(175, 147, 255, 0.08);
+  transition: border-color 0.18s ease, box-shadow 0.18s ease;
+
+  &:focus-within {
+    border-color: rgba(143, 118, 255, 0.72);
+    box-shadow:
+      0 20px 48px rgba(50, 42, 130, 0.13),
+      0 0 0 4px rgba(175, 147, 255, 0.12);
+  }
 
 
   :deep(.arco-textarea-wrapper) {
   :deep(.arco-textarea-wrapper) {
+    flex: 1;
     border: 0;
     border: 0;
     background: transparent;
     background: transparent;
     box-shadow: none;
     box-shadow: none;
+    padding: 0;
   }
   }
 
 
   :deep(textarea) {
   :deep(textarea) {
+    min-height: 36px;
+    padding: 0;
     font-size: 14px;
     font-size: 14px;
     line-height: 1.65;
     line-height: 1.65;
   }
   }
 
 
+  :deep(.arco-textarea-clear-btn) {
+    right: 0;
+  }
+
+  &__actions,
+  &__left,
+  &__right {
+    display: flex;
+    align-items: center;
+  }
+
+  &__actions {
+    justify-content: space-between;
+    gap: 10px;
+  }
+
+  &__left,
+  &__right {
+    gap: 8px;
+    min-width: 0;
+  }
+
   &__tool {
   &__tool {
-    background: #f2f3f5;
+    height: 30px;
+    padding: 0 6px;
+    border-radius: 999px;
+    color: #1f2937;
+    background: transparent;
+
+    &--label {
+      gap: 4px;
+      padding: 0 8px;
+      font-size: 13px;
+    }
+  }
+
+  &__mode {
+    width: 76px;
+
+    :deep(.arco-select-view-single) {
+      height: 30px;
+      border: 0;
+      border-radius: 999px;
+      background: #f5f6f8;
+      box-shadow: none;
+    }
   }
   }
 
 
   &__send {
   &__send {
-    background: linear-gradient(135deg, #2563eb, #0f766e);
+    width: 32px;
+    height: 32px;
     border: 0;
     border: 0;
+    background: linear-gradient(135deg, #c9a8ff, #8f7cff);
+    box-shadow: 0 8px 18px rgba(143, 118, 255, 0.28);
+  }
+}
+
+@keyframes ai-chat-spin {
+  from {
+    transform: rotate(0deg);
+  }
+
+  to {
+    transform: rotate(360deg);
   }
   }
 }
 }
 
 
 @media (max-width: 768px) {
 @media (max-width: 768px) {
   .ai-chat-shell {
   .ai-chat-shell {
     grid-template-columns: 1fr;
     grid-template-columns: 1fr;
-    height: calc(100vh - 64px);
-    margin: -12px -8px -16px;
+    height: 100%;
+    margin: 0;
   }
   }
 
 
   .conversation-panel {
   .conversation-panel {
@@ -933,14 +1334,29 @@ onUnmounted(() => {
   }
   }
 
 
   .chat-toolbar {
   .chat-toolbar {
-    min-height: 62px;
-    padding: 12px;
+    min-height: 54px;
+    padding: 10px 12px;
   }
   }
 
 
   .message-list {
   .message-list {
     padding: 16px 12px 12px;
     padding: 16px 12px 12px;
   }
   }
 
 
+  .welcome-card {
+    min-height: 260px;
+    padding: 0 6px;
+  }
+
+  .welcome-prompts {
+    justify-content: flex-start;
+  }
+
+  .welcome-prompt {
+    width: 100%;
+    border-radius: 12px;
+    text-align: left;
+  }
+
   .message-stack {
   .message-stack {
     max-width: calc(100% - 52px);
     max-width: calc(100% - 52px);
   }
   }
@@ -959,7 +1375,17 @@ onUnmounted(() => {
 
 
   .composer {
   .composer {
     gap: 8px;
     gap: 8px;
-    padding: 8px;
+    min-height: 94px;
+    padding: 10px 12px 9px;
+    border-radius: 20px;
+  }
+
+  .composer__actions {
+    align-items: flex-end;
+  }
+
+  .composer__left {
+    flex-wrap: wrap;
   }
   }
 }
 }
 </style>
 </style>

+ 0 - 1
src/pages/admin/aiChat/list.vue

@@ -111,7 +111,6 @@ const columnDefs = [
   { title: '内部ID', dataIndex: 'id', width: 100 },
   { title: '内部ID', dataIndex: 'id', width: 100 },
   { title: '标题', dataIndex: 'title', width: 180, ellipsis: true, tooltip: true },
   { title: '标题', dataIndex: 'title', width: 180, ellipsis: true, tooltip: true },
   { title: '用户', dataIndex: 'username', width: 140, ellipsis: true, tooltip: true },
   { title: '用户', dataIndex: 'username', width: 140, ellipsis: true, tooltip: true },
-  { title: '用户UUID', dataIndex: 'create_user', width: 240, ellipsis: true, tooltip: true },
   { title: '最后消息', dataIndex: 'last_message_preview', width: 260, ellipsis: true, tooltip: true },
   { title: '最后消息', dataIndex: 'last_message_preview', width: 260, ellipsis: true, tooltip: true },
   { title: '最后消息时间', slotName: 'last_message_time', width: 170 },
   { title: '最后消息时间', slotName: 'last_message_time', width: 170 },
   { title: '更新时间', slotName: 'update_time', width: 170 },
   { title: '更新时间', slotName: 'update_time', width: 170 },