H-16: align takeover with channel AI workflow (#2)

* feat(conversations): complete manual AI takeover

* fix(conversations): align AI takeover flow with channel AI

* fix(conversations): close takeover review gaps

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-12 22:57:06 +08:00
committed by GitHub
co-authored by rogee
parent 4bf6ba6b20
commit 83a8ab315a
31 changed files with 604 additions and 201 deletions
@@ -8,7 +8,7 @@ class AssignableAgents extends ApiClient {
get(inboxIds) {
return axios.get(this.url, {
params: { inbox_ids: inboxIds, include_agent_bots: true },
params: { inbox_ids: inboxIds },
});
}
}
@@ -62,13 +62,20 @@ class ConversationApi extends ApiClient {
});
}
assignAgent({ conversationId, agentId, assigneeType }) {
assignAgent({ conversationId, agentId }) {
return axios.post(`${this.url}/${conversationId}/assignments`, {
assignee_id: agentId,
assignee_type: assigneeType,
});
}
startAITakeover(conversationId) {
return axios.post(`${this.url}/${conversationId}/ai_takeover`);
}
exitAITakeover(conversationId) {
return axios.delete(`${this.url}/${conversationId}/ai_takeover`);
}
assignTeam({ conversationId, teamId }) {
const params = { team_id: teamId };
return axios.post(`${this.url}/${conversationId}/assignments`, params);
@@ -111,7 +118,9 @@ class ConversationApi extends ApiClient {
}
sendEmailTranscript({ conversationId, email }) {
return axios.post(`${this.url}/${conversationId}/transcript`, { email });
return axios.post(`${this.url}/${conversationId}/transcript`, {
email,
});
}
updateCustomAttributes({ conversationId, customAttributes }) {
@@ -23,7 +23,6 @@ describe('#AssignableAgentsAPI', () => {
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
params: {
inbox_ids: [1],
include_agent_bots: true,
},
});
});
@@ -12,6 +12,8 @@ describe('#ConversationAPI', () => {
expect(conversationAPI).toHaveProperty('toggleStatus');
expect(conversationAPI).toHaveProperty('assignAgent');
expect(conversationAPI).toHaveProperty('assignTeam');
expect(conversationAPI).toHaveProperty('startAITakeover');
expect(conversationAPI).toHaveProperty('exitAITakeover');
expect(conversationAPI).toHaveProperty('markMessageRead');
expect(conversationAPI).toHaveProperty('toggleTyping');
expect(conversationAPI).toHaveProperty('mute');
@@ -79,7 +81,10 @@ describe('#ConversationAPI', () => {
});
it('#toggleStatus', () => {
conversationAPI.toggleStatus({ conversationId: 12, status: 'online' });
conversationAPI.toggleStatus({
conversationId: 12,
status: 'online',
});
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/toggle_status`,
{
@@ -93,17 +98,29 @@ describe('#ConversationAPI', () => {
conversationAPI.assignAgent({
conversationId: 12,
agentId: 34,
assigneeType: 'AgentBot',
});
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments`,
{
assignee_id: 34,
assignee_type: 'AgentBot',
}
);
});
it('#startAITakeover', () => {
conversationAPI.startAITakeover(12);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/conversations/12/ai_takeover'
);
});
it('#exitAITakeover', () => {
conversationAPI.exitAITakeover(12);
expect(axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/conversations/12/ai_takeover'
);
});
it('#assignTeam', () => {
conversationAPI.assignTeam({ conversationId: 12, teamId: 1 });
expect(axiosMock.post).toHaveBeenCalledWith(
@@ -885,10 +885,14 @@ export default {
copilotAcceptedMessage = ''
) {
try {
await this.$store.dispatch(
const sentMessage = await this.$store.dispatch(
'createPendingMessageAndSend',
messagePayload
);
if (sentMessage?.ai_takeover_exited) {
useAlert(this.$t('CONVERSATION.AI_TAKEOVER.EXIT_SUCCESS'));
await this.$store.dispatch('getConversation', this.conversationId);
}
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
emitter.emit(BUS_EVENTS.MESSAGE_SENT);
this.removeFromDraft();
@@ -62,6 +62,29 @@ const showBotHandoffBanner = computed(
currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING
);
const aiTakeoverActive = computed(
() => currentChat.value?.ai_takeover_active === true
);
const toggleAITakeover = async () => {
try {
const wasActive = aiTakeoverActive.value;
const action = wasActive ? 'exitAITakeover' : 'startAITakeover';
await store.dispatch(action, currentChat.value?.id);
useAlert(
t(
wasActive
? 'CONVERSATION.AI_TAKEOVER.EXIT_SUCCESS'
: 'CONVERSATION.AI_TAKEOVER.START_SUCCESS'
)
);
} catch (error) {
useAlert(
error?.response?.data?.error || t('CONVERSATION.AI_TAKEOVER.ERROR')
);
}
};
const botHandoffActionLabel = computed(() => {
return assignedAgent.value?.id === currentUser.value?.id
? t('CONVERSATION.BOT_HANDOFF_REOPEN_ACTION')
@@ -109,6 +132,19 @@ const onClickBotHandoff = async () => {
</script>
<template>
<div class="mx-2 mb-2 flex justify-end">
<button
type="button"
class="rounded-md border border-n-slate-5 px-3 py-1.5 text-sm font-medium text-n-slate-12 hover:bg-n-alpha-2"
@click="toggleAITakeover"
>
{{
aiTakeoverActive
? $t('CONVERSATION.AI_TAKEOVER.EXIT')
: $t('CONVERSATION.AI_TAKEOVER.START')
}}
</button>
</div>
<Banner
v-if="showSelfAssignBanner && !showBotHandoffBanner"
action-button-variant="ghost"
@@ -41,9 +41,7 @@ const assignableAgentsList = useMapGetter(
'inboxAssignableAgents/getAssignableAgents'
);
const assignableAgents = computed(() =>
assignableAgentsList
.value(props.selectedInboxes.join(','))
.filter(agent => agent.assignee_type !== 'AgentBot')
assignableAgentsList.value(props.selectedInboxes.join(','))
);
const agentMenuItems = computed(() => {
@@ -59,25 +59,13 @@ export function useBulkActions() {
// Same method used in context menu, conversationId being passed from there.
async function onAssignAgent(agent, conversationId = null) {
try {
if (conversationId && agent.assignee_type === 'AgentBot') {
await Promise.all(
conversationId.map(id =>
store.dispatch('assignAgent', {
conversationId: id,
agentId: agent.id,
assigneeType: agent.assignee_type,
})
)
);
} else {
await store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: conversationId || selectedConversations.value,
fields: {
assignee_id: agent.id,
},
});
}
await store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: conversationId || selectedConversations.value,
fields: {
assignee_id: agent.id,
},
});
store.dispatch('bulkActions/clearSelectedConversationIds');
if (conversationId) {
useAlert(
@@ -38,7 +38,7 @@ export const getAgentsByUpdatedPresence = (
currentAccountId
) => {
const agentsWithDynamicPresenceUpdate = agents.map(item =>
item.assignee_type !== 'AgentBot' && item.id === currentUser.id
item.id === currentUser.id
? {
...item,
availability_status: currentUser.accounts.find(
@@ -41,6 +41,13 @@
"BOT_HANDOFF_REOPEN_ACTION": "Mark conversation open",
"BOT_HANDOFF_SUCCESS": "Conversation has been handed over to you",
"BOT_HANDOFF_ERROR": "Failed to take over the conversation. Please try again.",
"AI_TAKEOVER": {
"START": "AI takeover",
"EXIT": "Exit AI takeover",
"START_SUCCESS": "AI takeover started",
"EXIT_SUCCESS": "AI takeover ended",
"ERROR": "AI takeover failed. Please try again."
},
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
@@ -41,6 +41,13 @@
"BOT_HANDOFF_REOPEN_ACTION": "标记对话已打开",
"BOT_HANDOFF_SUCCESS": "对话已分配给您",
"BOT_HANDOFF_ERROR": "接管对话失败,请再试一次。",
"AI_TAKEOVER": {
"START": "AI 接管",
"EXIT": "退出 AI 接管",
"START_SUCCESS": "AI 已接管",
"EXIT_SUCCESS": "已退出 AI 接管",
"ERROR": "AI 接管操作失败,请重试"
},
"TWILIO_WHATSAPP_CAN_REPLY": "您只能使用模板信息回复此会话,原因是",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 小时消息窗口限制",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "此 Instagram 帐户已迁移到新的 Instagram 通道收件箱。 所有新消息都将在这里显示。您将无法从这个对话中发送消息。",
@@ -93,7 +93,6 @@ export default {
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId,
assigneeType: agent?.assignee_type,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
@@ -219,15 +219,11 @@ const actions = {
}
},
assignAgent: async (
{ dispatch },
{ conversationId, agentId, assigneeType }
) => {
assignAgent: async ({ dispatch }, { conversationId, agentId }) => {
try {
const response = await ConversationApi.assignAgent({
conversationId,
agentId,
assigneeType,
});
dispatch('setCurrentChatAssignee', {
conversationId,
@@ -238,6 +234,18 @@ const actions = {
}
},
startAITakeover: async ({ commit }, conversationId) => {
const { data } = await ConversationApi.startAITakeover(conversationId);
commit(types.UPDATE_CONVERSATION, data);
return data;
},
exitAITakeover: async ({ commit }, conversationId) => {
const { data } = await ConversationApi.exitAITakeover(conversationId);
commit(types.UPDATE_CONVERSATION, data);
return data;
},
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
commit(types.ASSIGN_AGENT, { conversationId, assignee });
},
@@ -299,7 +307,7 @@ const actions = {
createPendingMessageAndSend: async ({ dispatch }, data) => {
const pendingMessage = createPendingMessage(data);
dispatch('sendMessageWithData', pendingMessage);
return dispatch('sendMessageWithData', pendingMessage);
},
sendMessageWithData: async ({ commit }, pendingMessage) => {
@@ -318,6 +326,7 @@ const actions = {
commit(types.ADD_CONVERSATION_ATTACHMENTS, {
...response.data,
});
return response.data;
} catch (error) {
const errorMessage = error.response
? error.response.data.error