From 65653525a0d2f9177ce8aeca32f69d4793206de1 Mon Sep 17 00:00:00 2001 From: zoujing Date: Wed, 6 Aug 2025 11:47:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E5=81=9C=E6=AD=A2=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 17 +++ pages/chat/ChatInputArea.vue | 48 +++++---- pages/chat/ChatMainList.vue | 188 ++++++++++++++++++++------------- request/api/AgentChatStream.js | 43 ++++---- static/input_send_icon.png | Bin 1217 -> 1597 bytes static/input_stop_icon.png | Bin 0 -> 1335 bytes 6 files changed, 182 insertions(+), 114 deletions(-) create mode 100644 .editorconfig create mode 100644 static/input_stop_icon.png diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e15919f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 4 +tab_width = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/pages/chat/ChatInputArea.vue b/pages/chat/ChatInputArea.vue index 4a60a56..c59d55c 100644 --- a/pages/chat/ChatInputArea.vue +++ b/pages/chat/ChatInputArea.vue @@ -26,7 +26,8 @@ maxlength="300" /> - + + @@ -36,7 +37,9 @@ import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue' const props = defineProps({ inputMessage: String, - holdKeyboard: Boolean + holdKeyboard: Boolean, + isSessionActive: Boolean, + stopRequest: Function }) const emit = defineEmits(['update:inputMessage', 'send', 'noHideKeyboard', 'keyboardShow', 'keyboardHide']) @@ -46,6 +49,7 @@ const inputMessage = ref(props.inputMessage || '') const isFocused = ref(false) const keyboardHeight = ref(0) + // 保持和父组件同步 watch(() => props.inputMessage, (val) => { inputMessage.value = val @@ -65,16 +69,24 @@ onMounted(() => { }) const sendMessage = () => { - if (!inputMessage.value.trim()) return; - emit('send', inputMessage.value) - inputMessage.value = '' - emit('update:inputMessage', inputMessage.value) - - // 发送后保持焦点(可选) - if (props.holdKeyboard && textareaRef.value) { - nextTick(() => { - textareaRef.value.focus() - }) + if (props.isSessionActive) { + // 如果会话进行中,调用停止请求函数 + if (props.stopRequest) { + props.stopRequest(); + } + } else { + // 否则发送新消息 + if (!inputMessage.value.trim()) return; + emit('send', inputMessage.value) + inputMessage.value = '' + emit('update:inputMessage', inputMessage.value) + + // 发送后保持焦点(可选) + if (props.holdKeyboard && textareaRef.value) { + nextTick(() => { + textareaRef.value.focus() + }) + } } } @@ -128,7 +140,7 @@ defineExpose({ margin: 0 12px; /* 确保输入框在安全区域内 */ margin-bottom: 8px; - + .input-container-voice { display: flex; align-items: center; @@ -137,13 +149,13 @@ defineExpose({ height: 44px; flex-shrink: 0; align-self: flex-end; - + image { width: 22px; height: 22px; } } - + .input-container-send { display: flex; align-items: center; @@ -152,13 +164,13 @@ defineExpose({ height: 44px; flex-shrink: 0; align-self: flex-end; - + image { width: 28px; height: 28px; } } - + .textarea { flex: 1; max-height: 92px; @@ -169,4 +181,4 @@ defineExpose({ align-items: center; } } - \ No newline at end of file + diff --git a/pages/chat/ChatMainList.vue b/pages/chat/ChatMainList.vue index d6c81a8..979c833 100644 --- a/pages/chat/ChatMainList.vue +++ b/pages/chat/ChatMainList.vue @@ -2,7 +2,7 @@ - + - + - - - + + - + - + + ref="inputAreaRef" + v-model="inputMessage" + :holdKeyboard="holdKeyboard" + :is-session-active="isSessionActive" + :stop-request="stopRequest" + @send="sendMessageAction" + @noHideKeyboard="handleNoHideKeyboard" + @keyboardShow="handleKeyboardShow" + @keyboardHide="handleKeyboardHide" + /> @@ -79,7 +81,7 @@ import { ref } from 'vue' import { defineEmits } from 'vue' import { onLoad } from '@dcloudio/uni-app'; - import { SCROLL_TO_BOTTOM, RECOMMEND_POSTS_TITLE, SEND_COMMAND_TEXT } from '@/constant/constant' + import { SCROLL_TO_BOTTOM, RECOMMEND_POSTS_TITLE, SEND_COMMAND_TEXT } from '@/constant/constant' import { MessageRole, MessageType, CompName } from '../../model/ChatModel'; import ChatTopWelcome from './ChatTopWelcome.vue'; @@ -92,44 +94,46 @@ import ChatMoreTips from './ChatMoreTips.vue'; import ChatInputArea from './ChatInputArea.vue' import CommandWrapper from '@/components/CommandWrapper/index.vue' - + import QuickBookingComponent from '../module/booking/QuickBookingComponent.vue' import DiscoveryCardComponent from '../module/discovery/DiscoveryCardComponent.vue'; import ActivityListComponent from '../module/banner/ActivityListComponent.vue'; import RecommendPostsComponent from '../module/recommend/RecommendPostsComponent.vue'; import AttachListComponent from '../module/attach/AttachListComponent.vue'; - + import CreateServiceOrder from '@/components/CreateServiceOrder/index.vue' - + import { agentChatStream } from '@/request/api/AgentChatStream'; import { mainPageData } from '@/request/api/MainPageDataApi'; import { conversationMsgList, recentConversation } from '@/request/api/ConversationApi'; - - + + /// 导航栏相关 const statusBarHeight = ref(20); /// 输入框组件引用 const inputAreaRef = ref(null); - + const timer = ref(null) /// focus时,点击页面的时候不收起键盘 - const holdKeyboard = ref(false) + const holdKeyboard = ref(false) /// 是否在键盘弹出,点击界面时关闭键盘 - const holdKeyboardFlag = ref(true) + const holdKeyboardFlag = ref(true) /// 键盘高度 const keyboardHeight = ref(0) /// 是否显示键盘 const isKeyboardShow = ref(false) - + ///(控制滚动位置) const scrollTop = ref(99999); - + /// 会话列表 const chatMsgList = ref([]) /// 输入口的输入消息 const inputMessage = ref('') - + /// 加载中 + let currentAIMsgIndex = 0 + /// 从个渠道获取如二维,没有的时候就返回首页的数据 const sceneId = ref('') /// agentId 首页接口中获取 @@ -140,13 +144,15 @@ const mainPageDataModel = ref({}) // 会话进行中标志 - let isSessionActive = false; + const isSessionActive = ref(false); + // 请求任务引用 + const requestTaskRef = ref(null); /// 指令 let commonType = '' - - - + + + // 打开抽屉 const emits = defineEmits(['openDrawer']) const openDrawer = () => { @@ -164,12 +170,12 @@ holdKeyboardFlag.value = true }, 100) } - + // 点击输入框、发送按钮时,不收键盘 const handleNoHideKeyboard = () => { holdKeyboardFlag.value = false } - + // 键盘弹起事件 const handleKeyboardShow = (height) => { keyboardHeight.value = height @@ -180,7 +186,7 @@ scrollToBottom() }, 150) } - + // 键盘收起事件 const handleKeyboardHide = () => { keyboardHeight.value = 0 @@ -202,7 +208,7 @@ scrollToBottom() }, 100) } - + /// 发送普通消息 const handleReply = (text) => { sendMessage(text) @@ -229,7 +235,7 @@ if (!inputText.trim()) return; handleNoHideKeyboard() sendMessage(inputText) - if(!isSessionActive) { + if(!isSessionActive.value) { inputMessage.value = '' } // 发送消息后保持键盘状态 @@ -248,14 +254,14 @@ } }); }); - + onMounted( async() => { getMainPageData() await loadRecentConversation() loadConversationMsgList() addNoticeListener() }) - + const addNoticeListener = () => { uni.$on(SCROLL_TO_BOTTOM, (value) => { setTimeout(() => { @@ -279,7 +285,7 @@ } }) } - + /// 获取最近一次的会话id const loadRecentConversation = async() => { const res = await recentConversation() @@ -287,7 +293,7 @@ conversationId.value = res.data.conversationId } } - + /// 加载历史消息的数据 let historyCurrentPageNum = 1 const loadConversationMsgList = async() => { @@ -305,7 +311,7 @@ scrollToBottom() } } - + /// 初始化数据 首次数据加载的时候 const initData = () => { const msg = { @@ -315,18 +321,18 @@ } chatMsgList.value.push(msg) } - - + + /// 发送消息的参数拼接 const sendMessage = (message, isInstruct = false) => { - if (isSessionActive) { + if (isSessionActive.value) { uni.showToast({ title: '请等待当前回复完成', icon: 'none' }); return; } - isSessionActive = true; + isSessionActive.value = true; const newMsg = { msgId: `msg_${chatMsgList.value.length}`, msgType: MessageRole.ME, @@ -337,10 +343,10 @@ } } chatMsgList.value.push(newMsg) - sendChat(message, isInstruct) + sendChat(message, isInstruct) console.log("发送的新消息:",JSON.stringify(newMsg)) } - + /// 打字机效果实现的变量 let loadingTimer = null; let typeWriterTimer = null; @@ -355,7 +361,7 @@ messageType: isInstruct ? 1 : 0, messageContent: isInstruct ? commonType : message } - + // 插入AI消息 const aiMsg = { msgId: `msg_${chatMsgList.value.length}`, @@ -368,7 +374,8 @@ } chatMsgList.value.push(aiMsg) const aiMsgIndex = chatMsgList.value.length - 1 - + currentAIMsgIndex = aiMsgIndex + // 动态加载中动画 let dotCount = 1; loadingTimer && clearInterval(loadingTimer); @@ -376,16 +383,16 @@ dotCount = dotCount % 3 + 1; chatMsgList.value[aiMsgIndex].msg = '加载中' + '.'.repeat(dotCount); }, 400); - + aiMsgBuffer = ''; isTyping = false; if (typeWriterTimer) { clearTimeout(typeWriterTimer); typeWriterTimer = null; } - + // 流式接收内容 - agentChatStream(args, (chunk) => { + const { promise, requestTask } = agentChatStream(args, (chunk) => { console.log('分段内容:', chunk) if (chunk && chunk.error) { chatMsgList.value[aiMsgIndex].msg = '请求错误,请重试'; @@ -393,7 +400,7 @@ loadingTimer = null; isTyping = false; typeWriterTimer = null; - isSessionActive = false; // 出错也允许再次发送 + isSessionActive.value = false; // 出错也允许再次发送 console.error('流式错误:', chunk.message, chunk.detail); return; } @@ -406,7 +413,7 @@ } // 把新内容追加到缓冲区 aiMsgBuffer += chunk.content; - + // 启动打字机(只启动一次) if (!isTyping) { isTyping = true; @@ -423,7 +430,7 @@ loadingTimer = null; isTyping = false; typeWriterTimer = null; - + // 补全:如果消息内容还停留在'加载中.'或为空,则给出友好提示 const msg = chatMsgList.value[aiMsgIndex].msg; console.log('msg:', msg) @@ -433,33 +440,41 @@ chatMsgList.value[aiMsgIndex].msg = ''; } } - // 如果有组件 + // 如果有组件 if(chunk.toolCall) { console.log('chunk.toolCall:', chunk.toolCall) chatMsgList.value[aiMsgIndex].toolCall = chunk.toolCall } - + // 如果有问题,则设置问题 if(chunk.question && chunk.question.length > 0) { chatMsgList.value[aiMsgIndex].question = chunk.question } - - isSessionActive = false; + + isSessionActive.value = false; scrollToBottom(); } }, 50); } - }).catch(e => { - isSessionActive = false; // 出错也允许再次发送 - console.log('error:', e) + }) + + // 存储请求任务 + requestTaskRef.value = requestTask; + + // 可选:处理Promise完成/失败, 已经在回调中处理数据,此处无需再处理 + promise.then(data => { + console.log('请求完成'); + }).catch(err => { + isSessionActive.value = false; // 出错也允许再次发送 + console.log('error:', err) }); - + // 打字机函数 function typeWriter() { if (aiMsgBuffer.length > 0) { chatMsgList.value[aiMsgIndex].msg += aiMsgBuffer[0]; aiMsgBuffer = aiMsgBuffer.slice(1); - + nextTick(() => { scrollToBottom(); }); @@ -468,12 +483,35 @@ // 等待新内容到来,不结束 typeWriterTimer = setTimeout(typeWriter, 30); } - } + } } - - + + + // 停止请求函数 + const stopRequest = () => { + if (requestTaskRef.value && requestTaskRef.value.abort) { + requestTaskRef.value.abort(); + // 重置状态 + isSessionActive.value = false; + const msg = chatMsgList.value[currentAIMsgIndex].msg; + if (!msg || msg === '加载中.' || msg.startsWith('加载中')) { + chatMsgList.value[currentAIMsgIndex].msg = '已终止请求,请重试'; + } + // 清除计时器 + if (loadingTimer) { + clearInterval(loadingTimer); + loadingTimer = null; + } + if (typeWriterTimer) { + clearTimeout(typeWriterTimer); + typeWriterTimer = null; + } + setTimeoutScrollToBottom() + } + } + \ No newline at end of file + diff --git a/request/api/AgentChatStream.js b/request/api/AgentChatStream.js index f01cd78..0cba5cf 100644 --- a/request/api/AgentChatStream.js +++ b/request/api/AgentChatStream.js @@ -7,16 +7,17 @@ const API = '/agent/assistant/chat'; * 获取AI聊天流式信息(仅微信小程序支持) * @param {Object} params 请求参数 * @param {Function} onChunk 回调,每收到一段数据触发 - * @returns {Promise} + * @returns {Object} 包含Promise和requestTask的对象 */ function agentChatStream(params, onChunk) { - return new Promise((resolve, reject) => { + let requestTask; + const promise = new Promise((resolve, reject) => { const token = uni.getStorageSync('token'); let hasError = false; console.log("发送请求内容: ", params) // #ifdef MP-WEIXIN - const requestTask = uni.request({ + requestTask = uni.request({ url: BASE_URL + API, // 替换为你的接口地址 method: 'POST', data: params, @@ -28,22 +29,21 @@ function agentChatStream(params, onChunk) { }, responseType: 'arraybuffer', success(res) { - resolve(res.data); + resolve(res.data); }, fail(err) { console.log("====> ", JSON.stringify(err)) - reject(err); + reject(err); }, - complete(res) { + complete(res) { if(res.statusCode !== 200) { - console.log("====> ", JSON.stringify(res)) - - if (onChunk) { - onChunk({ error: true, message: '服务器错误', detail: res }); - } - reject(res); - } - } + console.log("====> ", JSON.stringify(res)) + if (onChunk) { + onChunk({ error: true, message: '服务器错误', detail: res }); + } + reject(res); + } + } }); requestTask.onHeadersReceived(res => { @@ -75,18 +75,19 @@ function agentChatStream(params, onChunk) { }); // #endif }); + + return { + promise, + requestTask + }; } // window.atob兼容性处理 const weAtob = (string) => { - const b64re = - /^(?:[A-Za-z\d+/]{4})*?(?:[A-Za-z\d+/]{2}(?:==)?|[A-Za-z\d+/]{3}=?)?$/; - const b64 = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; - + const b64re = /^(?:[A-Za-z\d+/]{4})*?(?:[A-Za-z\d+/]{2}(?:==)?|[A-Za-z\d+/]{3}=?)?$/; + const b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; // 去除空白字符 string = String(string).replace(/[\t\n\f\r ]+/g, ''); - // 验证 Base64 编码 if (!b64re.test(string)) { throw new TypeError( @@ -150,4 +151,4 @@ function parseSSEChunk(raw) { return results; } -export { agentChatStream } \ No newline at end of file +export { agentChatStream } \ No newline at end of file diff --git a/static/input_send_icon.png b/static/input_send_icon.png index c8599b783fb6e48d0c62b2833c3707ac45bbf9f1..f09163eadaa515b0215dcdc7b815f4d985a071fa 100644 GIT binary patch literal 1597 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 literal 0 HcmV?d00001