From cbdac458240f0948857b47fa949c139295ec57c0 Mon Sep 17 00:00:00 2001 From: Pranav Date: Fri, 16 May 2025 19:27:57 -0700 Subject: [PATCH] feat: Improve Captain interactions, activity messages (#11493) Show captain messages under the name of the assistant which generated the message. - Add support for `Captain::Assistant` sender type - Add push_event_data for captain_assistants - Add activity message handler for captain_assistants - Update UI to show captain messages under the name of the assistant - Fix the issue where openAI errors when image is sent - Add support for custom name of the assistant --------- Co-authored-by: Muhsin Keloth Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../components-next/message/Message.vue | 12 +++++-- .../components-next/message/constants.js | 1 + app/models/conversation.rb | 1 + app/models/message.rb | 4 +-- config/locales/en.yml | 3 ++ config/locales/pt_BR.yml | 9 ++++-- .../conversation/response_builder_job.rb | 25 ++++++++++++--- ...ox_pending_conversations_resolution_job.rb | 21 +++++++++---- enterprise/app/models/captain/assistant.rb | 31 +++++++++++++++++++ .../enterprise/activity_message_handler.rb | 17 ++++++++-- .../captain/llm/assistant_chat_service.rb | 2 +- .../captain/llm/system_prompts_service.rb | 4 +-- .../slack/send_on_slack_service.rb | 6 ++-- .../assets/images/dashboard/captain/logo.svg | 6 ++++ spec/enterprise/models/message_spec.rb | 25 +++++++++++++++ 15 files changed, 143 insertions(+), 24 deletions(-) create mode 100644 public/assets/images/dashboard/captain/logo.svg create mode 100644 spec/enterprise/models/message_spec.rb diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index 63a779a7a..cf6cc0881 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -186,12 +186,20 @@ const isBotOrAgentMessage = computed(() => { return true; } const senderId = props.senderId ?? props.sender?.id; - const senderType = props.senderType ?? props.sender?.type; + const senderType = props.sender?.type ?? props.senderType; if (!senderType || !senderId) { return true; } + if ( + [SENDER_TYPES.AGENT_BOT, SENDER_TYPES.CAPTAIN_ASSISTANT].includes( + senderType + ) + ) { + return true; + } + return senderType.toLowerCase() === SENDER_TYPES.USER.toLowerCase(); }); @@ -406,7 +414,7 @@ const avatarInfo = computed(() => { const { name, type, avatarUrl, thumbnail } = sender || {}; // If sender type is agent bot, use avatarUrl - if (type === SENDER_TYPES.AGENT_BOT) { + if ([SENDER_TYPES.AGENT_BOT, SENDER_TYPES.CAPTAIN_ASSISTANT].includes(type)) { return { name: name ?? '', src: avatarUrl ?? '', diff --git a/app/javascript/dashboard/components-next/message/constants.js b/app/javascript/dashboard/components-next/message/constants.js index ea63f8c74..71257f66a 100644 --- a/app/javascript/dashboard/components-next/message/constants.js +++ b/app/javascript/dashboard/components-next/message/constants.js @@ -21,6 +21,7 @@ export const SENDER_TYPES = { CONTACT: 'Contact', USER: 'User', AGENT_BOT: 'agent_bot', + CAPTAIN_ASSISTANT: 'captain_assistant', }; export const ORIENTATION = { diff --git a/app/models/conversation.rb b/app/models/conversation.rb index d7efff139..bcb79c05b 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -108,6 +108,7 @@ class Conversation < ApplicationRecord has_many :conversation_participants, dependent: :destroy_async has_many :notifications, as: :primary_actor, dependent: :destroy_async has_many :attachments, through: :messages + has_many :reporting_events, dependent: :destroy_async before_save :ensure_snooze_until_reset before_create :determine_conversation_status diff --git a/app/models/message.rb b/app/models/message.rb index 20dad7403..f5d7712d2 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -197,10 +197,10 @@ class Message < ApplicationRecord end def valid_first_reply? - return false unless outgoing? && human_response? && !private? + return false unless human_response? && !private? return false if conversation.first_reply_created_at.present? return false if conversation.messages.outgoing - .where.not(sender_type: 'AgentBot') + .where.not(sender_type: ['AgentBot', 'Captain::Assistant']) .where.not(private: true) .where("(additional_attributes->'campaign_id') is null").count > 1 diff --git a/config/locales/en.yml b/config/locales/en.yml index 2caeeb250..2a2e73d91 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -146,6 +146,8 @@ en: attachment: 'Attachment' no_content: 'No content' conversations: + captain: + handoff: 'Transferring to another agent for further assistance.' messages: instagram_story_content: '%{story_sender} mentioned you in the story: ' instagram_deleted_story_content: This story is no longer available. @@ -155,6 +157,7 @@ en: activity: captain: resolved: 'Conversation was marked resolved by %{user_name} due to inactivity' + open: 'Conversation was marked open by %{user_name}' status: resolved: 'Conversation was marked resolved by %{user_name}' contact_resolved: 'Conversation was resolved by %{contact_name}' diff --git a/config/locales/pt_BR.yml b/config/locales/pt_BR.yml index 601f689ac..70104ee37 100644 --- a/config/locales/pt_BR.yml +++ b/config/locales/pt_BR.yml @@ -132,6 +132,8 @@ pt_BR: attachment: 'Anexo' no_content: 'Sem conteúdo' conversations: + captain: + handoff: 'Transferindo para outro agente para mais assistência.' messages: instagram_story_content: '%{story_sender} mencionou você na conversa: ' instagram_deleted_story_content: Este Story não está mais disponível. @@ -139,6 +141,9 @@ pt_BR: delivery_status: error_code: 'Código de erro: %{error_code}' activity: + captain: + resolved: 'A conversa foi marcada como resolvida por %{user_name} devido à inatividade' + open: 'A conversa foi marcada como aberta por %{user_name}' status: resolved: 'Conversa foi marcada como resolvida por %{user_name}' contact_resolved: 'A conversa foi resolvida por %{contact_name}' @@ -203,7 +208,7 @@ pt_BR: meeting_name: '%{agent_name} começou a reunião' slack: name: 'Slack' - description: "Integre Chatwoot com Slack para manter seu time em sincronia. Essa integração permite que você receba notificações de novas conversas e as responda diretamente na interface do Slack." + description: 'Integre Chatwoot com Slack para manter seu time em sincronia. Essa integração permite que você receba notificações de novas conversas e as responda diretamente na interface do Slack.' webhooks: name: 'Webhooks' description: 'Eventos webhook fornecem atualizações sobre atividades em tempo real na sua conta Chatwoot. Você pode se inscrever em seus eventos preferidos, e o Chatwoot enviará as chamadas HTTP com as atualizações.' @@ -212,7 +217,7 @@ pt_BR: description: 'Construa chatbots com o Dialogflow e integre-os facilmente na sua caixa de entrada. Esses bots podem lidar com as consultas iniciais antes de transferi-las para um agente de atendimento ao cliente.' google_translate: name: 'Tradutor do Google' - description: "Integre o Google Tradutor para ajudar os agentes a traduzir facilmente as mensagens dos clientes. Esta integração detecta automaticamente o idioma e o converte para o idioma preferido do agente ou do administrador." + description: 'Integre o Google Tradutor para ajudar os agentes a traduzir facilmente as mensagens dos clientes. Esta integração detecta automaticamente o idioma e o converte para o idioma preferido do agente ou do administrador.' openai: name: 'OpenAI' description: 'Aproveite o poder dos grandes modelos de linguagem do OpenAI com recursos como sugestões de resposta, resumo, reformulação de mensagens, verificação ortográfica e classificação de rótulos.' diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index 5bc9defa4..39a12b662 100644 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -6,11 +6,15 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob @inbox = conversation.inbox @assistant = assistant + Current.executed_by = @assistant + ActiveRecord::Base.transaction do generate_and_process_response end rescue StandardError => e handle_error(e) + ensure + Current.executed_by = nil end private @@ -37,13 +41,23 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob .where(private: false) .map do |message| { - content: message.content, + content: message_content(message), role: determine_role(message) } end end + def message_content(message) + return message.content if message.content.present? + + 'User has shared an attachment' if message.attachments.any? + + 'User has shared a message without content' + end + def determine_role(message) + return 'system' if message.content.blank? + message.message_type == 'incoming' ? 'user' : 'system' end @@ -54,13 +68,15 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob def process_action(action) case action when 'handoff' - create_handoff_message - @conversation.bot_handoff! + I18n.with_locale(@assistant.account.locale) do + create_handoff_message + @conversation.bot_handoff! + end end end def create_handoff_message - create_outgoing_message(@assistant.config['handoff_message'] || 'Transferring to another agent for further assistance.') + create_outgoing_message(@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')) end def create_messages @@ -77,6 +93,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob message_type: :outgoing, account_id: account.id, inbox_id: inbox.id, + sender: @assistant, content: message_content ) end diff --git a/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb b/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb index 51503ccb8..d3f1f5d96 100644 --- a/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb +++ b/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb @@ -2,22 +2,31 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob queue_as :low def perform(inbox) - # limiting the number of conversations to be resolved to avoid any performance issues Current.executed_by = inbox.captain_assistant + resolvable_conversations = inbox.conversations.pending.where('last_activity_at < ? ', Time.now.utc - 1.hour).limit(Limits::BULK_ACTIONS_LIMIT) resolvable_conversations.each do |conversation| - resolution_message = conversation.inbox.captain_assistant.config['resolution_message'] + create_outgoing_message(conversation, inbox) + conversation.resolved! + end + ensure + Current.reset + end + + private + + def create_outgoing_message(conversation, inbox) + I18n.with_locale(inbox.account.locale) do + resolution_message = inbox.captain_assistant.config['resolution_message'] conversation.messages.create!( { message_type: :outgoing, account_id: conversation.account_id, inbox_id: conversation.inbox_id, - content: resolution_message || I18n.t('conversations.activity.auto_resolution_message') + content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'), + sender: inbox.captain_assistant } ) - conversation.resolved! - ensure - Current.reset end end end diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index 4592ead1a..196fa81a2 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -15,6 +15,8 @@ # index_captain_assistants_on_account_id (account_id) # class Captain::Assistant < ApplicationRecord + include Avatarable + self.table_name = 'captain_assistants' belongs_to :account @@ -26,6 +28,7 @@ class Captain::Assistant < ApplicationRecord dependent: :destroy_async has_many :inboxes, through: :captain_inboxes + has_many :messages, as: :sender, dependent: :nullify validates :name, presence: true validates :description, presence: true @@ -34,4 +37,32 @@ class Captain::Assistant < ApplicationRecord scope :ordered, -> { order(created_at: :desc) } scope :for_account, ->(account_id) { where(account_id: account_id) } + + def push_event_data + { + id: id, + name: name, + avatar_url: avatar_url.presence || default_avatar_url, + description: description, + created_at: created_at, + type: 'captain_assistant' + } + end + + def webhook_data + { + id: id, + name: name, + avatar_url: avatar_url.presence || default_avatar_url, + description: description, + created_at: created_at, + type: 'captain_assistant' + } + end + + private + + def default_avatar_url + "#{ENV.fetch('FRONTEND_URL', nil)}/assets/images/dashboard/captain/logo.svg" + end end diff --git a/enterprise/app/models/enterprise/activity_message_handler.rb b/enterprise/app/models/enterprise/activity_message_handler.rb index 202884b5b..e6a93718f 100644 --- a/enterprise/app/models/enterprise/activity_message_handler.rb +++ b/enterprise/app/models/enterprise/activity_message_handler.rb @@ -1,7 +1,20 @@ module Enterprise::ActivityMessageHandler def automation_status_change_activity_content - if Current.executed_by.instance_of?(Captain::Assistant) && resolved? - I18n.t('conversations.activity.captain.resolved', user_name: Current.executed_by.name) + if Current.executed_by.instance_of?(Captain::Assistant) + locale = Current.executed_by.account.locale + if resolved? + I18n.t( + 'conversations.activity.captain.resolved', + user_name: Current.executed_by.name, + locale: locale + ) + elsif open? + I18n.t( + 'conversations.activity.captain.open', + user_name: Current.executed_by.name, + locale: locale + ) + end else super end diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb index 1e45cb1d8..50688f18b 100644 --- a/enterprise/app/services/captain/llm/assistant_chat_service.rb +++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb @@ -22,7 +22,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService def system_message { role: 'system', - content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.config['product_name'], @assistant.config) + content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.name, @assistant.config['product_name'], @assistant.config) } end end diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb index b1e627275..2d2940a8e 100644 --- a/enterprise/app/services/captain/llm/system_prompts_service.rb +++ b/enterprise/app/services/captain/llm/system_prompts_service.rb @@ -103,10 +103,10 @@ class Captain::Llm::SystemPromptsService SYSTEM_PROMPT_MESSAGE end - def assistant_response_generator(product_name, config = {}) + def assistant_response_generator(assistant_name, product_name, config = {}) <<~SYSTEM_PROMPT_MESSAGE [Identity] - You are Captain, a helpful, friendly, and knowledgeable assistant for the product #{product_name}. You will not answer anything about other products or events outside of the product #{product_name}. + Your name is #{assistant_name || 'Captain'}, a helpful, friendly, and knowledgeable assistant for the product #{product_name}. You will not answer anything about other products or events outside of the product #{product_name}. [Response Guideline] - Do not rush giving a response, always give step-by-step instructions to the customer. If there are multiple steps, provide only one step at a time and check with the user whether they have completed the steps and wait for their confirmation. If the user has said okay or yes, continue with the steps. diff --git a/lib/integrations/slack/send_on_slack_service.rb b/lib/integrations/slack/send_on_slack_service.rb index e68dd81af..c37a152e8 100644 --- a/lib/integrations/slack/send_on_slack_service.rb +++ b/lib/integrations/slack/send_on_slack_service.rb @@ -153,12 +153,12 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService def sender_type(sender) if sender.instance_of?(Contact) 'Contact' - elsif message.message_type == 'template' && sender.nil? - 'Bot' + elsif sender.instance_of?(User) + 'Agent' elsif message.message_type == 'activity' && sender.nil? 'System' else - 'Agent' + 'Bot' end end diff --git a/public/assets/images/dashboard/captain/logo.svg b/public/assets/images/dashboard/captain/logo.svg new file mode 100644 index 000000000..7eb14df2b --- /dev/null +++ b/public/assets/images/dashboard/captain/logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/spec/enterprise/models/message_spec.rb b/spec/enterprise/models/message_spec.rb new file mode 100644 index 000000000..5a9dc4e27 --- /dev/null +++ b/spec/enterprise/models/message_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' + +RSpec.describe Message do + let!(:conversation) { create(:conversation) } + + it 'updates first reply if the message is human and even if there are messages from captain' do + captain_assistant = create(:captain_assistant, account: conversation.account) + expect(conversation.first_reply_created_at).to be_nil + + ## There is a difference on how the time is stored in the database and how it is retrieved + # This is because of the precision of the time stored in the database + # In the test, we will check whether the time is within the range + expect(conversation.waiting_since).to be_within(0.000001.seconds).of(conversation.created_at) + + create(:message, message_type: :outgoing, conversation: conversation, sender: captain_assistant) + + expect(conversation.first_reply_created_at).to be_nil + expect(conversation.waiting_since).to be_within(0.000001.seconds).of(conversation.created_at) + + create(:message, message_type: :outgoing, conversation: conversation) + + expect(conversation.first_reply_created_at).not_to be_nil + expect(conversation.waiting_since).to be_nil + end +end