From 80b18cee0f409eba3d5d842eded8669bb790e337 Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Wed, 13 May 2026 09:19:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai-chat):=20=E4=BC=98=E5=8C=96=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E7=8A=B6=E6=80=81=E5=8A=A8=E7=94=BB=E4=B8=8E=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E6=B6=88=E6=81=AF=E8=BF=87=E6=B8=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pages_tool/ai-chat/ai-chat-message.vue | 463 ++++++++++++++++--------- 1 file changed, 302 insertions(+), 161 deletions(-) diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue index fa9d3c6..6f7381c 100644 --- a/pages_tool/ai-chat/ai-chat-message.vue +++ b/pages_tool/ai-chat/ai-chat-message.vue @@ -17,7 +17,7 @@ - + - - - - + + + + + + + + {{ message.loadingText || 'AI正在思考' }} + + . + . + . + + - AI正在思考中... @@ -558,9 +567,15 @@ export default { } // 会话未变化且已有消息 → 跳过加载,保留当前消息 }, - // 新增:组件销毁时清除防抖定时器 + // 新增:组件销毁时清除所有定时器 beforeDestroy() { if (this.scrollTimer) clearTimeout(this.scrollTimer); + // 清理所有加载状态定时器 + this.messages.forEach(msg => { + if (msg._rotationTimer) { + clearInterval(msg._rotationTimer); + } + }); }, created() { this.messages = [...this.initialMessages] @@ -900,17 +915,25 @@ async loadChatHistoryIfExist() { this.messages.push(userMessage) this.inputText = '' - // 显示AI正在输入 + // 显示AI正在处理 - 带状态轮换提示 const loadingMessage = { id: ++this.messageId, role: 'ai', type: 'loading', content: '', - timestamp: Date.now() + timestamp: Date.now(), + loadingText: 'AI正在思考', + _loadingIndex: 0 } this.shouldScrollToBottom = true this.messages.push(loadingMessage) + // 启动处理状态文字轮换 + this._startLoadingTextRotation(loadingMessage) + + // 等待 Vue 先渲染 loading 动画,再发起请求 + await this.$nextTick() + try { // 直接发送消息(跳过健康检查,避免健康检查接口格式不匹配导致误判) // 如果服务不可用,实际发送时会抛出异常,由 catch 块处理 @@ -925,7 +948,7 @@ async loadChatHistoryIfExist() { console.error('AI服务调用失败:', error) // 移除加载状态 - this.messages = this.messages.filter(msg => msg.type !== 'loading') + this._removeLoadingMessages() // 显示错误消息 const errorMessage = { @@ -955,7 +978,7 @@ async loadChatHistoryIfExist() { }) // 移除加载状态 - this.messages = this.messages.filter(msg => msg.type !== 'loading') + this._removeLoadingMessages() // 更新会话ID if (response.conversationId) { @@ -982,134 +1005,193 @@ async loadChatHistoryIfExist() { this.$emit('ai-response', aiMessage) }, // 发送流式消息(自动适配 H5 / 微信小程序) - async sendStreamMessage(userMessage) { - // 创建流式消息对象 - const streamMessage = { - id: ++this.messageId, - role: 'ai', - type: 'text', - content: '', - timestamp: Date.now(), - isStreaming: true - }; - // 标记正在流式响应 - this.isStreaming = true; - // 移除加载状态,添加流式消息 - this.messages = this.messages.filter(msg => msg.type !== 'loading'); - this.shouldScrollToBottom = true; - this.messages.push(streamMessage); - - // 添加消息后立即滚动一次 - this.$nextTick(() => { - this.scrollToBottom(); - }); - - try { - // #ifdef H5 - await aiService.sendHttpStream( - userMessage, - (chunk) => { - // 实时更新内容 - streamMessage.content += chunk; - // 流式更新时滚动到底部显示最新内容(防抖) - this.scrollToBottom(); +async sendStreamMessage(userMessage) { + // 找到已有的 loading 消息,复用它在 DOM 中的位置,实现无缝过渡 + const loadingIdx = this.messages.findIndex(msg => msg.type === 'loading') + if (loadingIdx === -1) { + // 安全回退:没有 loading 消息时创建新消息 + const fallback = { + id: ++this.messageId, + role: 'ai', + type: 'text', + content: '', + timestamp: Date.now(), + isStreaming: true + } + this.isStreaming = true + this.messages.push(fallback) + this.scrollToBottom() + + try { + // #ifdef H5 + await aiService.sendHttpStream(userMessage, + (chunk) => { fallback.content += chunk; this.scrollToBottom() }, + (result) => this._finishStream(fallback, result)) + // #endif + // #ifdef MP-WEIXIN + await aiService.sendStreamMessage(userMessage, + (chunk) => { fallback.content += chunk; this.scrollToBottom() }, + (result) => this._finishStream(fallback, result)) + // #endif + } catch (error) { + this._handleStreamError(fallback) + } + return + } + + this.isStreaming = true + const loadingMsg = this.messages[loadingIdx] + this._stopLoadingTextRotation(loadingMsg) + loadingMsg.loadingText = '正在接收回复' + + let transformed = false + + const transformOnFirstChunk = () => { + if (transformed) return this.messages[loadingIdx] + transformed = true + const msg = this.messages[loadingIdx] + msg.type = 'text' + msg.content = '' + this.$set(msg, 'isStreaming', true) + this.$set(msg, 'actions', []) + delete msg.loadingText + delete msg._loadingIndex + return msg + } + + try { + // #ifdef H5 + await aiService.sendHttpStream( + userMessage, + (chunk) => { + const msg = transformOnFirstChunk() + msg.content += chunk + this.scrollToBottom() }, - (completeResult) => { - // 流结束回调 - let convId = ''; - - if (typeof completeResult !== 'string') { - convId = completeResult.conversation_id || ''; - } - - // 标记流式结束 - streamMessage.isStreaming = false; - this.isStreaming = false; - - // 检查内容是否为空,如果为空则移除该消息 - if (!streamMessage.content || streamMessage.content.trim() === '') { - this.messages = this.messages.filter(msg => msg.id !== streamMessage.id); - return; - } - - // 保存会话 ID - if (convId) { - this.currentConversationId = convId; - aiService.setConversationId(convId); - this.saveConversationIdToLocal(convId); - } - - // 流结束后再滚动到底部 - this.$nextTick(() => { - this.scrollToBottom(); - }); - - // 触发事件 - this.$emit('ai-response', streamMessage); - } - ); - // #endif - - // #ifdef MP-WEIXIN - // ===== 微信小程序: 使用 WebSocket ===== - await aiService.sendStreamMessage( - userMessage, - (chunk) => { - // 实时更新内容 - streamMessage.content += chunk; - // 流式更新时滚动到底部显示最新内容(防抖) - this.scrollToBottom(); - }, - (completeResult) => { - // 流结束回调 - let convId = completeResult?.conversation_id || ''; - - // 标记流式结束 - streamMessage.isStreaming = false; - this.isStreaming = false; - - // 检查内容是否为空,如果为空则移除该消息 - if (!streamMessage.content || streamMessage.content.trim() === '') { - this.messages = this.messages.filter(msg => msg.id !== streamMessage.id); - return; - } - - // 保存会话 ID - if (convId) { - this.currentConversationId = convId; - aiService.setConversationId(convId); - this.saveConversationIdToLocal(convId); - } - - // 流结束后再滚动到底部 - this.$nextTick(() => { - this.scrollToBottom(); - }); - - // 触发事件 - this.$emit('ai-response', streamMessage); + (completeResult) => { + const msg = this.messages[loadingIdx] + if (!this._finishStream(msg, completeResult)) return } - ); - // #endif - } catch (error) { - console.error('流式请求失败:', error); - // 标记流式结束 - this.isStreaming = false; - // 移除流式消息 - this.messages = this.messages.filter(msg => msg.id !== streamMessage.id); - // 显示错误 - const errorMsg = { - id: ++this.messageId, - role: 'ai', - type: 'text', - content: '抱歉,服务暂时不可用,请稍后重试。', - timestamp: Date.now(), - actions: [{ id: 1, text: '重试', type: 'retry' }] - }; - this.messages.push(errorMsg); - this.$emit('ai-response', errorMsg); - } - }, + ) + // #endif + + // #ifdef MP-WEIXIN + await aiService.sendStreamMessage( + userMessage, + (chunk) => { + const msg = transformOnFirstChunk() + msg.content += chunk + this.scrollToBottom() + }, + (completeResult) => { + const msg = this.messages[loadingIdx] + if (!this._finishStream(msg, completeResult)) return + } + ) + // #endif + } catch (error) { + console.error('流式请求失败:', error) + this.isStreaming = false + + if (!transformed) { + // 在第一个 chunk 到达前出错 → 移除 loading 消息 + this._removeLoadingMessages() + } else { + // 流传输中出错 → 移除空的流式消息 + const msg = this.messages[loadingIdx] + if (msg && (!msg.content || msg.content.trim() === '')) { + this.messages.splice(loadingIdx, 1) + } + } + + const errorMsg = { + id: ++this.messageId, + role: 'ai', + type: 'text', + content: '抱歉,服务暂时不可用,请稍后重试。', + timestamp: Date.now(), + actions: [{ id: 1, text: '重试', type: 'retry' }] + } + this.messages.push(errorMsg) + this.$emit('ai-response', errorMsg) + } +}, + +// 完成流式消息处理(共用逻辑) +_finishStream(msg, result) { + msg.isStreaming = false + this.isStreaming = false + + if (!msg.content || msg.content.trim() === '') { + this._removeLoadingMessages() + return false + } + + if (result) { + const convId = typeof result === 'string' ? '' : (result.conversation_id || '') + if (convId) { + this.currentConversationId = convId + aiService.setConversationId(convId) + this.saveConversationIdToLocal(convId) + } + } + + msg.actions = [ + { id: 1, text: '有帮助', type: 'like' }, + { id: 2, text: '没帮助', type: 'dislike' } + ] + + this.$nextTick(() => this.scrollToBottom()) + this.$emit('ai-response', msg) + return true +}, + +// 流式请求出错处理 +_handleStreamError(msg) { + console.error('流式请求失败') + this.isStreaming = false + this.messages = this.messages.filter(m => m.id !== msg.id) + + const errorMsg = { + id: ++this.messageId, + role: 'ai', + type: 'text', + content: '抱歉,服务暂时不可用,请稍后重试。', + timestamp: Date.now(), + actions: [{ id: 1, text: '重试', type: 'retry' }] + } + this.messages.push(errorMsg) + this.$emit('ai-response', errorMsg) +}, + // 加载状态文字轮换 - 让AI看起来在处理不同阶段 + _startLoadingTextRotation(loadingMsg) { + const statusTexts = [ + 'AI正在思考', + 'AI正在分析', + 'AI正在检索', + 'AI正在生成' + ] + loadingMsg._rotationTimer = setInterval(() => { + loadingMsg._loadingIndex = (loadingMsg._loadingIndex + 1) % statusTexts.length + loadingMsg.loadingText = statusTexts[loadingMsg._loadingIndex] + }, 2000) + }, + + // 停止加载状态文字轮换 + _stopLoadingTextRotation(loadingMsg) { + if (loadingMsg && loadingMsg._rotationTimer) { + clearInterval(loadingMsg._rotationTimer) + loadingMsg._rotationTimer = null + } + }, + + // 移除加载消息并清理定时器 + _removeLoadingMessages() { + const loadingMsgs = this.messages.filter(msg => msg.type === 'loading'); + loadingMsgs.forEach(msg => this._stopLoadingTextRotation(msg)); + this.messages = this.messages.filter(msg => msg.type !== 'loading'); + }, + generateAIResponse(userMessage) { const responses = { '你好': '您好!我是AI智能客服,很高兴为您服务!有什么可以帮助您的吗?', @@ -1713,11 +1795,17 @@ async loadChatHistoryIfExist() { role: 'ai', type: 'loading', content: '', - timestamp: Date.now() + timestamp: Date.now(), + loadingText: 'AI正在思考', + _loadingIndex: 0 } this.messages.push(loadingMessage) - + this._startLoadingTextRotation(loadingMessage) + + // 等待 Vue 渲染 loading 动画 + await this.$nextTick() + try { // 使用AI服务获取回复 if (this.enableStreaming) { @@ -1732,7 +1820,7 @@ async loadChatHistoryIfExist() { console.error('AI服务调用失败:', error) // 移除加载状态 - this.messages = this.messages.filter(msg => msg.type !== 'loading') + this._removeLoadingMessages() // 显示错误消息 const errorMessage = { @@ -1900,6 +1988,13 @@ async loadChatHistoryIfExist() { // 清空聊天记录 clearMessages() { + // 清理所有加载状态定时器 + this.messages.forEach(msg => { + if (msg._rotationTimer) { + clearInterval(msg._rotationTimer) + msg._rotationTimer = null + } + }) // 清空消息数组 this.messages = [] // 重置消息 ID @@ -2562,31 +2657,68 @@ $radius-lg: 36rpx; align-items: center; padding: 32rpx 0; - .loading-dots { + .loading-typing { display: flex; - margin-right: 16rpx; + flex-direction: column; + align-items: flex-start; + gap: 12rpx; - .dot { - width: 14rpx; - height: 14rpx; - border-radius: 50%; - background-color: $color-primary; - margin: 0 6rpx; - animation: dotPulse 1.5s infinite ease-in-out; + .typing-dots { + display: flex; + align-items: center; - &:nth-child(2) { - animation-delay: 0.2s; - } - - &:nth-child(3) { - animation-delay: 0.4s; + .dot { + width: 14rpx; + height: 14rpx; + border-radius: 50%; + background-color: $color-primary; + margin: 0 6rpx; + animation: dotPulse 1.5s infinite ease-in-out; + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0.4s; + } + } + } + + .loading-text { + color: $color-text-light; + font-size: 24rpx; + display: flex; + align-items: center; + + .loading-status { + color: $color-primary; + font-weight: 500; + transition: opacity 0.3s ease; + } + + .loading-ellipsis { + display: inline-flex; + margin-left: 4rpx; + + .ellipsis-dot { + animation: ellipsisBlink 1.4s infinite ease-in-out; + opacity: 0; + + &:nth-child(1) { + animation-delay: 0s; + } + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0.4s; + } + } } } - } - - .loading-text { - color: $color-text-light; - font-size: 26rpx; } } @@ -3478,6 +3610,15 @@ $radius-lg: 36rpx; } } +@keyframes ellipsisBlink { + 0%, 100% { + opacity: 0; + } + 50% { + opacity: 1; + } +} + @keyframes wave { 0%, 100% { transform: scaleY(0.5);