diff --git a/pages/Chat/Chat.vue b/pages/Chat/Chat.vue index b8f91d5..1ebadfa 100644 --- a/pages/Chat/Chat.vue +++ b/pages/Chat/Chat.vue @@ -733,11 +733,61 @@ const isThinking = ref(false) const isSelfSent = ref(false) // 是否当前设备发起的 AI 对话(用于区分弹窗/仅锁输入) + const programInputLocked = ref(false) const isUploading = ref(false) const isLoadingMessages = ref(false) // 消息加载中状态(仅会话变化时显示) const uploadedFiles = ref([]) const streamingMessageId = ref(null) // 流式消息的临时 ID,用于实时更新 AI 回复 + const ensureStreamingBubble = () => { + if (streamingMessageId.value) return + streamingMessageId.value = 'streaming-' + Date.now() + allmessages.value = [...allmessages.value, { + role: 'assistant', + content: '', + _streaming: true, + _id: streamingMessageId.value + }] + } + + const removeStreamingBubble = () => { + if (!streamingMessageId.value) return + allmessages.value = allmessages.value.filter(m => m._id !== streamingMessageId.value) + streamingMessageId.value = null + } + + const beginAiTurn = (selfSent = false) => { + programInputLocked.value = false + isThinking.value = true + socketStore.messageString = '' + socketStore.isThinking = true + isSelfSent.value = selfSent + ensureStreamingBubble() + } + + const cleanupAiTurn = async (options = {}) => { + programInputLocked.value = false + isThinking.value = false + isSelfSent.value = false + removeStreamingBubble() + if (options.refresh !== false) { + await takeConversationMessages() + } + } + + const finishAiTurn = async (options = {}) => { + await cleanupAiTurn(options) + socketStore.isThinking = false + } + + const keepProgramInputLocked = () => { + programInputLocked.value = true + isThinking.value = true + socketStore.isThinking = true + isSelfSent.value = false + ensureStreamingBubble() + } + // 消息详情弹窗 const showMessageModal = ref(false) const detailLinks = ref([]) @@ -904,6 +954,9 @@ const sendMessage = async () => { + if (isUploading.value) return + if (ChatType.value === 0 && isThinking.value) return + const message = textMessage.value.trim() if (!message && previewFileArray.value.length === 0) return @@ -1064,27 +1117,18 @@ "conversation_id": currentSessionId.value, "content": messageContent } - socketStore.send(data) - isThinking.value = true - isSelfSent.value = true + beginAiTurn(true) + await socketStore.send(data) // // 立即添加用户消息到列表(使用完整的 messageContent,包含文件信息) // allmessages.value = [...allmessages.value, { // role: 'user', // content: messageContent // }] - // 添加流式消息气泡(加载中状态) - streamingMessageId.value = 'streaming-' + Date.now() - allmessages.value = [...allmessages.value, { - role: 'assistant', - content: '', - _streaming: true, - _id: streamingMessageId.value - }] textMessage.value = '' uploadedFiles.value = [] scrollToBottom() } catch (error) { - isThinking.value = false + await finishAiTurn({ refresh: false }) uni.showToast({ title: `用户发送消息失败${error}`, icon: 'error' @@ -1104,16 +1148,7 @@ } catch (err) { console.error('中断指令发送失败:', err) } - // 移除流式消息占位 - if (streamingMessageId.value) { - allmessages.value = allmessages.value.filter(m => m._id !== streamingMessageId.value) - streamingMessageId.value = null - } - // 立即关闭弹窗,清除超时 - // clearTimeout(aiResponseTimer) - isThinking.value = false - socketStore.isThinking = false - isSelfSent.value = false + await finishAiTurn({ refresh: false }) textMessage.value = '' uploadedFiles.value = [] // takeConversationMessages 由后端中断确认驱动(见 handleBusinessMessage) @@ -1132,28 +1167,14 @@ console.log('AI开始回复(跨设备同步),锁定输入框'); isThinking.value = true // 非本设备发起时,也需要添加流式气泡 - if (!streamingMessageId.value) { - streamingMessageId.value = 'streaming-' + Date.now() - allmessages.value = [...allmessages.value, { - role: 'assistant', - content: '', - _streaming: true, - _id: streamingMessageId.value - }] - } + ensureStreamingBubble() return } // true → false:AI 回复结束或中断,解锁输入框并刷新消息列表 if (oldVal && !newVal) { console.log('AI回复结束(正常/中断),解锁并刷新消息列表'); // 移除流式消息气泡,从服务器加载完整消息 - if (streamingMessageId.value) { - allmessages.value = allmessages.value.filter(m => m._id !== streamingMessageId.value) - streamingMessageId.value = null - } - takeConversationMessages() - isThinking.value = false - isSelfSent.value = false + cleanupAiTurn() textMessage.value = '' uploadedFiles.value = [] } @@ -1832,4 +1853,4 @@ color: #fff; font-weight: 500; } - \ No newline at end of file + diff --git a/stores/socket.js b/stores/socket.js index d51bf4a..ac1c49f 100644 --- a/stores/socket.js +++ b/stores/socket.js @@ -161,6 +161,21 @@ export const useSocketStore = defineStore('socket', () => { // 调试日志:明确输出 type 和 data console.log(`[handleMessage] type = ${type}, data = ${JSON.stringify(data)}`); + const eventConversationId = + conversation_id || + data?.conversation_id || + data?.page?.conversation?.conversation_id || + data?.page?.conversation?.id || + '' + if ( + eventConversationId && + config.value.conversationId && + String(eventConversationId) !== String(config.value.conversationId) + ) { + addLog('info', `忽略非当前会话消息: ${eventConversationId}`) + return + } + switch (type) { case 'auth_ok': addLog('success', '连接成功') @@ -176,6 +191,42 @@ export const useSocketStore = defineStore('socket', () => { addLog('warn', 'AI成功收到用户发送的消息') break + case 'forbid_input': + addLog('warn', '收到封框指令') + isThinking.value = true + break + + case 'relieve_input': + addLog('warn', '收到解框指令') + isThinking.value = false + break + + case 'turn_status': { + const status = String(messageData.status || data?.status || '').toLowerCase() + if (status === 'locked') { + addLog('warn', '后端确认程序封框') + isThinking.value = true + } else if (status === 'finished' || status === 'cancelled' || status === 'unlocked' || status === 'relieved') { + addLog('warn', `后端确认当前轮结束/解框: ${status}`) + isThinking.value = false + } + break + } + + case 'prompt': + if ((messageData.info || data?.info) === '结束等待') { + addLog('warn', '收到结束等待信号') + isThinking.value = false + } + break + + case 'refresh': + if (messageData.page?.waiting === false || data?.page?.waiting === false) { + addLog('warn', 'refresh 确认 waiting=false,释放输入框') + isThinking.value = false + } + break + case 'render': addLog('warn', 'AI正在回复信息') handleBusinessMessage(data) @@ -183,6 +234,8 @@ export const useSocketStore = defineStore('socket', () => { case 'pc_offline': addLog('warn', 'PC 端已离线') + isThinking.value = false + authFailReason.value = 'pc offline' break default: @@ -229,6 +282,46 @@ export const useSocketStore = defineStore('socket', () => { // 处理 data 为对象的情况 if (!data || typeof data !== 'object') return + const msgType = String(data.type || '').trim() + if (msgType === 'forbid_input') { + console.log("收到后端封框指令"); + isThinking.value = true + return + } + if (msgType === 'relieve_input') { + console.log("收到后端解框指令"); + isThinking.value = false + return + } + + if (data.cmd === 'turn_status') { + const status = String(data.status || '').toLowerCase() + if (status === 'locked') { + console.log("后端确认程序封框"); + isThinking.value = true + return + } + if (status === 'finished' || status === 'cancelled' || status === 'unlocked' || status === 'relieved') { + console.log("后端确认当前轮结束/解框:", status); + isThinking.value = false + return + } + } + + if (data.cmd === 'prompt' && data.info === '结束等待') { + console.log("收到结束等待信号"); + isThinking.value = false + return + } + + // refresh 也会在流式输出和工具中间消息落盘时出现,不能直接当成最终完成。 + // 只有后端明确 page.waiting=false 时,才作为漏发结束信号的兜底释放。 + if (data.cmd === 'refresh' && data.page?.waiting === false) { + console.log("refresh 确认 waiting=false,释放输入框"); + isThinking.value = false + return + } + // 中断确认:后端返回 cmd=event_callback 表示中断成功 if (data.cmd === 'event_callback' && data.info === '中断成功') { console.log("后端确认中断成功"); @@ -303,4 +396,4 @@ export const useSocketStore = defineStore('socket', () => { clearLogs, getStatusText } -}) \ No newline at end of file +})