From 4ff934ad98ec9eacf750cff20dbdb65774e29c1e Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 27 Sep 2026 21:10:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E8=AE=BF=E5=AE=A2=E4=B8=8E?= =?UTF-8?q?=E5=AE=A2=E6=9C=8D=E8=81=8A=E5=A4=A9=E7=95=8C=E9=9D=A2=E3=80=81?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E6=8B=89=E5=8F=96=E4=B8=8E=E6=96=AD=E7=BA=BF?= =?UTF-8?q?=E9=87=8D=E8=BF=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 聊天页支持发送确认、历史补拉和重连;刷新后不重复展示已确认消息。 --- .../__tests__/components/HandlerBadge.spec.ts | 53 + .../components/MessageStatusTicks.spec.ts | 48 + .../components/QuickReplyChips.spec.ts | 54 + .../src/__tests__/utils/chatActions.spec.ts | 59 + .../__tests__/utils/widgetPresence.spec.ts | 56 + .../__tests__/views/ConversationsView.spec.ts | 189 +++ .../conversations/ChatInfoPanel.vue | 826 ++++++++++ .../conversations/ConversationChat.vue | 1419 +++++++++++++++++ .../conversations/ConversationFilters.vue | 466 ++++++ .../conversations/ConversationsList.vue | 815 ++++++++++ .../components/conversations/HandlerBadge.vue | 82 + .../components/conversations/MessageTag.vue | 41 + .../conversations/NewWhatsAppConversation.vue | 351 ++++ .../conversations/WhatsAppTemplatePicker.vue | 96 ++ .../conversations/WhatsAppTemplateSelect.vue | 343 ++++ .../src/composables/useConversationChat.ts | 449 ++++++ .../src/composables/useConversationsList.ts | 253 +++ frontend/src/composables/useOrgInboxRoom.ts | 46 + frontend/src/views/ConversationsView.vue | 606 +++++++ 19 files changed, 6252 insertions(+) create mode 100644 frontend/src/__tests__/components/HandlerBadge.spec.ts create mode 100644 frontend/src/__tests__/components/MessageStatusTicks.spec.ts create mode 100644 frontend/src/__tests__/components/QuickReplyChips.spec.ts create mode 100644 frontend/src/__tests__/utils/chatActions.spec.ts create mode 100644 frontend/src/__tests__/utils/widgetPresence.spec.ts create mode 100644 frontend/src/__tests__/views/ConversationsView.spec.ts create mode 100644 frontend/src/components/conversations/ChatInfoPanel.vue create mode 100644 frontend/src/components/conversations/ConversationChat.vue create mode 100644 frontend/src/components/conversations/ConversationFilters.vue create mode 100644 frontend/src/components/conversations/ConversationsList.vue create mode 100644 frontend/src/components/conversations/HandlerBadge.vue create mode 100644 frontend/src/components/conversations/MessageTag.vue create mode 100644 frontend/src/components/conversations/NewWhatsAppConversation.vue create mode 100644 frontend/src/components/conversations/WhatsAppTemplatePicker.vue create mode 100644 frontend/src/components/conversations/WhatsAppTemplateSelect.vue create mode 100644 frontend/src/composables/useConversationChat.ts create mode 100644 frontend/src/composables/useConversationsList.ts create mode 100644 frontend/src/composables/useOrgInboxRoom.ts create mode 100644 frontend/src/views/ConversationsView.vue diff --git a/frontend/src/__tests__/components/HandlerBadge.spec.ts b/frontend/src/__tests__/components/HandlerBadge.spec.ts new file mode 100644 index 0000000..d8e5e35 --- /dev/null +++ b/frontend/src/__tests__/components/HandlerBadge.spec.ts @@ -0,0 +1,53 @@ +/* +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 { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import HandlerBadge from '@/components/conversations/HandlerBadge.vue' + +const mountBadge = (props: Record) => + mount(HandlerBadge, { + props, + global: { stubs: { 'font-awesome-icon': true } }, + }) + +describe('HandlerBadge', () => { + it('labels an AI-handled chat', () => { + const badge = mountBadge({ chat: { status: 'open', user_id: null } }) + expect(badge.text()).toBe('AI') + expect(badge.find('.handler-ai').exists()).toBe(true) + }) + + it('labels a chat queued for a human', () => { + const badge = mountBadge({ chat: { status: 'transferred', user_id: null } }) + expect(badge.text()).toBe('等待人工客服') + expect(badge.find('.handler-waiting').exists()).toBe(true) + }) + + it('names the human holding the chat', () => { + const badge = mountBadge({ + chat: { status: 'open', user_id: 'u1', user_name: 'Priya' }, + currentUserId: 'u2', + }) + expect(badge.text()).toBe('Priya') + expect(badge.find('.handler-human').exists()).toBe(true) + }) + + it('renders nothing for a closed chat, which the status pill already states', () => { + const badge = mountBadge({ chat: { status: 'closed', user_id: 'u1' } }) + expect(badge.find('.handler-badge').exists()).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/components/MessageStatusTicks.spec.ts b/frontend/src/__tests__/components/MessageStatusTicks.spec.ts new file mode 100644 index 0000000..6969bd5 --- /dev/null +++ b/frontend/src/__tests__/components/MessageStatusTicks.spec.ts @@ -0,0 +1,48 @@ +/* +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 { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import MessageStatusTicks from '@/components/conversations/MessageStatusTicks.vue' + +const stubs = { global: { stubs: { 'font-awesome-icon': true } } } + +describe('MessageStatusTicks', () => { + it.each([ + ['sent', '已发送'], + ['delivered', '已送达'], + ['read', '已读'], + ])('labels a %s message', (status, label) => { + const ticks = mount(MessageStatusTicks, { props: { status }, ...stubs }) + expect(ticks.find('.status-ticks').attributes('aria-label')).toBe(label) + expect(ticks.find(`.status-ticks--${status}`).exists()).toBe(true) + }) + + it.each([null, undefined, 'failed', 'unknown'])( + 'shows nothing for %s — a failure has its own notice', + (status) => { + const ticks = mount(MessageStatusTicks, { props: { status }, ...stubs }) + expect(ticks.find('.status-ticks').exists()).toBe(false) + }, + ) +}) + +describe('MessageStatusTicks on an accent bubble', () => { + it('marks read with the inverted badge', () => { + const ticks = mount(MessageStatusTicks, { props: { status: 'read', onAccent: true }, ...stubs }) + expect(ticks.find('.status-ticks--on-accent.status-ticks--read').exists()).toBe(true) + }) +}) diff --git a/frontend/src/__tests__/components/QuickReplyChips.spec.ts b/frontend/src/__tests__/components/QuickReplyChips.spec.ts new file mode 100644 index 0000000..8f15549 --- /dev/null +++ b/frontend/src/__tests__/components/QuickReplyChips.spec.ts @@ -0,0 +1,54 @@ +/* +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 { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import QuickReplyChips from '@/components/conversations/QuickReplyChips.vue' +import MessageTag from '@/components/conversations/MessageTag.vue' + +const sent = (asText = false) => ({ + as_text: asText, + choices: [{ id: 'qr_0', text: 'Today' }, { id: 'qr_1', text: 'Tomorrow' }] +}) + +describe('QuickReplyChips', () => { + it('lists the choices that were sent, in order', () => { + const chips = mount(QuickReplyChips, { props: { sent: sent() } }) + expect(chips.findAll('.quick-reply-chip').map((c) => c.text())).toEqual(['Today', 'Tomorrow']) + expect(chips.find('.quick-reply-chips').attributes('aria-label')).toBe('已发送的选项') + }) + + it('says when the choices went out as a numbered list', () => { + const chips = mount(QuickReplyChips, { props: { sent: sent(true) } }) + expect(chips.find('.quick-reply-chips').attributes('aria-label')).toContain('编号列表') + }) + + it('renders nothing without a record', () => { + expect(mount(QuickReplyChips, { props: { sent: null } }).find('.quick-reply-chips').exists()).toBe(false) + expect(mount(QuickReplyChips, { props: { sent: { choices: [] } } }).find('.quick-reply-chips').exists()).toBe(false) + }) +}) + +describe('MessageTag', () => { + it('shows its icon and label', () => { + const tag = mount(MessageTag, { + props: { icon: 'fa-solid fa-plug', label: 'Sent by CoachIQ' }, + global: { stubs: { 'font-awesome-icon': true } } + }) + expect(tag.text()).toBe('Sent by CoachIQ') + expect(tag.find('font-awesome-icon-stub').attributes('icon')).toBe('fa-solid fa-plug') + }) +}) diff --git a/frontend/src/__tests__/utils/chatActions.spec.ts b/frontend/src/__tests__/utils/chatActions.spec.ts new file mode 100644 index 0000000..c5bf5bb --- /dev/null +++ b/frontend/src/__tests__/utils/chatActions.spec.ts @@ -0,0 +1,59 @@ +/* +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 { beforeEach, describe, expect, it, vi } from 'vitest' +import { routeChatToHuman } from '@/utils/chatActions' +import { chatService } from '@/services/chat' + +vi.mock('@/services/chat', () => ({ + chatService: { routeToHuman: vi.fn() }, +})) + +const toastSuccess = vi.fn() +const toastError = vi.fn() +vi.mock('vue-sonner', () => ({ + toast: { + success: (...args: unknown[]) => toastSuccess(...args), + error: (...args: unknown[]) => toastError(...args), + }, +})) + +describe('routeChatToHuman', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the updated chat the endpoint sends back', async () => { + const updated = { session_id: 's1', status: 'transferred', user_id: null } + vi.mocked(chatService.routeToHuman).mockResolvedValue(updated as never) + + await expect(routeChatToHuman('s1')).resolves.toBe(updated) + expect(toastSuccess).toHaveBeenCalled() + expect(toastError).not.toHaveBeenCalled() + }) + + it('surfaces the server reason and returns null on failure', async () => { + vi.mocked(chatService.routeToHuman).mockRejectedValue({ + response: { data: { detail: 'A human is already handling this chat' } }, + }) + + await expect(routeChatToHuman('s1')).resolves.toBeNull() + expect(toastError).toHaveBeenCalledWith( + '转交会话失败', + expect.objectContaining({ description: 'A human is already handling this chat' }) + ) + }) +}) diff --git a/frontend/src/__tests__/utils/widgetPresence.spec.ts b/frontend/src/__tests__/utils/widgetPresence.spec.ts new file mode 100644 index 0000000..85c3062 --- /dev/null +++ b/frontend/src/__tests__/utils/widgetPresence.spec.ts @@ -0,0 +1,56 @@ +/* +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 { describe, expect, it } from 'vitest' +import { presenceLine } from '@/utils/widgetPresence' + +describe('presenceLine', () => { + it('promises instant replies only while the AI is answering', () => { + const line = presenceLine({ mode: 'ai', available: true }) + expect(line).toEqual({ text: '在线 · 立即回复', online: true }) + }) + + it('ignores business hours for an AI agent — it answers at 3am too', () => { + expect(presenceLine({ mode: 'ai', available: false }).online).toBe(true) + }) + + it('sets a softer expectation when a person has to reply', () => { + const line = presenceLine({ mode: 'human', available: true }) + expect(line.text).toBe('在线 · 通常几分钟内回复') + expect(line.online).toBe(true) + }) + + it('says away outside business hours instead of claiming to be online', () => { + const line = presenceLine({ mode: 'human', available: false }) + expect(line.text).toContain('离开') + expect(line.online).toBe(false) + }) + + it('switches to the human wording once someone takes the chat over', () => { + // The agent still has AI enabled, but a person owns this conversation now. + expect(presenceLine({ mode: 'ai', available: true }, true).text).toBe( + '在线 · 通常几分钟内回复' + ) + expect(presenceLine({ mode: 'ai', available: false }, true).online).toBe(false) + }) + + it('keeps the old wording when the payload predates presence', () => { + // An older cached widget page sends nothing — do not declare the team away + // on no evidence. + expect(presenceLine(undefined).text).toBe('在线 · 立即回复') + expect(presenceLine({}).text).toBe('在线 · 立即回复') + }) +}) diff --git a/frontend/src/__tests__/views/ConversationsView.spec.ts b/frontend/src/__tests__/views/ConversationsView.spec.ts new file mode 100644 index 0000000..66bb7b5 --- /dev/null +++ b/frontend/src/__tests__/views/ConversationsView.spec.ts @@ -0,0 +1,189 @@ +/* +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 { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, VueWrapper } from '@vue/test-utils' +import { nextTick } from 'vue' +import ConversationsView from '../../views/ConversationsView.vue' +import { createPinia, setActivePinia } from 'pinia' + +// Mock vue-router (component uses useRoute for deep-link query params) +vi.mock('vue-router', () => ({ + useRoute: () => ({ query: {} }), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) +})) + +// Mock the chat service +vi.mock('@/services/chat', () => ({ + chatService: { + getRecentChats: vi.fn(), + getChatDetail: vi.fn(), + takeoverChat: vi.fn(), + reassignChat: vi.fn() + } +})) + +// Mock the components +vi.mock('@/layouts/DashboardLayout.vue', () => ({ + default: { + name: 'DashboardLayout', + template: '
' + } +})) + +vi.mock('@/components/conversations/ConversationsList.vue', () => ({ + default: { + name: 'ConversationsList', + template: '
', + props: ['conversations', 'loading', 'error', 'loadedCount', 'totalCount', 'hasMore', 'statusFilter', 'showChatInfo'], + emits: ['refresh', 'update-filter', 'load-more', 'chat-updated', 'chat-selected', 'clear-unread'] + } +})) + +vi.mock('@/components/conversations/ConversationFilters.vue', () => ({ + default: { + name: 'ConversationFilters', + template: '
', + props: ['showFilters', 'filterValues', 'users', 'agents', 'loadingUsers', 'loadingAgents'], + emits: ['toggle', 'apply', 'clear', 'update:filterValues'] + } +})) + +vi.mock('@/components/conversations/ChatInfoPanel.vue', () => ({ + default: { + name: 'ChatInfoPanel', + template: '
', + props: ['chatInfo', 'users'], + emits: ['close', 'refresh', 'chatUpdated', 'chatClosed'] + } +})) + +// Mock API service +vi.mock('@/services/api', () => ({ + default: { + get: vi.fn().mockResolvedValue({ data: [] }), + post: vi.fn().mockResolvedValue({ data: {} }), + put: vi.fn().mockResolvedValue({ data: {} }), + delete: vi.fn().mockResolvedValue({ data: {} }) + } +})) + +// Mock agent service +vi.mock('@/services/agent', () => ({ + agentService: { + getOrganizationAgents: vi.fn().mockResolvedValue([]) + } +})) + +// Import the mocked modules +import { chatService } from '@/services/chat' + +describe('ConversationsView', () => { + let wrapper: VueWrapper + + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('renders properly', () => { + wrapper = mount(ConversationsView) + expect(wrapper.find('.page-header').exists()).toBe(true) + expect(wrapper.find('h1').text()).toBe('会话') + }) + + it('shows loading state initially', () => { + wrapper = mount(ConversationsView) + const conversationsList = wrapper.findComponent({ name: 'ConversationsList' }) + expect(conversationsList.props('loading')).toBe(true) + }) + + it('loads conversations on mount', async () => { + const mockConversations = [ + { id: '1', title: 'Chat 1' }, + { id: '2', title: 'Chat 2' } + ] + + ;(chatService.getRecentChats as any).mockResolvedValue(mockConversations) + + wrapper = mount(ConversationsView) + + // Wait for the mounted hook and async operations to complete + await vi.waitFor(async () => { + await nextTick() + const conversationsList = wrapper.findComponent({ name: 'ConversationsList' }) + return conversationsList.props('loading') === false + }) + + const conversationsList = wrapper.findComponent({ name: 'ConversationsList' }) + expect(chatService.getRecentChats).toHaveBeenCalledWith({ + status: 'open,transferred', + skip: 0, + limit: 20 + }) + expect(conversationsList.props('conversations')).toEqual(mockConversations) + expect(conversationsList.props('loading')).toBe(false) + }) + + it('handles loading error', async () => { + const errorMessage = '加载会话失败' + ;(chatService.getRecentChats as any).mockRejectedValue(new Error('API Error')) + + wrapper = mount(ConversationsView) + + // Wait for the mounted hook and async operations to complete + await vi.waitFor(async () => { + await nextTick() + const conversationsList = wrapper.findComponent({ name: 'ConversationsList' }) + return conversationsList.props('loading') === false + }) + + const conversationsList = wrapper.findComponent({ name: 'ConversationsList' }) + expect(conversationsList.props('error')).toBe(errorMessage) + expect(conversationsList.props('loading')).toBe(false) + }) + + it('refreshes conversations when refresh event is emitted', async () => { + const mockConversations = [ + { id: '1', title: 'Chat 1' }, + { id: '2', title: 'Chat 2' } + ] + + ;(chatService.getRecentChats as any).mockResolvedValue(mockConversations) + + wrapper = mount(ConversationsView) + await vi.waitFor(async () => { + await nextTick() + const conversationsList = wrapper.findComponent({ name: 'ConversationsList' }) + return conversationsList.props('loading') === false + }) + + // Clear the first call to getRecentChats from mounted + vi.clearAllMocks() + + // Trigger refresh + const conversationsList = wrapper.findComponent({ name: 'ConversationsList' }) + await conversationsList.vm.$emit('refresh') + + // Wait for the refresh to complete + await vi.waitFor(async () => { + await nextTick() + return chatService.getRecentChats.mock.calls.length > 0 + }) + + expect(chatService.getRecentChats).toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/frontend/src/components/conversations/ChatInfoPanel.vue b/frontend/src/components/conversations/ChatInfoPanel.vue new file mode 100644 index 0000000..f8a3d43 --- /dev/null +++ b/frontend/src/components/conversations/ChatInfoPanel.vue @@ -0,0 +1,826 @@ + + + + + + + diff --git a/frontend/src/components/conversations/ConversationChat.vue b/frontend/src/components/conversations/ConversationChat.vue new file mode 100644 index 0000000..b19f4c8 --- /dev/null +++ b/frontend/src/components/conversations/ConversationChat.vue @@ -0,0 +1,1419 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/conversations/ConversationFilters.vue b/frontend/src/components/conversations/ConversationFilters.vue new file mode 100644 index 0000000..853540d --- /dev/null +++ b/frontend/src/components/conversations/ConversationFilters.vue @@ -0,0 +1,466 @@ + + + + + + + diff --git a/frontend/src/components/conversations/ConversationsList.vue b/frontend/src/components/conversations/ConversationsList.vue new file mode 100644 index 0000000..3ca41f2 --- /dev/null +++ b/frontend/src/components/conversations/ConversationsList.vue @@ -0,0 +1,815 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/conversations/HandlerBadge.vue b/frontend/src/components/conversations/HandlerBadge.vue new file mode 100644 index 0000000..cebb393 --- /dev/null +++ b/frontend/src/components/conversations/HandlerBadge.vue @@ -0,0 +1,82 @@ + + + + + + + diff --git a/frontend/src/components/conversations/MessageTag.vue b/frontend/src/components/conversations/MessageTag.vue new file mode 100644 index 0000000..34d99cf --- /dev/null +++ b/frontend/src/components/conversations/MessageTag.vue @@ -0,0 +1,41 @@ + + + + + + + diff --git a/frontend/src/components/conversations/NewWhatsAppConversation.vue b/frontend/src/components/conversations/NewWhatsAppConversation.vue new file mode 100644 index 0000000..b7fa7ea --- /dev/null +++ b/frontend/src/components/conversations/NewWhatsAppConversation.vue @@ -0,0 +1,351 @@ + + + + + + + diff --git a/frontend/src/components/conversations/WhatsAppTemplatePicker.vue b/frontend/src/components/conversations/WhatsAppTemplatePicker.vue new file mode 100644 index 0000000..fcaf37c --- /dev/null +++ b/frontend/src/components/conversations/WhatsAppTemplatePicker.vue @@ -0,0 +1,96 @@ + + + + + + + diff --git a/frontend/src/components/conversations/WhatsAppTemplateSelect.vue b/frontend/src/components/conversations/WhatsAppTemplateSelect.vue new file mode 100644 index 0000000..1c2df2b --- /dev/null +++ b/frontend/src/components/conversations/WhatsAppTemplateSelect.vue @@ -0,0 +1,343 @@ + + + + + + + diff --git a/frontend/src/composables/useConversationChat.ts b/frontend/src/composables/useConversationChat.ts new file mode 100644 index 0000000..9415af3 --- /dev/null +++ b/frontend/src/composables/useConversationChat.ts @@ -0,0 +1,449 @@ +/* +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' + +// Define valid chat statuses +type ChatStatus = 'open' | 'closed' | 'transferred' + +/** 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 + } +} \ No newline at end of file diff --git a/frontend/src/composables/useConversationsList.ts b/frontend/src/composables/useConversationsList.ts new file mode 100644 index 0000000..efdfecf --- /dev/null +++ b/frontend/src/composables/useConversationsList.ts @@ -0,0 +1,253 @@ +/* +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, onMounted, onBeforeUnmount, reactive } from 'vue' +import type { Conversation, ChatDetail, Message } from '@/types/chat' +import { formatDistanceToNow } from 'date-fns' +import { zhCN } from 'date-fns/locale' +import { chatService } from '@/services/chat' +import { socketService } from '@/services/socket' +import { userService } from '@/services/user' + +export function useConversationsList(props: { + conversations: Conversation[] + loading: boolean + error: string + hasMore?: boolean + loadingMore?: boolean +}, emit: { + (e: 'refresh'): void + (e: 'chatUpdated', data: ChatDetail): void + (e: 'clearUnread', sessionId: string): void + (e: 'updateFilter', status: 'open' | 'closed'): void + (e: 'loadMore'): void +}) { + const selectedChat = ref(null) + const selectedId = ref(null) + const chatLoading = ref(false) + const currentUserId = ref(userService.getUserId()) + const unreadMessages = reactive>({}) + const processedMessages = reactive>(new Set()) + + // Socket event handlers + const handleChatReply = (data: { + message: string + type: string + agent_name?: string + created_at: string + session_id: string + attributes?: any + attachments?: any[] + }) => { + // Create unique message identifier + const messageKey = `${data.session_id}-${data.created_at}`; + // Skip if already processed + if (processedMessages.has(messageKey)) return; + processedMessages.add(messageKey); + + // Add unread counter if not current chat + if (selectedId.value !== data.session_id) { + if (!unreadMessages[data.session_id]) { + unreadMessages[data.session_id] = 0 + } + unreadMessages[data.session_id]++ + } + + if (selectedChat.value && selectedId.value === data.session_id) { + // Ensure created_at is a valid ISO string + const created_at = new Date(data.created_at).toISOString() + + // Base message structure + const newMessage: Message = { + message: data.message, + message_type: data.type === 'agent_message' ? 'agent' : data.type, + created_at, + session_id: data.session_id, + attributes: data.attributes || {}, + agent_name: data.agent_name, + attachments: data.attachments || undefined + } + + // Check if message has Shopify data in attributes + if (data.attributes?.shopify_output && typeof data.attributes.shopify_output === 'object') { + newMessage.message_type = 'product' + newMessage.shopify_output = data.attributes.shopify_output + } + + // Create a completely new chat object with all properties + const updatedChat: ChatDetail = { + ...selectedChat.value, + messages: [...(selectedChat.value.messages || []), newMessage], + updated_at: created_at, + customer: { ...selectedChat.value.customer }, + agent: { ...selectedChat.value.agent }, + status: selectedChat.value.status, + user_id: selectedChat.value.user_id, + user_name: selectedChat.value.user_name, + session_id: selectedChat.value.session_id, + } + + // Use Promise.resolve().then() to break the reactive chain + // This prevents the recursive update error by deferring the state update + Promise.resolve().then(() => { + // Update the selected chat with the new object + selectedChat.value = updatedChat + + // Emit chat update event in a separate tick + Promise.resolve().then(() => { + emit('chatUpdated', updatedChat) + }) + }) + } + } + + const handleRoomEvent = (data: unknown) => { + console.log('Room event received:', data) + // You can handle join/leave events here if needed + } + + const setupSocketListeners = () => { + + socketService.on('chat_reply', handleChatReply) + socketService.on('room_event', handleRoomEvent) + } + + const cleanupSocketListeners = () => { + + socketService.off('chat_reply', handleChatReply) + socketService.off('room_event', handleRoomEvent) + } + + // Handle socket reconnection + const handleSocketReconnect = () => { + console.log('Socket reconnected, re-establishing listeners and room') + cleanupSocketListeners() + setupSocketListeners() + + // Rejoin room if necessary + + const userId = userService.getUserId() + if (userId) { + socketService.emit('join_room', { session_id: `user_${userId}` }) + } + } + + + + /** + * @param force refetch even when this chat is already open — the Refresh + * button. Live socket updates only arrive once a human takes a chat over; + * while the AI is answering, this is how an agent pulls the latest turns. + */ + const loadChatDetail = async (sessionId: string, force = false) => { + try { + // Only load full chat detail if it's a new chat or not loaded yet + if (force || !selectedChat.value || selectedChat.value.session_id !== sessionId) { + chatLoading.value = true + selectedId.value = sessionId + const detail = await chatService.getChatDetail(sessionId) + + // Debug: Log if messages have attachments + if (detail && detail.messages) { + detail.messages.forEach((msg, idx) => { + if (msg.attachments && msg.attachments.length > 0) { + console.log(`[API Response] Message ${idx} has ${msg.attachments.length} attachments`) + } + }) + } + + selectedChat.value = detail + + // Clear unread messages for this chat + if (unreadMessages[sessionId]) { + delete unreadMessages[sessionId] + emit('clearUnread', sessionId) + } + } + } catch (err) { + console.error('Failed to load chat:', err) + } finally { + chatLoading.value = false + } + } + + // Watch for conversations changes and load first conversation + watch(() => props.conversations, (newConversations) => { + if (newConversations.length > 0 && !selectedId.value) { + // Use Promise.resolve().then() to break the reactive chain + // This prevents the recursive update error + Promise.resolve().then(() => { + if (!selectedId.value) { + // loadChatDetail(newConversations[0].session_id) + } + }) + } + }, { immediate: true }) + + const formattedConversations = computed(() => { + // Ensure conversations is an array before mapping + if (!Array.isArray(props.conversations)) { + return [] + } + + return props.conversations.map(conv => ({ + ...conv, + timeAgo: formatDistanceToNow(new Date(conv.updated_at), { addSuffix: true, locale: zhCN }), + message_type: conv.attributes?.message_type || 'text', + shopify_output: conv.attributes?.shopify_output + })) + }) + + // Connect to socket and setup listeners on mount + onMounted(() => { + console.log('Connecting to socket') + socketService.connect() + setupSocketListeners() + socketService.onReconnect(handleSocketReconnect) + + // Join user-specific room + const userId = userService.getUserId() + if (userId) { + socketService.emit('join_room', { session_id: `user_${userId}` }) + } + }) + + // Cleanup on unmount + onBeforeUnmount(() => { + console.log('Disconnecting from socket') + const userId = userService.getUserId() + if (userId) { + socketService.emit('leave_room', { session_id: `user_${userId}` }) + } + cleanupSocketListeners() + socketService.offReconnect(handleSocketReconnect) + }) + + // Add method to clear unreads + const clearUnread = (sessionId: string) => { + delete unreadMessages[sessionId] + } + + return { + selectedChat, + selectedId, + chatLoading, + formattedConversations, + loadChatDetail, + unreadMessages, + clearUnread + } +} \ No newline at end of file diff --git a/frontend/src/composables/useOrgInboxRoom.ts b/frontend/src/composables/useOrgInboxRoom.ts new file mode 100644 index 0000000..6091dc6 --- /dev/null +++ b/frontend/src/composables/useOrgInboxRoom.ts @@ -0,0 +1,46 @@ +/* +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. + +Joins the per-org inbox room on the /agent namespace, where live updates +about any of the org's conversations arrive (delivery receipts on +AI-handled chats have no agent of their own to address). Only for people who +can see chats nobody owns — the backend refuses everyone else. +*/ + +import { onBeforeUnmount, onMounted } from 'vue' +import { socketService } from '@/services/socket' +import { userService } from '@/services/user' +import { permissionChecks } from '@/utils/permissions' + +export function useOrgInboxRoom() { + const orgId = userService.getCurrentUser()?.organization_id + const room = orgId && permissionChecks.canViewUnassignedChats() ? `org_inbox_${orgId}` : null + + const joinRoom = () => { + if (room) socketService.emit('join_room', { session_id: room }) + } + + onMounted(() => { + if (!room) return + joinRoom() + socketService.onReconnect(joinRoom) + }) + + onBeforeUnmount(() => { + if (!room) return + socketService.emit('leave_room', { session_id: room }) + socketService.offReconnect(joinRoom) + }) +} diff --git a/frontend/src/views/ConversationsView.vue b/frontend/src/views/ConversationsView.vue new file mode 100644 index 0000000..0c5b8aa --- /dev/null +++ b/frontend/src/views/ConversationsView.vue @@ -0,0 +1,606 @@ + + + + + + + \ No newline at end of file