From 5da5f910dadf7a58b510af1c5b023523bdabedc3 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 6 Oct 2026 21:45:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(handoff):=20=E8=BD=AC=E4=BA=BA=E5=B7=A5?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E3=80=81=E5=BE=85=E6=8E=A5=E7=AE=A1=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E4=B8=8E=E6=8E=A5=E5=BE=85=E6=97=B6=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同会话内 AI 转人工,待接管列表按分组和在线状态展示,接管后保留上下文。 --- backend/app/services/agent_rooms.py | 95 +++++++ backend/app/services/human_routing.py | 165 ++++++++++++ backend/tests/services/test_agent_rooms.py | 56 ++++ backend/tests/services/test_human_routing.py | 113 ++++++++ .../__tests__/views/HumanAgentView.spec.ts | 124 +++++++++ frontend/src/views/HumanAgentView.vue | 250 ++++++++++++++++++ 6 files changed, 803 insertions(+) create mode 100644 backend/app/services/agent_rooms.py create mode 100644 backend/app/services/human_routing.py create mode 100644 backend/tests/services/test_agent_rooms.py create mode 100644 backend/tests/services/test_human_routing.py create mode 100644 frontend/src/__tests__/views/HumanAgentView.spec.ts create mode 100644 frontend/src/views/HumanAgentView.vue diff --git a/backend/app/services/agent_rooms.py b/backend/app/services/agent_rooms.py new file mode 100644 index 0000000..98eaaf9 --- /dev/null +++ b/backend/app/services/agent_rooms.py @@ -0,0 +1,95 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy.orm import Session + +from app.core.auth import has_any_permission +from app.core.socketio import sio +from app.repositories.user import UserRepository +from app.services.chat_notifications import UNASSIGNED_CHAT_VIEWERS + +# Per-organization room every inbox page joins (see handle_join_room), for +# updates about any of the org's conversations — including AI-owned ones, +# which have no handling agent to address. +ORG_INBOX_ROOM_PREFIX = "org_inbox_" + + +# chat_reply `type`s the inbox appends to a conversation +USER_MESSAGE = 'user_message' +BOT_MESSAGE = 'bot' + + +def org_inbox_room(organization_id) -> str: + return f"{ORG_INBOX_ROOM_PREFIX}{organization_id}" + + +def is_org_inbox_room(room: str) -> bool: + return room.startswith(ORG_INBOX_ROOM_PREFIX) + + +def may_join_org_inbox(db: Session, socket_session: dict, room: str) -> bool: + """Whether a dashboard socket may join an org inbox room: its own + organization's, and only for people who can see chats nobody owns.""" + if str(socket_session.get('organization_id')) != room.removeprefix(ORG_INBOX_ROOM_PREFIX): + return False + user_id = socket_session.get('user_id') + user = UserRepository(db).get_user(user_id) if user_id else None + return (user is not None and user.is_active + and has_any_permission(user, UNASSIGNED_CHAT_VIEWERS)) + + +async def emit_to_session_agents(event: str, payload: dict, session_id, + user_id: Optional[object] = None) -> None: + """Push a live update to the dashboard for one conversation. + + The inbox listens on the handling agent's personal room (user_); the + session room reaches anyone who joined that conversation explicitly. + """ + if user_id is not None: + await sio.emit(event, payload, room=f"user_{user_id}", namespace='/agent') + await sio.emit(event, payload, room=str(session_id), namespace='/agent') + + +async def emit_to_org_inbox(event: str, payload: dict, organization_id, + user_id: Optional[object] = None) -> None: + """Push a live update to every inbox open on the organization, and to the + handling agent's personal room (where a chat they own is listening).""" + await sio.emit(event, payload, room=org_inbox_room(organization_id), namespace='/agent') + if user_id is not None: + await sio.emit(event, payload, room=f"user_{user_id}", namespace='/agent') + + +async def relay_stored_message(session_record, stored, message_type: str) -> None: + """Push a stored message into the dashboard conversation live, the way + the widget's chat_reply events do — a customer message, or one sent from + outside the inbox (e.g. by an external app) into a chat a human holds.""" + session_id = str(session_record.session_id) + payload = { + 'message': stored.message, + 'message_id': stored.id, + 'type': message_type, + 'transfer_to_human': False, + 'session_id': session_id, + # The inbox appends the message live off this field; without it the + # handler builds an invalid Date and the message only shows on refetch. + 'created_at': (stored.created_at or datetime.now(timezone.utc)).isoformat(), + # Carries e.g. the button-reply marker, so tags show live too + 'attributes': stored.attributes or {}, + } + await emit_to_session_agents('chat_reply', payload, session_id, session_record.user_id) diff --git a/backend/app/services/human_routing.py b/backend/app/services/human_routing.py new file mode 100644 index 0000000..2ad02ec --- /dev/null +++ b/backend/app/services/human_routing.py @@ -0,0 +1,165 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +from typing import Optional +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.channels.constants import MessageSource +from app.core.logger import get_logger +from app.models.session_to_agent import SessionStatus +from app.repositories.chat import ChatRepository +from app.repositories.group import GroupRepository +from app.repositories.session_to_agent import SessionToAgentRepository +from app.repositories.user import UserRepository +from app.services.chat_notifications import UNASSIGNED_CHAT_VIEWERS +from app.services.message_delivery import deliver_to_customer +from app.services.notifications import ChatNotificationEvent, notify_chat_event + +logger = get_logger(__name__) + +# The widget shows handover state in its own UI, so only external channels need +# a message spelling out what just happened. +WEB_CHANNEL = 'web' + +# Sent when a chat is queued for the team but nobody has picked it up yet. The +# widget composes an availability-aware line of its own; channels get this. +QUEUED_FOR_HUMAN_NOTICE = ( + "I'm passing this to a member of our team. Someone will reply here shortly." +) + +# First reply on an agent whose AI is switched off. Deliberately says nothing +# about transferring or passing anything on — there was no AI in the +# conversation to hand it over. It exists so the visitor is not left staring at +# an empty thread wondering whether the message arrived. +HUMAN_ONLY_ACK = ( + "Thanks for your message. Someone from our team will reply here shortly." +) + + +async def notify_customer( + db: Session, + session, + message: str, + user_id: Optional[str] = None, + channels_only: bool = True, +) -> None: + """Record a message in the thread and push it to the customer. + + Best-effort: a failed notice must never fail the action that triggered it. + Skips the widget by default, which renders handover state in its own UI — + pass channels_only=False for a message the visitor should actually read. + """ + if channels_only and getattr(session, 'channel', None) in (None, WEB_CHANNEL): + return + try: + stored = ChatRepository(db).create_message({ + 'message': message, + 'message_type': 'agent' if user_id else 'bot', + 'session_id': str(session.session_id), + 'organization_id': str(session.organization_id), + 'agent_id': str(session.agent_id) if session.agent_id else None, + 'customer_id': str(session.customer_id) if session.customer_id else None, + 'user_id': user_id, + 'attributes': {'channel': session.channel, 'handover_notice': True}, + }) + result = await deliver_to_customer(db, session, { + 'message': message, + 'type': 'chat_response', + }, message_id=stored.id, source=MessageSource.SYSTEM) + if not result.ok: + logger.warning( + f"Handover notice not delivered on {session.channel}: {result.reason}") + except Exception as e: + logger.error(f"Failed sending handover notice: {str(e)}") + + +def _transfer_recipients(db: Session, session, group_id) -> list: + """Who to tell that a chat is waiting. + + A group queue notifies that group; without one the chat belongs to nobody, + so it goes to whoever could actually open it from the inbox. + """ + if group_id: + group = GroupRepository(db).get_group_with_users(str(group_id)) + users = group.users if group else [] + else: + users = UserRepository(db).get_users_with_any_permission( + session.organization_id, UNASSIGNED_CHAT_VIEWERS + ) + return [user.id for user in users] + + +async def route_session_to_human( + db: Session, + session, + *, + reason: Optional[str] = None, + description: Optional[str] = None, + group_id: Optional[str | UUID] = None, + notify: bool = True, +) -> bool: + """Take the AI out of a conversation and queue it for the team. + + `TRANSFERRED` with no assignee is the state every AI path already checks: + the widget stops calling the model and answers with an availability line, + and channels relay straight to the dashboard. So this needs no new status — + it puts the session into the one the guards already understand. + + Returns False without touching anything when there is nothing to route: a + human already holds it, it is closed, or it is queued already. That makes + the call idempotent, so a double click cannot double-notify the team or + send the customer the holding message twice. + """ + if session is None: + return False + if session.user_id is not None: + return False + if session.status in (SessionStatus.CLOSED, SessionStatus.TRANSFERRED): + return False + + updates = { + 'status': SessionStatus.TRANSFERRED, + 'transfer_reason': reason, + 'transfer_description': description, + } + # Only when a queue was named: passing None would clear a group the AI's + # own transfer had already set. + if group_id: + updates['group_id'] = group_id + + if not SessionToAgentRepository(db).update_session(session.session_id, updates): + return False + + if notify: + recipients = _transfer_recipients(db, session, group_id or session.group_id) + if recipients: + await notify_chat_event( + db=db, + user_ids=recipients, + event=ChatNotificationEvent.CHAT_TRANSFER, + title="Chat waiting for a human", + message=description or "A chat is waiting for someone to take it over.", + metadata={ + "session_id": str(session.session_id), + "transfer_reason": reason, + "transfer_description": description, + }, + ) + + await notify_customer(db, session, QUEUED_FOR_HUMAN_NOTICE) + return True diff --git a/backend/tests/services/test_agent_rooms.py b/backend/tests/services/test_agent_rooms.py new file mode 100644 index 0000000..f066055 --- /dev/null +++ b/backend/tests/services/test_agent_rooms.py @@ -0,0 +1,56 @@ +""" +Copyright 2024-2026 ChatterMate + +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. +""" + +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from app.models.permission import Permission +from app.services.agent_rooms import may_join_org_inbox + + +@pytest.fixture +def viewer(db, test_user, test_role): + permission = Permission(name="view_unassigned_chats") + db.add(permission) + test_role.permissions = [*test_role.permissions, permission] + db.commit() + return test_user + + +def _socket(user, org=None): + return {"user_id": str(user.id), "organization_id": str(org or user.organization_id)} + + +def test_a_viewer_of_unowned_chats_may_join_their_orgs_room(db, viewer): + assert may_join_org_inbox(db, _socket(viewer), f"org_inbox_{viewer.organization_id}") + + +def test_not_another_orgs_room(db, viewer): + assert not may_join_org_inbox(db, _socket(viewer), f"org_inbox_{uuid4()}") + + +def test_not_without_the_permission(db, test_user): + assert not may_join_org_inbox(db, _socket(test_user), f"org_inbox_{test_user.organization_id}") + + +def test_not_once_deactivated_or_deleted(db, viewer): + viewer.is_active = False + db.commit() + assert not may_join_org_inbox(db, _socket(viewer), f"org_inbox_{viewer.organization_id}") + ghost = SimpleNamespace(id=uuid4(), organization_id=viewer.organization_id) + assert not may_join_org_inbox(db, _socket(ghost), f"org_inbox_{viewer.organization_id}") diff --git a/backend/tests/services/test_human_routing.py b/backend/tests/services/test_human_routing.py new file mode 100644 index 0000000..d8fccb7 --- /dev/null +++ b/backend/tests/services/test_human_routing.py @@ -0,0 +1,113 @@ +""" +Copyright 2024-2026 ChatterMate + +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. + +Taking the AI out of a conversation: the state transition behind the per-chat +"hand to my team" action. +""" +from datetime import datetime, timezone +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest + +from app.models.session_to_agent import SessionToAgent, SessionStatus +from app.models.chat_history import ChatHistory +from app.services.human_routing import notify_customer, route_session_to_human +from app.services.message_delivery import DeliveryResult + + +@pytest.fixture +def open_session(db, test_agent, test_customer, test_organization) -> SessionToAgent: + session = SessionToAgent( + session_id=uuid4(), + agent_id=test_agent.id, + customer_id=test_customer.id, + organization_id=test_organization.id, + status=SessionStatus.OPEN, + assigned_at=datetime.now(timezone.utc), + ) + db.add(session) + db.commit() + db.refresh(session) + return session + + +def _silenced(): + """Notifications and channel delivery are covered by their own tests.""" + return ( + patch("app.services.human_routing.notify_chat_event", new=AsyncMock()), + patch("app.services.human_routing.notify_customer", new=AsyncMock()), + ) + + +@pytest.mark.asyncio +async def test_routing_queues_the_chat_without_an_assignee(db, open_session): + """TRANSFERRED with user_id null is the state every AI guard checks.""" + notify, customer = _silenced() + with notify, customer: + assert await route_session_to_human( + db, open_session, reason="DIRECT_REQUEST", description="test") is True + + db.refresh(open_session) + assert open_session.status == SessionStatus.TRANSFERRED + assert open_session.user_id is None + + +@pytest.mark.asyncio +async def test_routing_refuses_a_claimed_or_closed_chat(db, open_session, test_user): + """Nothing to take away from the AI — it is not answering either one.""" + notify, customer = _silenced() + + open_session.user_id = test_user.id + db.commit() + with notify, customer: + assert await route_session_to_human(db, open_session, reason="x") is False + + open_session.user_id = None + open_session.status = SessionStatus.CLOSED + db.commit() + notify, customer = _silenced() + with notify, customer: + assert await route_session_to_human(db, open_session, reason="x") is False + + db.refresh(open_session) + assert open_session.status == SessionStatus.CLOSED + + +@pytest.mark.asyncio +async def test_routing_is_idempotent(db, open_session): + """A second call must not re-notify or re-message the customer.""" + notify, customer = _silenced() + with notify, customer: + assert await route_session_to_human(db, open_session, reason="x") is True + + with patch("app.services.human_routing.notify_chat_event", new=AsyncMock()) as n, \ + patch("app.services.human_routing.notify_customer", new=AsyncMock()) as c: + assert await route_session_to_human(db, open_session, reason="x") is False + assert not n.called + assert not c.called + + +@pytest.mark.asyncio +async def test_handover_notice_is_delivered_against_its_stored_row(db, open_session): + """The notice's channel message id must land on the notice's own row.""" + open_session.channel = "whatsapp" + db.commit() + with patch("app.services.human_routing.deliver_to_customer", + new=AsyncMock(return_value=DeliveryResult(ok=True))) as deliver: + await notify_customer(db, open_session, "You're now connected with our team.") + + notice = db.query(ChatHistory).filter_by(session_id=open_session.session_id).one() + assert deliver.await_args.kwargs["message_id"] == notice.id diff --git a/frontend/src/__tests__/views/HumanAgentView.spec.ts b/frontend/src/__tests__/views/HumanAgentView.spec.ts new file mode 100644 index 0000000..95d7a06 --- /dev/null +++ b/frontend/src/__tests__/views/HumanAgentView.spec.ts @@ -0,0 +1,124 @@ +/* +Copyright 2024-2026 ChatterMate + +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, beforeEach, vi } from 'vitest' +import { mount, VueWrapper } from '@vue/test-utils' +import HumanAgentView from '../../views/HumanAgentView.vue' +import { createPinia, setActivePinia } from 'pinia' + +// Mock the components +vi.mock('@/layouts/DashboardLayout.vue', () => ({ + default: { + name: 'DashboardLayout', + template: '
' + } +})) + +vi.mock('@/components/human-agent/UserList.vue', () => ({ + default: { + name: 'UserList', + template: '
' + } +})) + +vi.mock('@/components/human-agent/GroupList.vue', () => ({ + default: { + name: 'GroupList', + template: '
' + } +})) + +vi.mock('@/components/human-agent/RoleList.vue', () => ({ + default: { + name: 'RoleList', + template: '
' + } +})) + +describe('HumanAgentView', () => { + let wrapper: VueWrapper + + beforeEach(() => { + setActivePinia(createPinia()) + wrapper = mount(HumanAgentView) + }) + + it('renders properly', () => { + expect(wrapper.find('.human-agent-container').exists()).toBe(true) + expect(wrapper.find('.tab-pills').exists()).toBe(true) + expect(wrapper.find('.content-area').exists()).toBe(true) + }) + + it('displays all tab buttons', () => { + const tabButtons = wrapper.findAll('.tab-pill') + expect(tabButtons).toHaveLength(3) + // Labels may include an optional count badge, so match on the label text. + expect(tabButtons[0].text()).toContain('人员') + expect(tabButtons[1].text()).toContain('团队') + expect(tabButtons[2].text()).toContain('角色') + }) + + it('shows UserList component by default', () => { + expect(wrapper.findComponent({ name: 'UserList' }).exists()).toBe(true) + expect(wrapper.findComponent({ name: 'GroupList' }).exists()).toBe(false) + expect(wrapper.findComponent({ name: 'RoleList' }).exists()).toBe(false) + }) + + it('switches to GroupList when Groups tab is clicked', async () => { + const groupsTab = wrapper.findAll('.tab-pill')[1] + await groupsTab.trigger('click') + + expect(wrapper.findComponent({ name: 'UserList' }).exists()).toBe(false) + expect(wrapper.findComponent({ name: 'GroupList' }).exists()).toBe(true) + expect(wrapper.findComponent({ name: 'RoleList' }).exists()).toBe(false) + }) + + it('switches to RoleList when Roles tab is clicked', async () => { + const rolesTab = wrapper.findAll('.tab-pill')[2] + await rolesTab.trigger('click') + + expect(wrapper.findComponent({ name: 'UserList' }).exists()).toBe(false) + expect(wrapper.findComponent({ name: 'GroupList' }).exists()).toBe(false) + expect(wrapper.findComponent({ name: 'RoleList' }).exists()).toBe(true) + }) + + it('applies active class to selected tab button', async () => { + // Initially Users tab should be active + let tabButtons = wrapper.findAll('.tab-pill') + expect(tabButtons[0].classes()).toContain('active') + expect(tabButtons[1].classes()).not.toContain('active') + expect(tabButtons[2].classes()).not.toContain('active') + + // Click Groups tab + await tabButtons[1].trigger('click') + tabButtons = wrapper.findAll('.tab-pill') + expect(tabButtons[0].classes()).not.toContain('active') + expect(tabButtons[1].classes()).toContain('active') + expect(tabButtons[2].classes()).not.toContain('active') + + // Click Roles tab + await tabButtons[2].trigger('click') + tabButtons = wrapper.findAll('.tab-pill') + expect(tabButtons[0].classes()).not.toContain('active') + expect(tabButtons[1].classes()).not.toContain('active') + expect(tabButtons[2].classes()).toContain('active') + }) + + it('is wrapped in DashboardLayout', () => { + const dashboardLayout = wrapper.findComponent({ name: 'DashboardLayout' }) + expect(dashboardLayout.exists()).toBe(true) + }) +}) \ No newline at end of file diff --git a/frontend/src/views/HumanAgentView.vue b/frontend/src/views/HumanAgentView.vue new file mode 100644 index 0000000..498990b --- /dev/null +++ b/frontend/src/views/HumanAgentView.vue @@ -0,0 +1,250 @@ + + + + + + +