/* Copyright 2024-2026 ReplyPier Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue' import type { ChatDetail, Message } from '@/types/chat' import { formatDistanceToNow } from 'date-fns' import { zhCN } from 'date-fns/locale' import { chatService } from '@/services/chat' import { userService } from '@/services/user' import { socketService } from '@/services/socket' import { toast } from 'vue-sonner' import { canRequestRating, endChatMessage as endChatMessageFor } from '@/utils/endChat' import { canTakeOverChat, chatHandler } from '@/utils/chatState' import { routeChatToHuman } from '@/utils/chatActions' import { permissionChecks } from '@/utils/permissions' import { findMessage, newClientId } from '@/utils/messageMatch' import { useOrgInboxRoom } from '@/composables/useOrgInboxRoom' /** Emitted by the backend when a reply was saved but never reached the customer. */ interface DeliveryErrorEvent { error?: string type?: string session_id?: string can_template?: boolean client_id?: string | null message_id?: number } /** Tells the sender which stored row their message became. */ interface AgentMessageStoredEvent { session_id?: string client_id?: string | null message_id?: number } /** A channel's delivery receipt for a stored message (sent → delivered → read). */ interface MessageStatusEvent { session_id?: string message_id?: number external_message_id?: string | null channel_status?: Message['channel_status'] delivery_status?: string | null } export function useConversationChat( initialChat: ChatDetail, emit: { (event: 'refresh'): void (event: 'chatUpdated', data: ChatDetail): void (event: 'clearUnread', sessionId: string): void } ) { const chat = ref(initialChat) // Delivery receipts for chats no agent owns arrive through the org room useOrgInboxRoom() const newMessage = ref('') const messagesContainer = ref(null) const isLoading = ref(false) const currentUserId = userService.getUserId() // Set once a send is refused because the messaging window closed and a // template would reopen it. The frontend can't tell the window has expired on // its own — it never sees the customer's last inbound time — so this is only // known after the backend refuses a send. const templateCanReopen = ref(false) // Claimable and allowed to claim. Shared with ChatInfoPanel via // canTakeOverChat so the chat screen and the info panel can't disagree — // this pane used to require status 'transferred', which left an AI-handled // chat claimable only from the info panel. const showTakeoverButton = computed( () => canTakeOverChat(chat.value) && permissionChecks.canTakeOverChats() ) // Who is answering the customer. Derived once, shared with the inbox list and // the info panel through chatHandler, so the three surfaces cannot disagree. const handler = computed(() => chatHandler(chat.value, currentUserId)) const handledByAI = computed(() => handler.value.kind === 'ai') // Nobody is answering yet: the AI queued it, or this agent never uses AI. const isWaitingForHuman = computed(() => handler.value.kind === 'waiting') const isChatClosed = computed(() => handler.value.kind === 'closed') // A colleague holds it: we can watch, but not reply. const showTakenOverStatus = computed( () => handler.value.kind === 'human' && chat.value.user_id !== currentUserId ) /** One sentence explaining why the composer is unavailable, per handler. */ const handlerCaption = computed(() => { switch (handler.value.kind) { case 'ai': return '此会话正由 AI 客服处理' case 'waiting': // Deliberately not "the AI handed this over": with AI switched off on // the agent there was never an AI in the conversation to hand it on. return '此会话正在等待客服接手' case 'closed': return '此会话已结束' default: return `此会话正由 ${handler.value.label} 处理` } }) const canSendMessage = computed(() => { // Cannot send if chat is closed if (isChatClosed.value) return false // Cannot send if needs takeover or taken by another user return !showTakeoverButton.value && !showTakenOverStatus.value }) const scrollToBottom = async () => { // Wait for the new message to render, otherwise scrollHeight is still the // pre-append height and the list stops short of the bottom. await nextTick() if (messagesContainer.value) { messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight } } // Watch for changes in messages and scroll to bottom watch(() => chat.value.messages, (messages) => { scrollToBottom() // A customer message reopens the messaging window, so the closed-window // notice must go — otherwise it sits above a composer that now works. if (messages?.[messages.length - 1]?.message_type === 'user') { templateCanReopen.value = false } }, { deep: true }) // The same composable instance serves whichever chat is selected, so a // closed window learned about in one conversation must not follow us to the // next. Sending a template reopens the window, which also clears it. watch(() => chat.value.session_id, () => { templateCanReopen.value = false }) const clearTemplateSuggestion = () => { templateCanReopen.value = false } const updateChat = (newChat: ChatDetail) => { chat.value = { ...newChat } // Emit event to clear unreads emit('clearUnread', newChat.session_id) // Emit chatUpdated event to ensure the parent component updates emit('chatUpdated', chat.value) } // Replace chat state from parent props without emitting events const replaceChatFromProps = (newChat: ChatDetail) => { chat.value = { ...newChat } } const sendMessage = async () => { if (!newMessage.value.trim() || !canSendMessage.value) return try { const messageText = newMessage.value // Clear input immediately for better UX newMessage.value = '' // Add message locally first const timestamp = new Date().toISOString() // Ties this optimistic message to the row the backend stores, so its // delivery receipts and errors find it exactly (agent_message_stored) const clientId = newClientId() const localMessage: Message = { message: messageText, message_type: 'agent', created_at: timestamp, session_id: chat.value.session_id, client_id: clientId } // Update chat with new message chat.value.messages.push(localMessage) chat.value.updated_at = timestamp // Emit message through socket socketService.emit('agent_message', { message: messageText, session_id: chat.value.session_id, message_type: 'agent', created_at: timestamp, client_id: clientId }) scrollToBottom() } catch (err) { console.error('Failed to send message:', err) toast.error('消息发送失败', { description: '请重试', duration: 4000, closeButton: true }) } } const handleTakeover = async () => { try { isLoading.value = true await chatService.takeoverChat(chat.value.session_id) // Show success toast toast.success('已接管会话', { description: '现在可以在本会话中发送消息', duration: 4000, closeButton: true }) const userName = userService.getUserName() const userId = userService.getUserId() // Update local chat state chat.value = { ...chat.value, status: 'open', user_id: userId, user_name: userName } socketService.emit('taken_over', { session_id: chat.value.session_id, user_name: userName, profile_picture: userService.getCurrentUser()?.profile_pic ? userService.getCurrentUser()?.profile_pic : '' }) // Emit refresh event to update chat status emit('refresh') // Also emit chatUpdated event to ensure the parent component updates emit('chatUpdated', chat.value) } catch (err: any) { console.error('Failed to takeover chat:', err) // Show error toast with specific message if available toast.error('接管会话失败', { description: err.response?.data?.detail || '请重试', duration: 4000, closeButton: true }) } finally { isLoading.value = false } } const handleRouteToHuman = async () => { try { isLoading.value = true const updated = await routeChatToHuman(chat.value.session_id) if (!updated) return // The endpoint returns the whole thread back, so take its state rather // than guessing at a local patch. chat.value = updated emit('refresh') emit('chatUpdated', chat.value) } finally { isLoading.value = false } } // Add endChat function const endChat = async (requestRating = true) => { try { isLoading.value = true // Only the web widget can render a rating; on external channels the ask // is dropped and the closing message says nothing about rating. const askRating = requestRating && canRequestRating(chat.value.channel) // Create end chat message const timestamp = new Date().toISOString() const clientId = newClientId() const endChatMessage = { message: endChatMessageFor(chat.value.channel), message_type: "system", created_at: timestamp, session_id: chat.value.session_id, client_id: clientId, end_chat: true, request_rating: askRating, end_chat_reason: "AGENT_REQUEST", end_chat_description: "Agent manually ended the chat" } // Add message locally chat.value.messages.push(endChatMessage) // Update chat status locally chat.value.status = 'closed' chat.value.updated_at = timestamp // Emit message through socket to end chat socketService.emit('agent_message', { message: endChatMessage.message, session_id: chat.value.session_id, message_type: endChatMessage.message_type, created_at: timestamp, client_id: clientId, end_chat: true, request_rating: askRating, end_chat_reason: "AGENT_REQUEST", end_chat_description: "Agent manually ended the chat" }) // Show success toast toast.success('会话已结束', { description: askRating ? '将邀请客户进行评价' : '会话已关闭', duration: 4000, closeButton: true }) // Emit refresh event to update chat status emit('refresh') emit('chatUpdated', chat.value) scrollToBottom() } catch (err) { console.error('Failed to end chat:', err) toast.error('结束会话失败', { description: '请重试', duration: 4000, closeButton: true }) } finally { isLoading.value = false } } // On external channels a send can fail after the message is already stored // (typically once WhatsApp's 24h window closes). Without this the agent sees // their message sitting in the thread and assumes it arrived. The event names // the exact message; one not on screen is left for the next refetch. const handleDeliveryError = (data: DeliveryErrorEvent) => { if (data?.type !== 'delivery_error' || data.session_id !== chat.value.session_id) return const failed = findMessage(chat.value.messages, { messageId: data.message_id, clientId: data.client_id }) if (failed) { failed.attributes = { ...failed.attributes, delivery_status: 'failed' } } if (data.can_template) templateCanReopen.value = true toast.error('消息未送达', { description: data.error, duration: 6000, closeButton: true }) } // An optimistic message learns its row id, which receipts are keyed by const handleAgentMessageStored = (data: AgentMessageStoredEvent) => { if (data?.session_id !== chat.value.session_id || data.message_id == null) return const message = findMessage(chat.value.messages, { clientId: data.client_id }) if (message) message.id = data.message_id } const handleMessageStatus = (data: MessageStatusEvent) => { if (data?.session_id !== chat.value.session_id) return const message = findMessage(chat.value.messages, { messageId: data.message_id, externalId: data.external_message_id }) if (!message) return message.external_message_id = data.external_message_id ?? message.external_message_id message.channel_status = data.channel_status if (data.delivery_status) { message.attributes = { ...message.attributes, delivery_status: data.delivery_status } } } const setupSocketListeners = () => { socketService.on('error', handleDeliveryError) socketService.on('agent_message_stored', handleAgentMessageStored) socketService.on('message_status', handleMessageStatus) } const cleanupSocketListeners = () => { socketService.off('error', handleDeliveryError) socketService.off('agent_message_stored', handleAgentMessageStored) socketService.off('message_status', handleMessageStatus) } const handleSocketReconnect = () => { cleanupSocketListeners() setupSocketListeners() } onMounted(() => { setupSocketListeners() socketService.onReconnect(handleSocketReconnect) }) onBeforeUnmount(() => { cleanupSocketListeners() socketService.offReconnect(handleSocketReconnect) }) const formattedMessages = computed(() => { const formatted = chat.value.messages.map(msg => ({ ...msg, timeAgo: formatDistanceToNow(new Date(msg.created_at), { addSuffix: true, locale: zhCN }) })) // Debug: Log messages with attachments formatted.forEach((msg, idx) => { if (msg.attachments && msg.attachments.length > 0) { console.log(`[Message ${idx}] Has ${msg.attachments.length} attachments:`, msg.attachments) } }) return formatted }) return { newMessage, messagesContainer, formattedMessages, isLoading, showTakeoverButton, showTakenOverStatus, isChatClosed, canSendMessage, scrollToBottom, sendMessage, handleTakeover, handleRouteToHuman, isWaitingForHuman, handlerCaption, updateChat, replaceChatFromProps, handledByAI, endChat, templateCanReopen, clearTemplateSuggestion } }