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