|
|
@@ -1,381 +1,965 @@
|
|
|
<template>
|
|
|
- <a-drawer :visible="props.visible" class="drawer" width="570px" @cancel="closeAI" placement="left" :footer="false">
|
|
|
- <template #title>
|
|
|
- <div class="aiTitle">
|
|
|
- <img alt="AI" src="@/assets/ai.svg" height="25">
|
|
|
- <span>AI助手</span>
|
|
|
- <a-button size="small" class="button" @click="deleteMessages">清空记录</a-button>
|
|
|
+ <a-drawer
|
|
|
+ :visible="visible"
|
|
|
+ :width="drawerWidth"
|
|
|
+ :placement="isMobile ? 'bottom' : 'right'"
|
|
|
+ :height="isMobile ? '100vh' : undefined"
|
|
|
+ :footer="false"
|
|
|
+ class="ai-chat-drawer"
|
|
|
+ unmount-on-close
|
|
|
+ @cancel="close"
|
|
|
+ >
|
|
|
+ <template #title>
|
|
|
+ <div class="ai-chat-title">
|
|
|
+ <a-avatar :size="36" class="ai-chat-title__avatar">
|
|
|
+ <img src="@/assets/img/ai.png" alt="小妍助理" />
|
|
|
+ </a-avatar>
|
|
|
+ <div class="ai-chat-title__copy">
|
|
|
+ <div class="ai-chat-title__name">小妍助理</div>
|
|
|
+ <div class="ai-chat-title__sub">
|
|
|
+ <span class="status-dot"></span>
|
|
|
+ 在线
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <div class="ai-chat-shell" @paste="handlePaste">
|
|
|
+ <aside class="conversation-panel" :class="{ 'conversation-panel--open': showConversationPanel }">
|
|
|
+ <div class="conversation-panel__head">
|
|
|
+ <div>
|
|
|
+ <div class="conversation-panel__label">会话</div>
|
|
|
+ <div class="conversation-panel__count">{{ conversations.length }} 条</div>
|
|
|
+ </div>
|
|
|
+ <a-tooltip content="新建对话">
|
|
|
+ <a-button type="primary" shape="circle" @click="createConversationAndOpen">
|
|
|
+ <template #icon><icon-plus /></template>
|
|
|
+ </a-button>
|
|
|
+ </a-tooltip>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="conversation-list">
|
|
|
+ <button
|
|
|
+ v-for="item in conversations"
|
|
|
+ :key="item.id"
|
|
|
+ class="conversation-item"
|
|
|
+ :class="{ 'conversation-item--active': item.id === currentConversationId }"
|
|
|
+ @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 v-if="item.conversation_no">NO.{{ item.conversation_no }}</span>
|
|
|
+ </span>
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <div v-if="!conversations.length" class="conversation-empty">
|
|
|
+ <a-avatar :size="42" class="conversation-empty__avatar">
|
|
|
+ <img src="@/assets/img/ai.png" alt="小妍助理" />
|
|
|
+ </a-avatar>
|
|
|
+ <div>暂无会话</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </aside>
|
|
|
+
|
|
|
+ <main
|
|
|
+ class="chat-main"
|
|
|
+ :class="{ 'chat-main--dragging': dragging }"
|
|
|
+ @dragenter.prevent="dragging = true"
|
|
|
+ @dragover.prevent="dragging = true"
|
|
|
+ @dragleave.prevent="dragging = false"
|
|
|
+ @drop.prevent="handleDrop"
|
|
|
+ >
|
|
|
+ <div class="chat-toolbar">
|
|
|
+ <a-button v-if="isMobile" type="text" shape="circle" @click="showConversationPanel = !showConversationPanel">
|
|
|
+ <template #icon><icon-menu /></template>
|
|
|
+ </a-button>
|
|
|
+
|
|
|
+ <div class="chat-toolbar__identity">
|
|
|
+ <a-avatar :size="34" class="chat-toolbar__avatar">
|
|
|
+ <img src="@/assets/img/ai.png" alt="小妍助理" />
|
|
|
+ </a-avatar>
|
|
|
+ <div class="chat-toolbar__text">
|
|
|
+ <strong>{{ currentConversation?.title || '新对话' }}</strong>
|
|
|
+ <span>{{ currentConversation?.conversation_no ? `NO.${currentConversation.conversation_no}` : '准备开始' }}</span>
|
|
|
</div>
|
|
|
- </template>
|
|
|
- <div class="container">
|
|
|
- <div class="messages" ref="messagesContainer">
|
|
|
- <a-skeleton animation :loading="messagesLoading" class="messages-skeleton">
|
|
|
- <div class="messages-skeleton__list">
|
|
|
- <div v-for="n in 3" :key="n" class="messages-skeleton__item" :class="{ 'messages-skeleton__item--right': n === 2 }">
|
|
|
- <a-skeleton-shape shape="circle" />
|
|
|
- <a-skeleton-line :widths="['180px', '220px']" :rows="2" />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <template #content>
|
|
|
- <div v-for="(item, index) in messages" :key="index">
|
|
|
- <div class="time"
|
|
|
- v-if="!messages[index - 1] || (messages[index].time - messages[index - 1].time) > 300000">{{
|
|
|
- stramptoTime(item.time) }}</div>
|
|
|
- <div :class="['message', item.type === 'user' ? 'right' : 'left']">
|
|
|
- <a-avatar class="avatar">
|
|
|
- <img alt="avatar" src="@/assets/img/avatar/assistant.png" v-if="item.type !== 'user'" />
|
|
|
- <img alt="avatar" :src="user.avatar" v-else-if="item.type === 'user'" />
|
|
|
- </a-avatar>
|
|
|
- <a-dropdown trigger="contextMenu" alignPoint :style="{ display: 'block', zIndex: 99 }">
|
|
|
- <div :class="['content', item.type === 'user' ? 'user' : 'ai']"
|
|
|
- v-html="renderMarkdown(item.content)">
|
|
|
- </div>
|
|
|
- <template #content>
|
|
|
- <a-doption>
|
|
|
- <icon-play-arrow /> 打开仓库
|
|
|
- </a-doption>
|
|
|
- </template>
|
|
|
- </a-dropdown>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </template>
|
|
|
- </a-skeleton>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <a-popconfirm content="是否删除该对话?" @ok="deleteCurrentConversation">
|
|
|
+ <a-button :disabled="!currentConversationId" type="text" shape="circle" status="danger">
|
|
|
+ <template #icon><icon-delete /></template>
|
|
|
+ </a-button>
|
|
|
+ </a-popconfirm>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div ref="messageContainer" class="message-list">
|
|
|
+ <div v-if="!currentConversationId" class="empty-state">
|
|
|
+ <a-avatar :size="64" class="empty-state__avatar">
|
|
|
+ <img src="@/assets/img/ai.png" alt="小妍助理" />
|
|
|
+ </a-avatar>
|
|
|
+ <div class="empty-state__title">开启一段新对话</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template v-else>
|
|
|
+ <div v-if="messagesLoading" class="message-loading">
|
|
|
+ <a-spin />
|
|
|
</div>
|
|
|
|
|
|
- <div class="inputBox">
|
|
|
- <a-textarea placeholder="您的专属AI助手~" :max-length="300" :auto-size="{
|
|
|
- minRows: 1,
|
|
|
- maxRows: 6
|
|
|
- }" allow-clear v-model="input" />
|
|
|
- <a-button type="primary" :loading="send" @click="sendMessage">发送</a-button>
|
|
|
+ <div v-if="!messagesLoading && !messages.length" class="welcome-card">
|
|
|
+ <a-avatar :size="52" class="welcome-card__avatar">
|
|
|
+ <img src="@/assets/img/ai.png" alt="小妍助理" />
|
|
|
+ </a-avatar>
|
|
|
+ <div>
|
|
|
+ <div class="welcome-card__title">我是小妍助理</div>
|
|
|
+ <div class="welcome-card__text">可以开始输入问题。</div>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-for="message in messages"
|
|
|
+ :key="message.id"
|
|
|
+ class="message-row"
|
|
|
+ :class="`message-row--${message.role}`"
|
|
|
+ >
|
|
|
+ <a-avatar :size="36" class="message-avatar">
|
|
|
+ <img v-if="message.role === 'assistant'" src="@/assets/img/ai.png" alt="小妍助理" />
|
|
|
+ <icon-user v-else-if="message.role === 'user'" />
|
|
|
+ <icon-info-circle v-else />
|
|
|
+ </a-avatar>
|
|
|
+
|
|
|
+ <div class="message-stack">
|
|
|
+ <div class="message-meta">
|
|
|
+ <span>{{ roleLabel(message.role) }}</span>
|
|
|
+ <span>{{ formatFullTime(message.create_time) }}</span>
|
|
|
+ <a-tag v-if="message.status === 'error'" size="small" color="red">异常</a-tag>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="message-bubble">
|
|
|
+ <div v-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"
|
|
|
+ :key="url"
|
|
|
+ :src="url"
|
|
|
+ width="118"
|
|
|
+ height="118"
|
|
|
+ fit="cover"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div v-if="message.error_msg" class="message-error">{{ message.error_msg }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="uploadingImages.length" class="image-preview-bar">
|
|
|
+ <div v-for="item in uploadingImages" :key="item.id" class="image-preview">
|
|
|
+ <a-image :src="item.url" width="62" height="62" fit="cover" />
|
|
|
+ <a-button size="mini" shape="circle" @click="removeImage(item.id)">
|
|
|
+ <template #icon><icon-close /></template>
|
|
|
+ </a-button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </a-drawer>
|
|
|
+
|
|
|
+ <div class="composer-wrap">
|
|
|
+ <div class="composer">
|
|
|
+ <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
|
|
|
+ v-model="input"
|
|
|
+ placeholder="输入消息"
|
|
|
+ :max-length="1000"
|
|
|
+ :auto-size="{ minRows: 1, maxRows: 5 }"
|
|
|
+ allow-clear
|
|
|
+ @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>
|
|
|
+ </div>
|
|
|
+ </main>
|
|
|
+ </div>
|
|
|
+ </a-drawer>
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { ref, onMounted, nextTick, watch, onUnmounted } from 'vue'
|
|
|
-import { Message, Modal } from '@arco-design/web-vue'
|
|
|
-import { eventBus } from '@/utils/eventBus'
|
|
|
-import { AIChat, GetAIChatMessages, GetAIChatMessage, DeleteAIChatMessages } from '@/api/ai'
|
|
|
-import { useUserStore } from '@/store/modules/user'
|
|
|
+import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
|
+import { Message, Notification } from '@arco-design/web-vue'
|
|
|
import MarkdownIt from 'markdown-it'
|
|
|
-
|
|
|
-const md = new MarkdownIt()
|
|
|
-
|
|
|
-const renderMarkdown = (text) => {
|
|
|
- return md.render(text || '')
|
|
|
-}
|
|
|
+import storage from 'store'
|
|
|
+import { useUserStore } from '@/store/modules/user'
|
|
|
+import { uploadImg } from '@/api/upload'
|
|
|
+import {
|
|
|
+ createAIChatConversation,
|
|
|
+ deleteAIChatConversation,
|
|
|
+ listAIChatConversations,
|
|
|
+ listAIChatMessages,
|
|
|
+ sendAIChatMessage
|
|
|
+} from '@/api/aiChat'
|
|
|
|
|
|
const props = defineProps({
|
|
|
- visible: {
|
|
|
- type: Boolean,
|
|
|
- default: false
|
|
|
- }
|
|
|
+ visible: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ }
|
|
|
})
|
|
|
+const emit = defineEmits(['close', 'unread-change'])
|
|
|
|
|
|
-const user = ref('')
|
|
|
+const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
|
|
|
+const userStore = useUserStore()
|
|
|
+const isMobile = ref(window.innerWidth <= 768)
|
|
|
+const showConversationPanel = ref(false)
|
|
|
+const conversations = ref([])
|
|
|
+const messages = ref([])
|
|
|
+const currentConversationId = ref(null)
|
|
|
const input = ref('')
|
|
|
+const uploadingImages = ref([])
|
|
|
const messagesLoading = ref(false)
|
|
|
-const send = ref(false)
|
|
|
-const messages = ref([])
|
|
|
-const msgid = ref('')
|
|
|
-const newIndex = ref()
|
|
|
+const sending = ref(false)
|
|
|
+const uploading = ref(false)
|
|
|
+const dragging = ref(false)
|
|
|
+const messageContainer = ref(null)
|
|
|
+const fileInput = ref(null)
|
|
|
+const unreadMap = ref({})
|
|
|
+let conversationTimer = null
|
|
|
+let messageTimer = null
|
|
|
+
|
|
|
+const visible = computed(() => props.visible)
|
|
|
+const drawerWidth = computed(() => (isMobile.value ? '100%' : '940px'))
|
|
|
+const currentConversation = computed(() => conversations.value.find(item => item.id === currentConversationId.value))
|
|
|
+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 seenKey = computed(() => `aiChatSeen:${userStore.uuid || 'guest'}`)
|
|
|
+
|
|
|
+const roleLabel = (role) => ({ user: '我', assistant: '小妍助理', system: '系统' }[role] || role)
|
|
|
+const renderMarkdown = (text) => md.render(text || '')
|
|
|
+const formatFullTime = (time) => time ? new Date(Number(time)).toLocaleString('zh-CN', { hour12: false }) : ''
|
|
|
+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 messagesContainer = ref(null)
|
|
|
+const loadSeen = () => storage.get(seenKey.value) || {}
|
|
|
+const saveSeen = (seen) => storage.set(seenKey.value, seen)
|
|
|
+
|
|
|
+const updateUnreadByConversations = (list) => {
|
|
|
+ const oldTotal = unreadTotal.value
|
|
|
+ const seen = loadSeen()
|
|
|
+ const next = {}
|
|
|
+ list.forEach((item) => {
|
|
|
+ if (item.id === currentConversationId.value && visible.value) return
|
|
|
+ if (Number(item.last_message_time || 0) > Number(seen[item.id] || 0)) next[item.id] = 1
|
|
|
+ })
|
|
|
+ unreadMap.value = next
|
|
|
+ emit('unread-change', unreadTotal.value)
|
|
|
+ if (!visible.value && unreadTotal.value > oldTotal) {
|
|
|
+ Notification.info({ title: '小妍助理有新消息', content: '点击右下角助手查看回复' })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const markCurrentSeen = () => {
|
|
|
+ if (!currentConversationId.value) return
|
|
|
+ const seen = loadSeen()
|
|
|
+ const last = messages.value[messages.value.length - 1]
|
|
|
+ const conv = currentConversation.value
|
|
|
+ seen[currentConversationId.value] = Math.max(Number(last?.create_time || 0), Number(conv?.last_message_time || 0), Date.now())
|
|
|
+ saveSeen(seen)
|
|
|
+ delete unreadMap.value[currentConversationId.value]
|
|
|
+ unreadMap.value = { ...unreadMap.value }
|
|
|
+ emit('unread-change', unreadTotal.value)
|
|
|
+}
|
|
|
|
|
|
const scrollToBottom = () => {
|
|
|
- if (messagesContainer.value) {
|
|
|
- // 等 DOM 渲染完成再滚动
|
|
|
- nextTick(() => {
|
|
|
- messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
|
|
- })
|
|
|
- }
|
|
|
+ nextTick(() => {
|
|
|
+ if (messageContainer.value) messageContainer.value.scrollTop = messageContainer.value.scrollHeight
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
-const getAIChatMessages = async () => {
|
|
|
- try {
|
|
|
- messagesLoading.value = true
|
|
|
- const res = await GetAIChatMessages()
|
|
|
- if (!res || res.code !== 0)
|
|
|
- return Message.error(`获取历史对话消息失败!${res?.msg ?? ''}`)
|
|
|
- messages.value = res.data
|
|
|
- messages.value.push({
|
|
|
- type: 'system', time: new Date().getTime(), content: '你好!我是你的专属AI智能助手“小吉”,你可以问我任何问题哦~~\n试着问问:\n- 我拥有哪些Git仓库?\n- 仓库GitNexus最近一次提交的信息是什么?\n- 请对比electron仓库的最后两次提交。'
|
|
|
- })
|
|
|
- } catch (error) {
|
|
|
- Message.error(`获取历史对话消息失败!`)
|
|
|
- } finally {
|
|
|
- scrollToBottom()
|
|
|
- messagesLoading.value = false
|
|
|
- }
|
|
|
+const loadConversations = async () => {
|
|
|
+ const res = await listAIChatConversations({ current: 1, pagesize: 30 })
|
|
|
+ if (!res || res.code !== 0) return
|
|
|
+ conversations.value = res.data || []
|
|
|
+ updateUnreadByConversations(conversations.value)
|
|
|
+ if (visible.value && !currentConversationId.value && conversations.value.length) {
|
|
|
+ await openConversation(conversations.value[0].id)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
-const getAIChatMessage = async () => {
|
|
|
- try {
|
|
|
- const res = await GetAIChatMessage({ id: msgid.value })
|
|
|
- if (!res || res.code !== 0) {
|
|
|
- send.value = false
|
|
|
- return messages.value[newIndex.value].content = '获取对话消息失败,请稍后再试'
|
|
|
- }
|
|
|
-
|
|
|
- if (!res.data)
|
|
|
- return
|
|
|
-
|
|
|
- stopPolling()
|
|
|
- messages.value[newIndex.value].content = ''
|
|
|
- for (let i = 0; i < res.data.length; i++) {
|
|
|
- await new Promise(resolve => setTimeout(resolve, 30))
|
|
|
- messages.value[newIndex.value].content += res.data[i]
|
|
|
- scrollToBottom()
|
|
|
- }
|
|
|
- send.value = false
|
|
|
- } catch (error) {
|
|
|
- Message.error(`获取对话消息失败!`)
|
|
|
- } finally {
|
|
|
- scrollToBottom()
|
|
|
- }
|
|
|
+const loadMessages = async () => {
|
|
|
+ if (!currentConversationId.value) return
|
|
|
+ messagesLoading.value = true
|
|
|
+ try {
|
|
|
+ const res = await listAIChatMessages({ conversation_id: currentConversationId.value, current: 1, pagesize: 80 })
|
|
|
+ if (!res || res.code !== 0) return Message.error(res?.msg || '获取消息失败')
|
|
|
+ messages.value = res.data || []
|
|
|
+ markCurrentSeen()
|
|
|
+ scrollToBottom()
|
|
|
+ } finally {
|
|
|
+ messagesLoading.value = false
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
-let timer = null
|
|
|
+const pollNewMessages = async () => {
|
|
|
+ if (!currentConversationId.value || !visible.value) return
|
|
|
+ const lastId = messages.value[messages.value.length - 1]?.id || 0
|
|
|
+ const res = await listAIChatMessages({ conversation_id: currentConversationId.value, after_id: lastId })
|
|
|
+ if (!res || res.code !== 0) return
|
|
|
+ const incoming = res.data || []
|
|
|
+ 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 || '收到新消息' })
|
|
|
+ markCurrentSeen()
|
|
|
+ scrollToBottom()
|
|
|
+ await loadConversations()
|
|
|
+}
|
|
|
|
|
|
-// 轮询
|
|
|
-const startPolling = () => {
|
|
|
- if (!timer) {
|
|
|
- timer = setInterval(async () => {
|
|
|
- await getAIChatMessage()
|
|
|
- }, 1000)
|
|
|
- }
|
|
|
+const openConversation = async (id) => {
|
|
|
+ currentConversationId.value = id
|
|
|
+ showConversationPanel.value = false
|
|
|
+ await loadMessages()
|
|
|
}
|
|
|
|
|
|
-// 停止轮询
|
|
|
-const stopPolling = () => {
|
|
|
- if (timer) {
|
|
|
- clearInterval(timer)
|
|
|
- timer = null
|
|
|
+const createConversationAndOpen = async () => {
|
|
|
+ const blank = conversations.value.find(item => Number(item.last_message_time || 0) === 0)
|
|
|
+ if (blank) {
|
|
|
+ await openConversation(blank.id)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const res = await createAIChatConversation({ title: '小妍助理对话' })
|
|
|
+ if (!res || res.code !== 0) return Message.error(res?.msg || '创建会话失败')
|
|
|
+ currentConversationId.value = res.data.id
|
|
|
+ messages.value = []
|
|
|
+ await loadConversations()
|
|
|
+ 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 || '删除失败')
|
|
|
+ currentConversationId.value = null
|
|
|
+ messages.value = []
|
|
|
+ await loadConversations()
|
|
|
+}
|
|
|
+
|
|
|
+const uploadFiles = async (files) => {
|
|
|
+ const imageFiles = Array.from(files || []).filter(file => file.type?.startsWith('image/')).slice(0, 6 - uploadingImages.value.length)
|
|
|
+ if (!imageFiles.length) return
|
|
|
+ uploading.value = true
|
|
|
+ try {
|
|
|
+ for (const file of imageFiles) {
|
|
|
+ const formData = new FormData()
|
|
|
+ formData.append('uuid', userStore.uuid)
|
|
|
+ formData.append('session', userStore.session)
|
|
|
+ formData.append('upload', file)
|
|
|
+ const res = await uploadImg(formData)
|
|
|
+ if (!res || res.code !== 0) {
|
|
|
+ Message.error(res?.msg || '图片上传失败')
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ const url = res.data?.picturePath
|
|
|
+ if (url) uploadingImages.value.push({ id: `${Date.now()}-${Math.random()}`, url })
|
|
|
}
|
|
|
- send.value = false
|
|
|
+ } finally {
|
|
|
+ uploading.value = false
|
|
|
+ if (fileInput.value) fileInput.value.value = ''
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const handleFileInput = (event) => uploadFiles(event.target.files)
|
|
|
+const handlePaste = (event) => {
|
|
|
+ const files = Array.from(event.clipboardData?.files || [])
|
|
|
+ if (files.length) uploadFiles(files)
|
|
|
+}
|
|
|
+const handleDrop = (event) => {
|
|
|
+ dragging.value = false
|
|
|
+ uploadFiles(event.dataTransfer?.files)
|
|
|
+}
|
|
|
+const removeImage = (id) => {
|
|
|
+ uploadingImages.value = uploadingImages.value.filter(item => item.id !== id)
|
|
|
}
|
|
|
|
|
|
const sendMessage = async () => {
|
|
|
- if (input.value === '') return
|
|
|
- send.value = true
|
|
|
- let content = input.value
|
|
|
- input.value = ''
|
|
|
- try {
|
|
|
- messages.value.push({ type: 'user', time: new Date().getTime(), content })
|
|
|
- newIndex.value = messages.value.push({ type: 'ai', time: new Date().getTime(), content: '小吉正在思考哦~请稍候...' }) - 1
|
|
|
- scrollToBottom()
|
|
|
- const res = await AIChat({ message: content })
|
|
|
- if (!res || res.code !== 0) {
|
|
|
- send.value = false
|
|
|
- return messages.value[newIndex.value].content = '获取对话消息失败,请稍后再试'
|
|
|
- }
|
|
|
-
|
|
|
- msgid.value = res.id
|
|
|
- startPolling()
|
|
|
- } catch (error) {
|
|
|
- console.log(error)
|
|
|
- Message.error('获取对话消息失败!请稍后再试')
|
|
|
+ if (!canSend.value || sending.value) return
|
|
|
+ const content = input.value.trim()
|
|
|
+ const images = uploadingImages.value.map(item => item.url)
|
|
|
+ input.value = ''
|
|
|
+ uploadingImages.value = []
|
|
|
+ sending.value = true
|
|
|
+ try {
|
|
|
+ const res = await sendAIChatMessage({ conversation_id: currentConversationId.value, content, images })
|
|
|
+ if (!res || res.code !== 0) {
|
|
|
+ Message.error(res?.msg || '发送失败')
|
|
|
+ input.value = content
|
|
|
+ uploadingImages.value = images.map(url => ({ id: `${Date.now()}-${Math.random()}`, url }))
|
|
|
+ return
|
|
|
}
|
|
|
+ await loadMessages()
|
|
|
+ await loadConversations()
|
|
|
+ } finally {
|
|
|
+ sending.value = false
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
-const closeAI = () => {
|
|
|
- eventBus.emit('closeAI')
|
|
|
-}
|
|
|
+const close = () => emit('close')
|
|
|
+const syncMobile = () => { isMobile.value = window.innerWidth <= 768 }
|
|
|
|
|
|
-const getuser = async () => {
|
|
|
- const userStore = useUserStore()
|
|
|
- let userInfo = await userStore.getInfo()
|
|
|
- user.value = userInfo
|
|
|
+const startPolling = () => {
|
|
|
+ if (!conversationTimer) conversationTimer = setInterval(loadConversations, 8000)
|
|
|
+ if (!messageTimer) messageTimer = setInterval(pollNewMessages, 3000)
|
|
|
}
|
|
|
+const stopPolling = () => {
|
|
|
+ clearInterval(conversationTimer)
|
|
|
+ clearInterval(messageTimer)
|
|
|
+ conversationTimer = null
|
|
|
+ messageTimer = null
|
|
|
+}
|
|
|
+
|
|
|
+watch(() => props.visible, async (value) => {
|
|
|
+ if (value) {
|
|
|
+ await loadConversations()
|
|
|
+ if (!currentConversationId.value) await createConversationAndOpen()
|
|
|
+ markCurrentSeen()
|
|
|
+ scrollToBottom()
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+watch(unreadTotal, value => emit('unread-change', value))
|
|
|
|
|
|
onMounted(async () => {
|
|
|
- getuser()
|
|
|
- await getAIChatMessages()
|
|
|
+ await userStore.getInfo()
|
|
|
+ window.addEventListener('resize', syncMobile)
|
|
|
+ await loadConversations()
|
|
|
+ startPolling()
|
|
|
})
|
|
|
|
|
|
-// 组件销毁时停止轮询
|
|
|
onUnmounted(() => {
|
|
|
- stopPolling()
|
|
|
+ window.removeEventListener('resize', syncMobile)
|
|
|
+ stopPolling()
|
|
|
})
|
|
|
+</script>
|
|
|
|
|
|
-watch(
|
|
|
- () => props.visible,
|
|
|
- (val) => {
|
|
|
- if (val) {
|
|
|
- nextTick(() => {
|
|
|
- scrollToBottom()
|
|
|
- })
|
|
|
- }
|
|
|
- }
|
|
|
-)
|
|
|
+<style lang="less" scoped>
|
|
|
+.ai-chat-title {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+
|
|
|
+ &__avatar {
|
|
|
+ background: linear-gradient(135deg, #eaf2ff, #f7ecff);
|
|
|
+ box-shadow: 0 8px 20px rgba(48, 91, 189, 0.16);
|
|
|
+ }
|
|
|
+
|
|
|
+ &__copy {
|
|
|
+ display: grid;
|
|
|
+ gap: 3px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__name {
|
|
|
+ color: #1d2129;
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 700;
|
|
|
+ line-height: 1.2;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__sub {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ color: #4e5969;
|
|
|
+ font-size: 12px;
|
|
|
+ }
|
|
|
+}
|
|
|
|
|
|
-const stramptoTime = (time) => {
|
|
|
- return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
|
+.status-dot {
|
|
|
+ width: 7px;
|
|
|
+ height: 7px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: #16b364;
|
|
|
+ box-shadow: 0 0 0 4px rgba(22, 179, 100, 0.12);
|
|
|
}
|
|
|
|
|
|
-const deleteMessages = () => {
|
|
|
- Modal.confirm({
|
|
|
- title: '清空消息记录',
|
|
|
- content: '您即将清空所有消息记录,是否继续?',
|
|
|
- onOk: async () => {
|
|
|
- const res = await DeleteAIChatMessages()
|
|
|
- if (!res || res.code !== 0)
|
|
|
- return Message.error('清空消息记录失败!')
|
|
|
- getAIChatMessages()
|
|
|
- Message.success('清空消息记录成功!')
|
|
|
- },
|
|
|
- onCancel: () => {
|
|
|
+.ai-chat-shell {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 270px minmax(0, 1fr);
|
|
|
+ height: calc(100vh - 92px);
|
|
|
+ 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%);
|
|
|
+ border-top: 1px solid rgba(29, 33, 41, 0.08);
|
|
|
+}
|
|
|
|
|
|
- }
|
|
|
- })
|
|
|
+.conversation-panel {
|
|
|
+ min-width: 0;
|
|
|
+ border-right: 1px solid rgba(29, 33, 41, 0.08);
|
|
|
+ background: rgba(255, 255, 255, 0.72);
|
|
|
+ backdrop-filter: blur(18px);
|
|
|
}
|
|
|
|
|
|
-</script>
|
|
|
+.conversation-panel__head {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 18px 16px 12px;
|
|
|
+}
|
|
|
|
|
|
-<style lang="less" scoped>
|
|
|
-.container {
|
|
|
+.conversation-panel__label {
|
|
|
+ color: #1d2129;
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 720;
|
|
|
+}
|
|
|
+
|
|
|
+.conversation-panel__count {
|
|
|
+ margin-top: 2px;
|
|
|
+ color: #86909c;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.conversation-list {
|
|
|
+ height: calc(100% - 76px);
|
|
|
+ overflow-y: auto;
|
|
|
+ padding: 0 12px 18px;
|
|
|
+}
|
|
|
+
|
|
|
+.conversation-item {
|
|
|
+ position: relative;
|
|
|
+ display: grid;
|
|
|
+ width: 100%;
|
|
|
+ gap: 7px;
|
|
|
+ margin-bottom: 10px;
|
|
|
+ padding: 13px 14px;
|
|
|
+ border: 1px solid rgba(29, 33, 41, 0.07);
|
|
|
+ border-radius: 8px;
|
|
|
+ background: rgba(255, 255, 255, 0.58);
|
|
|
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
|
|
|
+ text-align: left;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease, background 0.18s ease;
|
|
|
+
|
|
|
+ &:hover,
|
|
|
+ &--active {
|
|
|
+ border-color: rgba(37, 99, 235, 0.36);
|
|
|
+ background: #ffffff;
|
|
|
+ box-shadow: 0 12px 28px rgba(37, 99, 235, 0.12);
|
|
|
+ transform: translateY(-1px);
|
|
|
+ }
|
|
|
+
|
|
|
+ &__top,
|
|
|
+ &__foot {
|
|
|
display: flex;
|
|
|
- flex-direction: column;
|
|
|
- height: calc(100% + 20px);
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
gap: 10px;
|
|
|
- margin-top: -20px;
|
|
|
+ min-width: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__title,
|
|
|
+ &__preview {
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__title {
|
|
|
+ min-width: 0;
|
|
|
+ color: #1d2129;
|
|
|
+ font-weight: 650;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__preview {
|
|
|
+ color: #4e5969;
|
|
|
+ font-size: 13px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__foot {
|
|
|
+ color: #86909c;
|
|
|
+ font-size: 11px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__dot {
|
|
|
+ flex: 0 0 auto;
|
|
|
+ width: 8px;
|
|
|
+ height: 8px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: #f53f3f;
|
|
|
+ box-shadow: 0 0 0 4px rgba(245, 63, 63, 0.12);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.conversation-empty {
|
|
|
+ display: grid;
|
|
|
+ place-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 40px 12px;
|
|
|
+ color: #86909c;
|
|
|
+ font-size: 13px;
|
|
|
+
|
|
|
+ &__avatar {
|
|
|
+ background: #eef4ff;
|
|
|
+ }
|
|
|
+}
|
|
|
|
|
|
- /* 对于 Webkit 浏览器(Chrome、Safari) */
|
|
|
- .messages::-webkit-scrollbar {
|
|
|
- width: 8px;
|
|
|
+.chat-main {
|
|
|
+ position: relative;
|
|
|
+ display: grid;
|
|
|
+ grid-template-rows: auto minmax(0, 1fr) auto auto;
|
|
|
+ min-width: 0;
|
|
|
+
|
|
|
+ &--dragging::after {
|
|
|
+ content: '释放上传图片';
|
|
|
+ position: absolute;
|
|
|
+ inset: 16px;
|
|
|
+ z-index: 5;
|
|
|
+ display: grid;
|
|
|
+ place-items: center;
|
|
|
+ border: 2px dashed #2563eb;
|
|
|
+ border-radius: 8px;
|
|
|
+ background: rgba(255, 255, 255, 0.88);
|
|
|
+ color: #1d4ed8;
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 700;
|
|
|
+ backdrop-filter: blur(6px);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.chat-toolbar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ min-height: 68px;
|
|
|
+ padding: 14px 18px;
|
|
|
+ border-bottom: 1px solid rgba(29, 33, 41, 0.08);
|
|
|
+ background: rgba(255, 255, 255, 0.62);
|
|
|
+ backdrop-filter: blur(16px);
|
|
|
+
|
|
|
+ &__identity {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 11px;
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__avatar {
|
|
|
+ flex: 0 0 auto;
|
|
|
+ background: #eef4ff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__text {
|
|
|
+ display: grid;
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ gap: 3px;
|
|
|
+
|
|
|
+ strong,
|
|
|
+ span {
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
}
|
|
|
|
|
|
- .messages::-webkit-scrollbar-track {
|
|
|
- background: transparent;
|
|
|
+ strong {
|
|
|
+ color: #1d2129;
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 700;
|
|
|
+ }
|
|
|
+
|
|
|
+ span {
|
|
|
+ color: #86909c;
|
|
|
+ font-size: 12px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.message-list {
|
|
|
+ overflow-y: auto;
|
|
|
+ padding: 22px 22px 18px;
|
|
|
+}
|
|
|
+
|
|
|
+.message-loading {
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ padding: 18px;
|
|
|
+}
|
|
|
+
|
|
|
+.empty-state,
|
|
|
+.welcome-card {
|
|
|
+ display: grid;
|
|
|
+ place-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ height: 100%;
|
|
|
+ color: #86909c;
|
|
|
+}
|
|
|
+
|
|
|
+.empty-state__avatar,
|
|
|
+.welcome-card__avatar {
|
|
|
+ background: linear-gradient(135deg, #eaf2ff, #f7ecff);
|
|
|
+}
|
|
|
+
|
|
|
+.empty-state__title,
|
|
|
+.welcome-card__title {
|
|
|
+ color: #1d2129;
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 700;
|
|
|
+}
|
|
|
+
|
|
|
+.welcome-card {
|
|
|
+ justify-content: center;
|
|
|
+ align-content: center;
|
|
|
+ height: auto;
|
|
|
+ min-height: 220px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.welcome-card__text {
|
|
|
+ margin-top: 4px;
|
|
|
+ color: #86909c;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+
|
|
|
+.message-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-start;
|
|
|
+ gap: 11px;
|
|
|
+ margin-bottom: 18px;
|
|
|
+
|
|
|
+ &--user {
|
|
|
+ flex-direction: row-reverse;
|
|
|
+
|
|
|
+ .message-stack {
|
|
|
+ align-items: flex-end;
|
|
|
}
|
|
|
|
|
|
- .messages::-webkit-scrollbar-thumb {
|
|
|
- background-color: rgba(0, 0, 0, 0.2);
|
|
|
- /* 滚动条滑块背景 */
|
|
|
- border-radius: 10px;
|
|
|
- /* 滚动条滑块圆角 */
|
|
|
+ .message-meta {
|
|
|
+ flex-direction: row-reverse;
|
|
|
}
|
|
|
|
|
|
- .messages::-webkit-scrollbar-thumb:hover {
|
|
|
- background-color: rgba(0, 0, 0, 0.3);
|
|
|
- /* 滚动条滑块悬停时的背景 */
|
|
|
+ .message-bubble {
|
|
|
+ color: #ffffff;
|
|
|
+ background: linear-gradient(135deg, #2563eb, #0f766e);
|
|
|
+ box-shadow: 0 12px 28px rgba(37, 99, 235, 0.2);
|
|
|
}
|
|
|
|
|
|
- .messages {
|
|
|
- flex: 1;
|
|
|
- overflow-y: auto;
|
|
|
- padding: 10px;
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
-
|
|
|
- .messages-skeleton {
|
|
|
- width: 100%;
|
|
|
-
|
|
|
- &__list {
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- gap: 16px;
|
|
|
- padding: 8px 0;
|
|
|
- }
|
|
|
-
|
|
|
- &__item {
|
|
|
- display: flex;
|
|
|
- align-items: flex-start;
|
|
|
- gap: 10px;
|
|
|
-
|
|
|
- &--right {
|
|
|
- flex-direction: row-reverse;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- .time {
|
|
|
- color: #888;
|
|
|
- font-size: 0.9em;
|
|
|
- text-align: center;
|
|
|
- margin: 10px 0 0;
|
|
|
- }
|
|
|
-
|
|
|
- .message {
|
|
|
- display: flex;
|
|
|
- margin-top: 10px;
|
|
|
-
|
|
|
- &.right {
|
|
|
- flex-direction: row-reverse;
|
|
|
- justify-content: end;
|
|
|
- }
|
|
|
-
|
|
|
- .avatar {
|
|
|
- user-select: none;
|
|
|
- width: 36px;
|
|
|
- height: 36px;
|
|
|
- background-color: #fff;
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- /* 对于 Webkit 浏览器(Chrome、Safari) */
|
|
|
- .content::-webkit-scrollbar {
|
|
|
- height: 6px;
|
|
|
- }
|
|
|
-
|
|
|
- .content::-webkit-scrollbar-track {
|
|
|
- background: transparent;
|
|
|
- }
|
|
|
-
|
|
|
- .content::-webkit-scrollbar-thumb {
|
|
|
- background-color: rgba(0, 0, 0, 0.2);
|
|
|
- /* 滚动条滑块背景 */
|
|
|
- border-radius: 10px;
|
|
|
- /* 滚动条滑块圆角 */
|
|
|
- }
|
|
|
-
|
|
|
- .content::-webkit-scrollbar-thumb:hover {
|
|
|
- background-color: rgba(0, 0, 0, 0.3);
|
|
|
- /* 滚动条滑块悬停时的背景 */
|
|
|
- }
|
|
|
-
|
|
|
- .content {
|
|
|
- max-width: 70%;
|
|
|
- padding: 0 14px;
|
|
|
- border-radius: 10px;
|
|
|
- margin: 0 10px;
|
|
|
- font-size: 14px;
|
|
|
- line-height: 1.5;
|
|
|
- word-break: break-word;
|
|
|
- overflow-x: auto;
|
|
|
-
|
|
|
- &.user {
|
|
|
- background-color: #4e88ff;
|
|
|
- color: white;
|
|
|
- }
|
|
|
-
|
|
|
- &.ai {
|
|
|
- background-color: #eee;
|
|
|
- color: #333;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
+ .message-content :deep(a) {
|
|
|
+ color: #ffffff;
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
+ &--system {
|
|
|
+ .message-avatar {
|
|
|
+ background: #fff7e8;
|
|
|
+ color: #d46b08;
|
|
|
}
|
|
|
|
|
|
- .inputBox {
|
|
|
- display: flex;
|
|
|
- gap: 10px;
|
|
|
- min-height: 15px;
|
|
|
+ .message-bubble {
|
|
|
+ background: #fffaf0;
|
|
|
+ border-color: rgba(245, 158, 11, 0.22);
|
|
|
}
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
-.aiTitle {
|
|
|
- display: flex;
|
|
|
- font-size: 1.1em;
|
|
|
- gap: 10px;
|
|
|
- width: 100%;
|
|
|
+.message-avatar {
|
|
|
+ flex: 0 0 36px;
|
|
|
+ background: #eef4ff;
|
|
|
+ box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
|
|
|
+}
|
|
|
+
|
|
|
+.message-stack {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: flex-start;
|
|
|
+ max-width: min(78%, 640px);
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.message-meta {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ margin-bottom: 6px;
|
|
|
+ color: #86909c;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.message-bubble {
|
|
|
+ max-width: 100%;
|
|
|
+ padding: 12px 14px;
|
|
|
+ border: 1px solid rgba(29, 33, 41, 0.07);
|
|
|
+ border-radius: 8px;
|
|
|
+ background: rgba(255, 255, 255, 0.86);
|
|
|
+ box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
|
|
|
+ line-height: 1.72;
|
|
|
+ word-break: break-word;
|
|
|
+}
|
|
|
+
|
|
|
+.message-content {
|
|
|
+ :deep(p) {
|
|
|
+ margin: 0 0 7px;
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(p:last-child) {
|
|
|
+ margin-bottom: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(ul),
|
|
|
+ :deep(ol) {
|
|
|
+ margin: 6px 0;
|
|
|
+ padding-left: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(code) {
|
|
|
+ border-radius: 4px;
|
|
|
+ padding: 2px 5px;
|
|
|
+ background: rgba(15, 23, 42, 0.08);
|
|
|
+ font-size: 12px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.message-images {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 8px;
|
|
|
+ margin-top: 9px;
|
|
|
+
|
|
|
+ :deep(.arco-image-img) {
|
|
|
+ border-radius: 8px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.message-error {
|
|
|
+ margin-top: 7px;
|
|
|
+ color: #f53f3f;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
|
|
|
- .button {
|
|
|
- position: absolute;
|
|
|
- left: 75%;
|
|
|
+.image-preview-bar {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ overflow-x: auto;
|
|
|
+ padding: 10px 18px 0;
|
|
|
+}
|
|
|
+
|
|
|
+.image-preview {
|
|
|
+ position: relative;
|
|
|
+ flex: 0 0 auto;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 8px 20px rgba(15, 23, 42, 0.1);
|
|
|
+
|
|
|
+ :deep(.arco-image-img) {
|
|
|
+ border-radius: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.arco-btn) {
|
|
|
+ position: absolute;
|
|
|
+ top: -8px;
|
|
|
+ right: -8px;
|
|
|
+ background: #ffffff;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.composer-wrap {
|
|
|
+ padding: 12px 18px 18px;
|
|
|
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.9) 32%);
|
|
|
+}
|
|
|
+
|
|
|
+.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);
|
|
|
+
|
|
|
+ :deep(.arco-textarea-wrapper) {
|
|
|
+ border: 0;
|
|
|
+ background: transparent;
|
|
|
+ box-shadow: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(textarea) {
|
|
|
+ font-size: 14px;
|
|
|
+ line-height: 1.65;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__tool {
|
|
|
+ background: #f2f3f5;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__send {
|
|
|
+ background: linear-gradient(135deg, #2563eb, #0f766e);
|
|
|
+ border: 0;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@media (max-width: 768px) {
|
|
|
+ .ai-chat-shell {
|
|
|
+ grid-template-columns: 1fr;
|
|
|
+ height: calc(100vh - 64px);
|
|
|
+ margin: -12px -8px -16px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .conversation-panel {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ bottom: 0;
|
|
|
+ left: 0;
|
|
|
+ z-index: 10;
|
|
|
+ width: min(86vw, 310px);
|
|
|
+ transform: translateX(-100%);
|
|
|
+ transition: transform 0.22s ease;
|
|
|
+ box-shadow: 12px 0 32px rgba(15, 23, 42, 0.16);
|
|
|
+
|
|
|
+ &--open {
|
|
|
+ transform: translateX(0);
|
|
|
}
|
|
|
+ }
|
|
|
+
|
|
|
+ .chat-toolbar {
|
|
|
+ min-height: 62px;
|
|
|
+ padding: 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .message-list {
|
|
|
+ padding: 16px 12px 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .message-stack {
|
|
|
+ max-width: calc(100% - 52px);
|
|
|
+ }
|
|
|
+
|
|
|
+ .message-bubble {
|
|
|
+ padding: 11px 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .image-preview-bar {
|
|
|
+ padding: 8px 12px 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .composer-wrap {
|
|
|
+ padding: 10px 12px max(12px, env(safe-area-inset-bottom));
|
|
|
+ }
|
|
|
+
|
|
|
+ .composer {
|
|
|
+ gap: 8px;
|
|
|
+ padding: 8px;
|
|
|
+ }
|
|
|
}
|
|
|
-</style>
|
|
|
+</style>
|