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 <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Pranav
2025-05-16 19:27:57 -07:00
committed by GitHub
parent a295d5b61d
commit cbdac45824
15 changed files with 143 additions and 24 deletions

View File

@@ -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 ?? '',

View File

@@ -21,6 +21,7 @@ export const SENDER_TYPES = {
CONTACT: 'Contact',
USER: 'User',
AGENT_BOT: 'agent_bot',
CAPTAIN_ASSISTANT: 'captain_assistant',
};
export const ORIENTATION = {

View File

@@ -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

View File

@@ -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

View File

@@ -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}'

View File

@@ -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.'

View File

@@ -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'
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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -0,0 +1,6 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="28" height="28" fill="#E1D9FF"/>
<path d="M10.2766 11.9346C10.2766 11.5593 10.5808 11.255 10.9562 11.255V11.255C11.3315 11.255 11.6358 11.5593 11.6358 11.9346V13.5053C11.6358 13.8806 11.3315 14.1849 10.9562 14.1849V14.1849C10.5808 14.1849 10.2766 13.8806 10.2766 13.5053V11.9346Z" fill="#272962"/>
<path d="M12.4514 11.9346C12.4514 11.5593 12.7556 11.255 13.131 11.255V11.255C13.5063 11.255 13.8106 11.5593 13.8106 11.9346V13.5053C13.8106 13.8806 13.5063 14.1849 13.131 14.1849V14.1849C12.7556 14.1849 12.4514 13.8806 12.4514 13.5053V11.9346Z" fill="#272962"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.7282 9.39092C15.1974 9.03385 13.1343 9.01976 10.5466 9.38935C9.89253 9.48277 9.45974 9.54591 9.12976 9.64235C8.82378 9.73177 8.64977 9.8384 8.50584 9.99925C8.20779 10.3323 8.16851 10.7091 8.12789 12.023C8.08796 13.3143 8.16704 14.4715 8.31682 15.7737C8.39664 16.4675 8.45156 16.9358 8.53925 17.2918C8.62244 17.6295 8.72304 17.8111 8.86287 17.9501C9.00381 18.0902 9.18443 18.189 9.51593 18.2692C9.86659 18.354 10.3267 18.4053 11.0105 18.4799C13.2219 18.7212 14.8773 18.72 17.097 18.4812C17.7891 18.4067 18.2567 18.3554 18.6126 18.2711C18.9507 18.1911 19.1325 18.093 19.2715 17.9569C19.4076 17.8236 19.5116 17.6398 19.6017 17.2853C19.6958 16.9154 19.7598 16.426 19.8525 15.707C20.0178 14.4242 20.1368 13.2976 20.1414 12.0634C20.1463 10.7439 20.1194 10.3684 19.8179 10.0202C19.6724 9.85197 19.4954 9.74137 19.1824 9.64927C18.8445 9.54981 18.4 9.4857 17.7282 9.39092ZM10.3886 8.28297C13.0843 7.89796 15.2531 7.91304 17.8843 8.28428L17.9228 8.28971C18.5458 8.37758 19.0721 8.45182 19.4979 8.57713C19.9575 8.71238 20.3418 8.91768 20.663 9.28876C21.2688 9.98876 21.2649 10.8193 21.2596 11.9227C21.2594 11.9705 21.2592 12.0188 21.259 12.0676C21.2541 13.3725 21.1278 14.5541 20.9609 15.8498L20.9565 15.8841C20.8694 16.5602 20.7976 17.1174 20.6848 17.5608C20.5662 18.0271 20.389 18.4268 20.0535 18.7554C19.7209 19.0811 19.3278 19.2503 18.87 19.3587C18.4377 19.461 17.8998 19.5189 17.2514 19.5886L17.2166 19.5924C14.918 19.8396 13.1818 19.841 10.8893 19.5909L10.8539 19.587C10.2146 19.5173 9.68223 19.4592 9.25317 19.3555C8.79707 19.2451 8.40665 19.0724 8.07492 18.7427C7.74207 18.4118 7.56725 18.0185 7.45409 17.5591C7.34735 17.1258 7.28531 16.5863 7.21059 15.9365L7.20654 15.9014C7.05293 14.566 6.96868 13.3513 7.01082 11.9884C7.01235 11.939 7.01381 11.8901 7.01526 11.8417C7.04801 10.7465 7.07258 9.92502 7.67297 9.25403C7.99151 8.89802 8.36802 8.70062 8.81625 8.56962C9.23195 8.44813 9.74451 8.37494 10.3512 8.28832C10.3636 8.28654 10.3761 8.28476 10.3886 8.28297Z" fill="#272962"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -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