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 c8599b7..f09163e 100644 Binary files a/static/input_send_icon.png and b/static/input_send_icon.png differ diff --git a/static/input_stop_icon.png b/static/input_stop_icon.png new file mode 100644 index 0000000..e5227d2 Binary files /dev/null and b/static/input_stop_icon.png differ