Kaynağa Gözat

perf: 优化对话页面内容

jiaxing.liao 3 gün önce
ebeveyn
işleme
dd81bdfe7b

+ 95 - 0
apps/web/src/components/Chat/MessageList.vue

@@ -156,6 +156,18 @@
 						need-view-code-btn
 						@card-submit="(val) => emit('card-submit', val)"
 					/>
+
+					<div v-if="isStreamingMessage(item)" class="ml-10px w-fit">
+						<div class="elx-bubble__loading-wrap">
+							<div
+								v-for="(_, index) in 3"
+								:key="index"
+								class="elx-bubble__dot"
+								:style="{ animationDelay: `${index * 0.2}s` }"
+							/>
+						</div>
+					</div>
+
 					<div v-if="item?.stopped" class="msg-stop-indicator">
 						{{ t('pages.chat.stopped') }}
 					</div>
@@ -540,6 +552,9 @@ const getDisplayText = (message: BubbleMessage) =>
 
 const isErrorMessage = (message: BubbleMessage) => !!parseMessageError(message)
 
+const isStreamingMessage = (message: BubbleMessage) =>
+	message.role === 'ai' && !message.streamCompleted && !message.stopped
+
 const getThinkingText = (message: BubbleMessage) => {
 	const parts = [`${message.thinking || ''}`.trim(), getEmbeddedThinkingText(message)].filter(
 		Boolean
@@ -691,6 +706,37 @@ const handleAddToKb = (message: BubbleMessage) => {
 		color: var(--text-tertiary);
 	}
 
+	.msg-stream-indicator {
+		display: inline-flex;
+		align-items: center;
+		align-self: flex-start;
+		gap: 6px;
+		font-size: 12px;
+		line-height: 1.4;
+		color: var(--text-tertiary);
+	}
+
+	.msg-stream-dots {
+		display: inline-flex;
+		gap: 3px;
+
+		i {
+			width: 4px;
+			height: 4px;
+			border-radius: 50%;
+			background: currentColor;
+			animation: msg-stream-dot 1s ease-in-out infinite;
+		}
+
+		i:nth-child(2) {
+			animation-delay: 0.15s;
+		}
+
+		i:nth-child(3) {
+			animation-delay: 0.3s;
+		}
+	}
+
 	.msg-content-text :deep(h3) {
 		margin: 0 0 10px;
 		font-size: 12px;
@@ -737,6 +783,20 @@ const handleAddToKb = (message: BubbleMessage) => {
 	}
 }
 
+@keyframes msg-stream-dot {
+	0%,
+	80%,
+	100% {
+		opacity: 0.35;
+		transform: translateY(0);
+	}
+
+	40% {
+		opacity: 1;
+		transform: translateY(-2px);
+	}
+}
+
 .structured-blocks {
 	display: flex;
 	flex-direction: column;
@@ -944,4 +1004,39 @@ const handleAddToKb = (message: BubbleMessage) => {
 	word-break: break-all;
 }
 
+.elx-bubble__loading-wrap {
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	gap: 5px;
+}
+
+.elx-bubble__dot {
+	will-change: transform;
+	width: 5px;
+	height: 5px;
+	background-color: var(--elx-bubble-dot-color, var(--el-color-primary));
+	border-radius: 50%;
+	animation: wave 1s infinite ease-in-out;
+}
+
+.dot-2 {
+	animation-delay: 0.2s;
+}
+
+.dot-3 {
+	animation-delay: 0.4s;
+}
+
+/* 波浪动画 */
+@keyframes wave {
+	0%,
+	100% {
+		transform: translateY(-2px);
+	}
+
+	50% {
+		transform: translateY(2px);
+	}
+}
 </style>

+ 1 - 0
apps/web/src/i18n/locales/en-us.ts

@@ -1185,6 +1185,7 @@ export default {
 			copy: 'Copy',
 			addToKnowledgeBase: 'Add to Knowledge Base',
 			successTag: 'Success',
+			streaming: 'Thinking',
 			stopped: 'Stopped',
 			unknownError: 'Unknown error',
 			unsupportedCard: 'Card display is not supported',

+ 1 - 0
apps/web/src/i18n/locales/zh-cn.ts

@@ -1090,6 +1090,7 @@ export default {
 			copy: '复制',
 			addToKnowledgeBase: '添加到知识库',
 			successTag: '成功',
+			streaming: '正在思考',
 			stopped: '已停止',
 			unknownError: '未知错误',
 			unsupportedCard: '不支持卡片展示',

+ 24 - 2
apps/web/src/views/chat/composables/useChatStream.ts

@@ -27,7 +27,7 @@ export function useChatStream() {
 		isLoading.value = false
 	}
 
-	const parseSseBlock = (block: string): ChatSseMessage[] => {
+	const parseSseEvent = (block: string): ChatSseMessage[] => {
 		const dataLines = block
 			.split(/\r?\n/)
 			.filter((line) => line.startsWith('data:'))
@@ -48,6 +48,19 @@ export function useChatStream() {
 		}
 	}
 
+	const parseSseBlock = (block: string): ChatSseMessage[] => {
+		const eventBlocks = block
+			.split(/\r?\n\r?\n/)
+			.map((item) => item.trim())
+			.filter(Boolean)
+
+		if (eventBlocks.length > 1) {
+			return eventBlocks.flatMap(parseSseEvent)
+		}
+
+		return parseSseEvent(block)
+	}
+
 	const getErrorMessage = (payload: any) => {
 		if (!payload || typeof payload !== 'object') return ''
 		return `${payload.error || payload.errors?.message || payload.message || payload.msg || ''}`.trim()
@@ -105,6 +118,7 @@ export function useChatStream() {
 		activeController = controller
 		let completeReceived = false
 		let completed = false
+		let terminalAbortTimer: ReturnType<typeof setTimeout> | null = null
 
 		const finishStream = () => {
 			if (!completed) {
@@ -143,8 +157,13 @@ export function useChatStream() {
 
 				const results = parseSseBlock(result)
 				results.forEach(onChunk)
-				if (results.some((item) => item?.response_type === 'complete')) {
+				const hasCompleteEvent = results.some((item) => item?.response_type === 'complete')
+				const hasErrorEvent = results.some((item) => item?.response_type === 'error')
+				const hasTerminalEvent = hasCompleteEvent || hasErrorEvent
+				if (hasTerminalEvent) {
 					completeReceived = true
+					isLoading.value = false
+					finishStream()
 					controller.abort()
 					break
 				}
@@ -162,6 +181,9 @@ export function useChatStream() {
 				onError(await normalizeRequestError(error))
 			}
 		} finally {
+			if (terminalAbortTimer) {
+				clearTimeout(terminalAbortTimer)
+			}
 			if (activeStreamId === streamId) {
 				activeController = null
 				isLoading.value = false

+ 13 - 6
apps/web/src/views/chat/index.vue

@@ -689,6 +689,15 @@ const hasRenderableContent = (message: BubbleMessage) => {
 	)
 }
 
+const appendInlineError = (message: BubbleMessage, error: string) => {
+	const trimmedError = error.trim()
+	if (!trimmedError) return
+	const inlineErrors = message.inlineErrors || []
+	if (!inlineErrors.includes(trimmedError)) {
+		message.inlineErrors = [...inlineErrors, trimmedError]
+	}
+}
+
 /**
  * 创建用户消息对象
  * @param content - 消息内容
@@ -883,11 +892,8 @@ const applyStructuredEventToMessage = (message: BubbleMessage, event: ChatSseMes
 			break
 		case 'error':
 			syncMessageIdentity(message, event)
-			message.inlineErrors = [
-				...(message.inlineErrors || []),
-				event.content || data.error || data.message || t('pages.chat.unknownError')
-			]
-
+			appendInlineError(message, event.content || data.error || data.message || t('pages.chat.unknownError'))
+			message.streamCompleted = true
 			break
 		case 'session_title': {
 			const title = data.title || event.content
@@ -911,7 +917,8 @@ const applyStructuredEventToMessage = (message: BubbleMessage, event: ChatSseMes
 			break
 	}
 	updateMessageContent(message)
-	message.loading = !hasRenderableContent(message) && event.response_type !== 'complete'
+	message.loading =
+		!message.streamCompleted && !event.done && !hasRenderableContent(message) && event.response_type !== 'complete'
 	message.typing = false
 	message.isFog = false
 }