|
|
@@ -12,7 +12,7 @@
|
|
|
<template #title>
|
|
|
<div class="ai-chat-title">
|
|
|
<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>
|
|
|
<div class="ai-chat-title__copy">
|
|
|
<div class="ai-chat-title__name">小妍助理</div>
|
|
|
@@ -25,6 +25,11 @@
|
|
|
</template>
|
|
|
|
|
|
<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 }">
|
|
|
<div class="conversation-panel__head">
|
|
|
<div>
|
|
|
@@ -39,27 +44,45 @@
|
|
|
</div>
|
|
|
|
|
|
<div class="conversation-list">
|
|
|
- <button
|
|
|
+ <div
|
|
|
v-for="item in conversations"
|
|
|
: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">
|
|
|
<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>
|
|
|
<div>暂无会话</div>
|
|
|
</div>
|
|
|
@@ -81,11 +104,11 @@
|
|
|
|
|
|
<div class="chat-toolbar__identity">
|
|
|
<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>
|
|
|
<div class="chat-toolbar__text">
|
|
|
<strong>{{ currentConversation?.title || '新对话' }}</strong>
|
|
|
- <span>{{ currentConversation?.conversation_no ? `NO.${currentConversation.conversation_no}` : '准备开始' }}</span>
|
|
|
+ <!-- <span>{{ currentConversation?.conversation_no ? `NO.${currentConversation.conversation_no}` : '准备开始' }}</span> -->
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
@@ -99,7 +122,7 @@
|
|
|
<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="小妍助理" />
|
|
|
+ <img src="https://lepao-api.xxoo365.top/uploads/9aca0644-f1f8-4773-b25e-4cd5d268cf0e.png" alt="小妍助理" />
|
|
|
</a-avatar>
|
|
|
<div class="empty-state__title">开启一段新对话</div>
|
|
|
</div>
|
|
|
@@ -111,12 +134,24 @@
|
|
|
|
|
|
<div v-if="!messagesLoading && !messages.length" class="welcome-card">
|
|
|
<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>
|
|
|
<div>
|
|
|
<div class="welcome-card__title">我是小妍助理</div>
|
|
|
<div class="welcome-card__text">可以开始输入问题。</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
|
|
|
@@ -126,7 +161,8 @@
|
|
|
:class="`message-row--${message.role}`"
|
|
|
>
|
|
|
<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-info-circle v-else />
|
|
|
</a-avatar>
|
|
|
@@ -135,7 +171,6 @@
|
|
|
<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">
|
|
|
@@ -152,6 +187,12 @@
|
|
|
</div>
|
|
|
<div v-if="message.error_msg" class="message-error">{{ message.error_msg }}</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>
|
|
|
</template>
|
|
|
@@ -169,22 +210,28 @@
|
|
|
<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="输入消息"
|
|
|
+ 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 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>
|
|
|
</main>
|
|
|
@@ -200,7 +247,6 @@ import storage from 'store'
|
|
|
import { useUserStore } from '@/store/modules/user'
|
|
|
import { uploadImg } from '@/api/upload'
|
|
|
import {
|
|
|
- createAIChatConversation,
|
|
|
deleteAIChatConversation,
|
|
|
listAIChatConversations,
|
|
|
listAIChatMessages,
|
|
|
@@ -215,8 +261,16 @@ const props = defineProps({
|
|
|
})
|
|
|
const emit = defineEmits(['close', 'unread-change'])
|
|
|
|
|
|
+const user = ref(null)
|
|
|
const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
|
|
|
const userStore = useUserStore()
|
|
|
+const MAX_UPLOAD_IMAGES = 3
|
|
|
+const examplePrompts = [
|
|
|
+ '登录器怎么下载?',
|
|
|
+ '乐跑账号绑定失败怎么办?',
|
|
|
+ '帮我添加一个乐跑账号',
|
|
|
+ '如何查看我的订单和售后进度?'
|
|
|
+]
|
|
|
const isMobile = ref(window.innerWidth <= 768)
|
|
|
const showConversationPanel = ref(false)
|
|
|
const conversations = ref([])
|
|
|
@@ -228,6 +282,7 @@ const messagesLoading = ref(false)
|
|
|
const sending = ref(false)
|
|
|
const uploading = ref(false)
|
|
|
const dragging = ref(false)
|
|
|
+const assistantMode = ref('default')
|
|
|
const messageContainer = ref(null)
|
|
|
const fileInput = ref(null)
|
|
|
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 unreadTotal = computed(() => Object.values(unreadMap.value).reduce((sum, value) => sum + Number(value || 0), 0))
|
|
|
const seenKey = computed(() => `aiChatSeen:${userStore.uuid || 'guest'}`)
|
|
|
+const avatar = computed(() => {
|
|
|
+ return userStore.avatar
|
|
|
+})
|
|
|
|
|
|
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 formatFullTime = (time) => time ? new Date(Number(time)).toLocaleString('zh-CN', { hour12: false }) : ''
|
|
|
const formatShortTime = (time) => {
|
|
|
@@ -288,7 +356,8 @@ const scrollToBottom = () => {
|
|
|
const loadConversations = async () => {
|
|
|
const res = await listAIChatConversations({ current: 1, pagesize: 30 })
|
|
|
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)
|
|
|
if (visible.value && !currentConversationId.value && conversations.value.length) {
|
|
|
await openConversation(conversations.value[0].id)
|
|
|
@@ -297,6 +366,11 @@ const loadConversations = async () => {
|
|
|
|
|
|
const loadMessages = async () => {
|
|
|
if (!currentConversationId.value) return
|
|
|
+ if (isLocalConversation(currentConversationId.value)) {
|
|
|
+ messages.value = []
|
|
|
+ messagesLoading.value = false
|
|
|
+ return
|
|
|
+ }
|
|
|
messagesLoading.value = true
|
|
|
try {
|
|
|
const res = await listAIChatMessages({ conversation_id: currentConversationId.value, current: 1, pagesize: 80 })
|
|
|
@@ -310,15 +384,17 @@ const loadMessages = 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 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 || '收到新消息' })
|
|
|
+ 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()
|
|
|
scrollToBottom()
|
|
|
await loadConversations()
|
|
|
@@ -331,31 +407,66 @@ const openConversation = async (id) => {
|
|
|
}
|
|
|
|
|
|
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) {
|
|
|
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
|
|
|
+ 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 = []
|
|
|
- await loadConversations()
|
|
|
+ showConversationPanel.value = false
|
|
|
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
|
|
|
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()
|
|
|
}
|
|
|
|
|
|
+const deleteCurrentConversation = async () => {
|
|
|
+ if (!currentConversationId.value) return
|
|
|
+ await deleteConversationById(currentConversationId.value)
|
|
|
+}
|
|
|
+
|
|
|
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
|
|
|
uploading.value = true
|
|
|
try {
|
|
|
@@ -391,28 +502,88 @@ const removeImage = (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 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 = ''
|
|
|
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
|
|
|
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) {
|
|
|
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
|
|
|
uploadingImages.value = images.map(url => ({ id: `${Date.now()}-${Math.random()}`, url }))
|
|
|
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()
|
|
|
+ markCurrentSeen()
|
|
|
+ scrollToBottom()
|
|
|
} finally {
|
|
|
sending.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+const sendExamplePrompt = async (prompt) => {
|
|
|
+ await sendMessage(prompt)
|
|
|
+}
|
|
|
+
|
|
|
const close = () => emit('close')
|
|
|
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 {
|
|
|
width: 7px;
|
|
|
height: 7px;
|
|
|
@@ -492,25 +675,38 @@ onUnmounted(() => {
|
|
|
}
|
|
|
|
|
|
.ai-chat-shell {
|
|
|
+ position: relative;
|
|
|
display: grid;
|
|
|
grid-template-columns: 270px minmax(0, 1fr);
|
|
|
- height: calc(100vh - 92px);
|
|
|
+ height: 100%;
|
|
|
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);
|
|
|
}
|
|
|
|
|
|
+.conversation-backdrop {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ z-index: 9;
|
|
|
+ background: rgba(15, 23, 42, 0.18);
|
|
|
+ backdrop-filter: blur(1px);
|
|
|
+}
|
|
|
+
|
|
|
.conversation-panel {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
min-width: 0;
|
|
|
+ min-height: 0;
|
|
|
+ overflow: hidden;
|
|
|
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);
|
|
|
}
|
|
|
|
|
|
.conversation-panel__head {
|
|
|
+ flex: 0 0 auto;
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
justify-content: space-between;
|
|
|
@@ -530,9 +726,67 @@ onUnmounted(() => {
|
|
|
}
|
|
|
|
|
|
.conversation-list {
|
|
|
- height: calc(100% - 76px);
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
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 {
|
|
|
+ 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 {
|
|
|
@@ -540,7 +794,6 @@ onUnmounted(() => {
|
|
|
display: grid;
|
|
|
width: 100%;
|
|
|
gap: 7px;
|
|
|
- margin-bottom: 10px;
|
|
|
padding: 13px 14px;
|
|
|
border: 1px solid rgba(29, 33, 41, 0.07);
|
|
|
border-radius: 8px;
|
|
|
@@ -552,12 +805,16 @@ onUnmounted(() => {
|
|
|
|
|
|
&:hover,
|
|
|
&--active {
|
|
|
- border-color: rgba(37, 99, 235, 0.36);
|
|
|
+ border-color: rgba(116, 89, 255, 0.36);
|
|
|
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);
|
|
|
}
|
|
|
|
|
|
+ &--local {
|
|
|
+ border-style: dashed;
|
|
|
+ }
|
|
|
+
|
|
|
&__top,
|
|
|
&__foot {
|
|
|
display: flex;
|
|
|
@@ -618,6 +875,8 @@ onUnmounted(() => {
|
|
|
display: grid;
|
|
|
grid-template-rows: auto minmax(0, 1fr) auto auto;
|
|
|
min-width: 0;
|
|
|
+ min-height: 0;
|
|
|
+ overflow: hidden;
|
|
|
|
|
|
&--dragging::after {
|
|
|
content: '释放上传图片';
|
|
|
@@ -640,8 +899,8 @@ onUnmounted(() => {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
gap: 12px;
|
|
|
- min-height: 68px;
|
|
|
- padding: 14px 18px;
|
|
|
+ min-height: 56px;
|
|
|
+ padding: 10px 16px;
|
|
|
border-bottom: 1px solid rgba(29, 33, 41, 0.08);
|
|
|
background: rgba(255, 255, 255, 0.62);
|
|
|
backdrop-filter: blur(16px);
|
|
|
@@ -686,7 +945,9 @@ onUnmounted(() => {
|
|
|
}
|
|
|
|
|
|
.message-list {
|
|
|
+ min-height: 0;
|
|
|
overflow-y: auto;
|
|
|
+ overscroll-behavior: contain;
|
|
|
padding: 22px 22px 18px;
|
|
|
}
|
|
|
|
|
|
@@ -731,6 +992,41 @@ onUnmounted(() => {
|
|
|
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 {
|
|
|
display: flex;
|
|
|
align-items: flex-start;
|
|
|
@@ -750,8 +1046,8 @@ onUnmounted(() => {
|
|
|
|
|
|
.message-bubble {
|
|
|
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) {
|
|
|
@@ -763,6 +1059,7 @@ onUnmounted(() => {
|
|
|
.message-avatar {
|
|
|
background: #fff7e8;
|
|
|
color: #d46b08;
|
|
|
+ user-select: none;
|
|
|
}
|
|
|
|
|
|
.message-bubble {
|
|
|
@@ -776,6 +1073,7 @@ onUnmounted(() => {
|
|
|
flex: 0 0 36px;
|
|
|
background: #eef4ff;
|
|
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
|
|
|
+ user-select: none;
|
|
|
}
|
|
|
|
|
|
.message-stack {
|
|
|
@@ -800,13 +1098,45 @@ onUnmounted(() => {
|
|
|
max-width: 100%;
|
|
|
padding: 12px 14px;
|
|
|
border: 1px solid rgba(29, 33, 41, 0.07);
|
|
|
- border-radius: 8px;
|
|
|
+ border-radius: 14px;
|
|
|
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-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 {
|
|
|
:deep(p) {
|
|
|
margin: 0 0 7px;
|
|
|
@@ -873,47 +1203,118 @@ onUnmounted(() => {
|
|
|
}
|
|
|
|
|
|
.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 {
|
|
|
- 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) {
|
|
|
+ flex: 1;
|
|
|
border: 0;
|
|
|
background: transparent;
|
|
|
box-shadow: none;
|
|
|
+ padding: 0;
|
|
|
}
|
|
|
|
|
|
:deep(textarea) {
|
|
|
+ min-height: 36px;
|
|
|
+ padding: 0;
|
|
|
font-size: 14px;
|
|
|
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 {
|
|
|
- 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 {
|
|
|
- background: linear-gradient(135deg, #2563eb, #0f766e);
|
|
|
+ width: 32px;
|
|
|
+ height: 32px;
|
|
|
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) {
|
|
|
.ai-chat-shell {
|
|
|
grid-template-columns: 1fr;
|
|
|
- height: calc(100vh - 64px);
|
|
|
- margin: -12px -8px -16px;
|
|
|
+ height: 100%;
|
|
|
+ margin: 0;
|
|
|
}
|
|
|
|
|
|
.conversation-panel {
|
|
|
@@ -933,14 +1334,29 @@ onUnmounted(() => {
|
|
|
}
|
|
|
|
|
|
.chat-toolbar {
|
|
|
- min-height: 62px;
|
|
|
- padding: 12px;
|
|
|
+ min-height: 54px;
|
|
|
+ padding: 10px 12px;
|
|
|
}
|
|
|
|
|
|
.message-list {
|
|
|
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 {
|
|
|
max-width: calc(100% - 52px);
|
|
|
}
|
|
|
@@ -959,7 +1375,17 @@ onUnmounted(() => {
|
|
|
|
|
|
.composer {
|
|
|
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>
|