diff --git a/AGENTS.md b/AGENTS.md index 3b1bcb024..301633d7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,11 @@ - **Setup**: `bundle install && pnpm install` - **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev` +- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification) +- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios) +- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too: + - UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`). + - CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find())"` (or call `Seeders::AccountSeeder.new(account: Account.find()).perform!` directly). - **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix` - **Lint Ruby**: `bundle exec rubocop -a` - **Test JS**: `pnpm test` or `pnpm test:watch` @@ -93,3 +98,7 @@ Practical checklist for any change impacting core logic or public APIs - When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift. - Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable. - When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`. + +## Branding / White-labeling note + +- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy. diff --git a/Gemfile b/Gemfile index 2023c32b1..e6c5a5250 100644 --- a/Gemfile +++ b/Gemfile @@ -191,7 +191,7 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' -gem 'ai-agents', '>= 0.7.0' +gem 'ai-agents' # TODO: Move this gem as a dependency of ai-agents gem 'ruby_llm', '>= 1.8.2' diff --git a/Gemfile.lock b/Gemfile.lock index db9b59c66..cc1f9e253 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,8 +126,8 @@ GEM jbuilder (~> 2) rails (>= 4.2, < 7.2) selectize-rails (~> 0.6) - ai-agents (0.7.0) - ruby_llm (~> 1.8.2) + ai-agents (0.9.0) + ruby_llm (~> 1.9.1) annotaterb (4.20.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) @@ -314,7 +314,7 @@ GEM faraday-net_http_persistent (2.1.0) faraday (~> 2.5) net-http-persistent (~> 4.0) - faraday-retry (2.2.1) + faraday-retry (2.4.0) faraday (~> 2.0) faraday_middleware-aws-sigv4 (1.0.1) aws-sigv4 (~> 1.0) @@ -540,7 +540,7 @@ GEM net-imap net-pop net-smtp - marcel (1.0.4) + marcel (1.1.0) maxminddb (0.1.22) meta_request (0.8.5) rack-contrib (>= 1.1, < 3) @@ -559,7 +559,7 @@ GEM multi_json (1.15.0) multi_xml (0.8.0) bigdecimal (>= 3.1, < 5) - multipart-post (2.3.0) + multipart-post (2.4.1) mutex_m (0.3.0) neighbor (0.2.3) activerecord (>= 5.2) @@ -825,7 +825,7 @@ GEM ruby2ruby (2.5.0) ruby_parser (~> 3.1) sexp_processor (~> 4.6) - ruby_llm (1.8.2) + ruby_llm (1.9.2) base64 event_stream_parser (~> 1) faraday (>= 1.10.0) @@ -1004,7 +1004,7 @@ GEM working_hours (1.4.1) activesupport (>= 3.2) tzinfo - zeitwerk (2.6.17) + zeitwerk (2.7.4) PLATFORMS arm64-darwin-20 @@ -1024,7 +1024,7 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) - ai-agents (>= 0.7.0) + ai-agents annotaterb attr_extras audited (~> 5.4, >= 5.4.1) diff --git a/app/controllers/devise_overrides/passwords_controller.rb b/app/controllers/devise_overrides/passwords_controller.rb index 00976c3cd..c69541f6f 100644 --- a/app/controllers/devise_overrides/passwords_controller.rb +++ b/app/controllers/devise_overrides/passwords_controller.rb @@ -6,12 +6,8 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController def create @user = User.from_email(params[:email]) - if @user - @user.send_reset_password_instructions - build_response(I18n.t('messages.reset_password_success'), 200) - else - build_response(I18n.t('messages.reset_password_failure'), 404) - end + @user&.send_reset_password_instructions + build_response(I18n.t('messages.reset_password'), 200) end def update diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index 0f6ab85a8..66234984c 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -43,6 +43,7 @@ import VoiceCallBubble from './bubbles/VoiceCall.vue'; import MessageError from './MessageError.vue'; import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue'; +import { useBranding } from 'shared/composables/useBranding'; /** * @typedef {Object} Attachment @@ -143,6 +144,7 @@ const { t } = useI18n(); const route = useRoute(); const inboxGetter = useMapGetter('inboxes/getInbox'); const inbox = computed(() => inboxGetter.value(props.inboxId) || {}); +const { replaceInstallationName } = useBranding(); /** * Computes the message variant based on props @@ -389,13 +391,17 @@ const shouldRenderMessage = computed(() => { const isUnsupported = props.contentAttributes?.isUnsupported; const isAnIntegrationMessage = props.contentType === CONTENT_TYPES.INTEGRATIONS; + const isFailedMessage = props.status === MESSAGE_STATUS.FAILED; + const hasExternalError = !!props.contentAttributes?.externalError; return ( hasAttachments || props.content || isEmailContentType || isUnsupported || - isAnIntegrationMessage + isAnIntegrationMessage || + isFailedMessage || + hasExternalError ); }); @@ -472,7 +478,7 @@ const avatarInfo = computed(() => { const avatarTooltip = computed(() => { if (props.contentAttributes?.externalEcho) { - return t('CONVERSATION.NATIVE_APP_ADVISORY'); + return replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY')); } if (avatarInfo.value.name === '') return ''; return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`; diff --git a/app/javascript/dashboard/components-next/message/MessageError.vue b/app/javascript/dashboard/components-next/message/MessageError.vue index cd17c1e3f..fe508c805 100644 --- a/app/javascript/dashboard/components-next/message/MessageError.vue +++ b/app/javascript/dashboard/components-next/message/MessageError.vue @@ -12,11 +12,16 @@ defineProps({ const emit = defineEmits(['retry']); -const { orientation, status, createdAt } = useMessageContext(); +const { orientation, status, createdAt, content, attachments } = + useMessageContext(); const { t } = useI18n(); -const canRetry = computed(() => !hasOneDayPassed(createdAt.value)); +const canRetry = computed(() => { + const hasContent = content.value !== null; + const hasAttachments = attachments.value && attachments.value.length > 0; + return !hasOneDayPassed(createdAt.value) && (hasContent || hasAttachments); +}); diff --git a/app/javascript/dashboard/components/widgets/conversation/conversation/LabelSuggestion.vue b/app/javascript/dashboard/components/widgets/conversation/conversation/LabelSuggestion.vue index 9075eb4ed..0c8a4fb57 100644 --- a/app/javascript/dashboard/components/widgets/conversation/conversation/LabelSuggestion.vue +++ b/app/javascript/dashboard/components/widgets/conversation/conversation/LabelSuggestion.vue @@ -2,6 +2,7 @@ // components import NextButton from 'dashboard/components-next/button/Button.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; +import { useBranding } from 'shared/composables/useBranding'; // composables import { useCaptain } from 'dashboard/composables/useCaptain'; @@ -34,8 +35,9 @@ export default { }, setup() { const { captainTasksEnabled } = useCaptain(); + const { replaceInstallationName } = useBranding(); - return { captainTasksEnabled }; + return { captainTasksEnabled, replaceInstallationName }; }, data() { return { @@ -228,7 +230,9 @@ export default {
{ - if (error.name === 'AbortError' || error.name === 'CanceledError') { + if ( + error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR || + error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR + ) { return; } const errorMessage = @@ -78,6 +82,24 @@ export function useCaptain() { useAlert(errorMessage); }; + /** + * Classifies API error types for downstream analytics. + * @param {Error} error + * @returns {string} + */ + const getErrorType = error => { + if ( + error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR || + error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR + ) { + return CAPTAIN_ERROR_TYPES.ABORTED; + } + if (error.response?.status) { + return `${CAPTAIN_ERROR_TYPES.HTTP_PREFIX}${error.response.status}`; + } + return CAPTAIN_ERROR_TYPES.API_ERROR; + }; + // === Task Methods === /** * Rewrites content with a specific operation. @@ -103,7 +125,7 @@ export function useCaptain() { return { message: generatedMessage, followUpContext }; } catch (error) { handleAPIError(error); - return { message: '' }; + return { message: '', errorType: getErrorType(error) }; } }; @@ -125,7 +147,7 @@ export function useCaptain() { return { message: generatedMessage, followUpContext }; } catch (error) { handleAPIError(error); - return { message: '' }; + return { message: '', errorType: getErrorType(error) }; } }; @@ -147,7 +169,7 @@ export function useCaptain() { return { message: generatedMessage, followUpContext }; } catch (error) { handleAPIError(error); - return { message: '' }; + return { message: '', errorType: getErrorType(error) }; } }; @@ -171,7 +193,11 @@ export function useCaptain() { return { message: generatedMessage, followUpContext: updatedContext }; } catch (error) { handleAPIError(error); - return { message: '', followUpContext }; + return { + message: '', + followUpContext, + errorType: getErrorType(error), + }; } }; diff --git a/app/javascript/dashboard/composables/useCopilotReply.js b/app/javascript/dashboard/composables/useCopilotReply.js index 492bcb43e..42f506b16 100644 --- a/app/javascript/dashboard/composables/useCopilotReply.js +++ b/app/javascript/dashboard/composables/useCopilotReply.js @@ -3,6 +3,10 @@ import { useCaptain } from 'dashboard/composables/useCaptain'; import { useUISettings } from 'dashboard/composables/useUISettings'; import { useTrack } from 'dashboard/composables'; import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; +import { + CAPTAIN_ERROR_TYPES, + CAPTAIN_GENERATION_FAILURE_REASONS, +} from 'dashboard/composables/captain/constants'; // Actions that map to REWRITE events (with operation attribute) const REWRITE_ACTIONS = [ @@ -52,6 +56,20 @@ function buildPayload(action, conversationId, followUpCount = undefined) { return payload; } +function trackGenerationFailure({ + action, + conversationId, + followUpCount = undefined, + stage, + reason, +}) { + useTrack(CAPTAIN_EVENTS.GENERATION_FAILED, { + ...buildPayload(action, conversationId, followUpCount), + stage, + reason, + }); +} + /** * Composable for managing Copilot reply generation state and actions. * Extracts copilot-related logic from ReplyBox for cleaner code organization. @@ -146,7 +164,8 @@ export function useCopilotReply() { // Reset without tracking dismiss (starting new action) reset(false); - abortController.value = new AbortController(); + const requestController = new AbortController(); + abortController.value = requestController; isGenerating.value = true; isContentReady.value = false; currentAction.value = action; @@ -154,28 +173,66 @@ export function useCopilotReply() { trackedConversationId.value = conversationId.value; try { - const { message: content, followUpContext: newContext } = - await processEvent(action, data, { - signal: abortController.value.signal, - }); + const { + message: content, + followUpContext: newContext, + errorType, + } = await processEvent(action, data, { + signal: requestController.signal, + }); - if (!abortController.value?.signal.aborted) { - generatedContent.value = content; - followUpContext.value = newContext; - if (content) { - showEditor.value = true; - // Track "Used" event on successful generation - const eventKey = `${getEventPrefix(action)}_USED`; - useTrack( - CAPTAIN_EVENTS[eventKey], - buildPayload(action, trackedConversationId.value) - ); + if (requestController.signal.aborted) return; + if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) { + if (abortController.value === requestController) { + isGenerating.value = false; } - isGenerating.value = false; + return; } - } catch { - if (!abortController.value?.signal.aborted) { - isGenerating.value = false; + + generatedContent.value = content; + followUpContext.value = newContext; + if (content) { + showEditor.value = true; + // Track "Used" event on successful generation + const eventKey = `${getEventPrefix(action)}_USED`; + useTrack( + CAPTAIN_EVENTS[eventKey], + buildPayload(action, trackedConversationId.value) + ); + } else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) { + trackGenerationFailure({ + action, + conversationId: trackedConversationId.value, + stage: 'initial', + reason: errorType, + }); + } else { + trackGenerationFailure({ + action, + conversationId: trackedConversationId.value, + stage: 'initial', + reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE, + }); + } + isGenerating.value = false; + } catch (error) { + if ( + requestController.signal.aborted || + error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR || + error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR + ) { + return; + } + trackGenerationFailure({ + action, + conversationId: trackedConversationId.value, + stage: 'initial', + reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION, + }); + isGenerating.value = false; + } finally { + if (abortController.value === requestController) { + abortController.value = null; } } } @@ -187,7 +244,8 @@ export function useCopilotReply() { async function sendFollowUp(message) { if (!followUpContext.value || !message.trim()) return; - abortController.value = new AbortController(); + const requestController = new AbortController(); + abortController.value = requestController; isGenerating.value = true; isContentReady.value = false; @@ -198,24 +256,65 @@ export function useCopilotReply() { followUpCount.value += 1; try { - const { message: content, followUpContext: updatedContext } = - await followUp({ - followUpContext: followUpContext.value, - message, - signal: abortController.value.signal, - }); + const { + message: content, + followUpContext: updatedContext, + errorType, + } = await followUp({ + followUpContext: followUpContext.value, + message, + signal: requestController.signal, + }); - if (!abortController.value?.signal.aborted) { - if (content) { - generatedContent.value = content; - followUpContext.value = updatedContext; - showEditor.value = true; + if (requestController.signal.aborted) return; + if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) { + if (abortController.value === requestController) { + isGenerating.value = false; } - isGenerating.value = false; + return; } - } catch { - if (!abortController.value?.signal.aborted) { - isGenerating.value = false; + + if (content) { + generatedContent.value = content; + followUpContext.value = updatedContext; + showEditor.value = true; + } else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) { + trackGenerationFailure({ + action: currentAction.value, + conversationId: trackedConversationId.value, + followUpCount: followUpCount.value, + stage: 'follow_up', + reason: errorType, + }); + } else { + trackGenerationFailure({ + action: currentAction.value, + conversationId: trackedConversationId.value, + followUpCount: followUpCount.value, + stage: 'follow_up', + reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE, + }); + } + isGenerating.value = false; + } catch (error) { + if ( + requestController.signal.aborted || + error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR || + error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR + ) { + return; + } + trackGenerationFailure({ + action: currentAction.value, + conversationId: trackedConversationId.value, + followUpCount: followUpCount.value, + stage: 'follow_up', + reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION, + }); + isGenerating.value = false; + } finally { + if (abortController.value === requestController) { + abortController.value = null; } } } diff --git a/app/javascript/dashboard/helper/AnalyticsHelper/events.js b/app/javascript/dashboard/helper/AnalyticsHelper/events.js index 0b6e85d77..c9fefb129 100644 --- a/app/javascript/dashboard/helper/AnalyticsHelper/events.js +++ b/app/javascript/dashboard/helper/AnalyticsHelper/events.js @@ -85,6 +85,11 @@ export const PORTALS_EVENTS = Object.freeze({ }); export const CAPTAIN_EVENTS = Object.freeze({ + // Editor funnel events + EDITOR_AI_MENU_OPENED: 'Captain: Editor AI menu opened', + GENERATION_FAILED: 'Captain: Generation failed', + AI_ASSISTED_MESSAGE_SENT: 'Captain: AI-assisted message sent', + // Rewrite events (with operation attribute in payload) REWRITE_USED: 'Captain: Rewrite used', REWRITE_APPLIED: 'Captain: Rewrite applied', diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 99e0bb072..c1c87bc0c 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -174,6 +174,10 @@ "SUCCESFUL": "Assigned label #{labelName} to conversation id {conversationId}", "FAILED": "Couldn't assign label. Please try again." }, + "LABEL_REMOVAL": { + "SUCCESFUL": "Removed label #{labelName} from conversation id {conversationId}", + "FAILED": "Couldn't remove label. Please try again." + }, "TEAM_ASSIGNMENT": { "SUCCESFUL": "Assigned team \"{team}\" to conversation id {conversationId}", "FAILED": "Couldn't assign team. Please try again." diff --git a/app/models/concerns/auto_assignment_handler.rb b/app/models/concerns/auto_assignment_handler.rb index a1198200a..dca154842 100644 --- a/app/models/concerns/auto_assignment_handler.rb +++ b/app/models/concerns/auto_assignment_handler.rb @@ -19,10 +19,18 @@ module AutoAssignmentHandler AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id) else # Use legacy assignment system - AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform + # If conversation has a team, only consider team members for assignment + allowed_agent_ids = team_id.present? ? team_member_ids_with_capacity : inbox.member_ids_with_assignment_capacity + AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: allowed_agent_ids).perform end end + def team_member_ids_with_capacity + return [] if team.blank? || team.allow_auto_assign.blank? + + inbox.member_ids_with_assignment_capacity & team.members.ids + end + def should_run_auto_assignment? return false unless inbox.enable_auto_assignment? diff --git a/app/services/auto_assignment/assignment_service.rb b/app/services/auto_assignment/assignment_service.rb index 5d75c515f..89eff9d1c 100644 --- a/app/services/auto_assignment/assignment_service.rb +++ b/app/services/auto_assignment/assignment_service.rb @@ -19,7 +19,7 @@ class AutoAssignment::AssignmentService def perform_for_conversation(conversation) return false unless assignable?(conversation) - agent = find_available_agent + agent = find_available_agent(conversation) return false unless agent assign_conversation(conversation, agent) @@ -44,13 +44,26 @@ class AutoAssignment::AssignmentService scope.limit(limit) end - def find_available_agent - agents = filter_agents_by_rate_limit(inbox.available_agents) + def find_available_agent(conversation = nil) + agents = filter_agents_by_team(inbox.available_agents, conversation) + return nil if agents.nil? + + agents = filter_agents_by_rate_limit(agents) return nil if agents.empty? round_robin_selector.select_agent(agents) end + def filter_agents_by_team(agents, conversation) + return agents if conversation&.team_id.blank? + + team = conversation.team + return nil if team.blank? || team.allow_auto_assign.blank? + + team_member_ids = team.members.ids + agents.where(user_id: team_member_ids) + end + def filter_agents_by_rate_limit(agents) agents.select do |agent_member| rate_limiter = build_rate_limiter(agent_member.user) diff --git a/app/services/twilio/delivery_status_service.rb b/app/services/twilio/delivery_status_service.rb index bf8422fcd..bed390aa5 100644 --- a/app/services/twilio/delivery_status_service.rb +++ b/app/services/twilio/delivery_status_service.rb @@ -47,8 +47,10 @@ class Twilio::DeliveryStatusService @twilio_channel ||= if params[:MessagingServiceSid].present? ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid]) elsif params[:AccountSid].present? && params[:From].present? - ::Channel::TwilioSms.find_by!(account_sid: params[:AccountSid], phone_number: params[:From]) + ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid], phone_number: params[:From]) end + log_channel_not_found if @twilio_channel.blank? + @twilio_channel end def message @@ -56,4 +58,14 @@ class Twilio::DeliveryStatusService @message ||= twilio_channel.inbox.messages.find_by(source_id: params[:MessageSid]) end + + def log_channel_not_found + Rails.logger.warn( + '[TWILIO] Delivery status channel lookup failed ' \ + "account_sid=#{params[:AccountSid]} " \ + "from=#{params[:From]} " \ + "messaging_service_sid=#{params[:MessagingServiceSid]} " \ + "message_sid=#{params[:MessageSid]}" + ) + end end diff --git a/app/services/twilio/incoming_message_service.rb b/app/services/twilio/incoming_message_service.rb index 5d695ebb2..d67b6d515 100644 --- a/app/services/twilio/incoming_message_service.rb +++ b/app/services/twilio/incoming_message_service.rb @@ -26,12 +26,23 @@ class Twilio::IncomingMessageService def twilio_channel @twilio_channel ||= ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid]) if params[:MessagingServiceSid].present? if params[:AccountSid].present? && params[:To].present? - @twilio_channel ||= ::Channel::TwilioSms.find_by!(account_sid: params[:AccountSid], - phone_number: params[:To]) + @twilio_channel ||= ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid], + phone_number: params[:To]) end + log_channel_not_found if @twilio_channel.blank? @twilio_channel end + def log_channel_not_found + Rails.logger.warn( + '[TWILIO] Incoming message channel lookup failed ' \ + "account_sid=#{params[:AccountSid]} " \ + "to=#{params[:To]} " \ + "messaging_service_sid=#{params[:MessagingServiceSid]} " \ + "sms_sid=#{params[:SmsSid]}" + ) + end + def inbox @inbox ||= twilio_channel.inbox end diff --git a/config/locales/en.yml b/config/locales/en.yml index f8d5b119e..07d9b0e2f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -41,8 +41,7 @@ en: invalid_email: 'Please enter a valid email address' authentication_failed: 'Authentication failed. Please check your credentials and try again.' messages: - reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions. - reset_password_failure: Uh ho! We could not find any user with the specified email. + reset_password: Request for password reset is successful. A email with instructions will be sent to your email if it exists. reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator. login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider. saml_not_available: SAML authentication is not available in this installation. diff --git a/enterprise/app/helpers/captain/chat_response_helper.rb b/enterprise/app/helpers/captain/chat_response_helper.rb index e0323996f..bfb8adc11 100644 --- a/enterprise/app/helpers/captain/chat_response_helper.rb +++ b/enterprise/app/helpers/captain/chat_response_helper.rb @@ -1,10 +1,13 @@ module Captain::ChatResponseHelper + include Integrations::LlmInstrumentationConstants + private def build_response(response) Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" } parsed = parse_json_response(response.content) + apply_credit_usage_metadata(parsed) persist_message(parsed, 'assistant') parsed @@ -19,6 +22,26 @@ module Captain::ChatResponseHelper { 'content' => content } end + def apply_credit_usage_metadata(parsed_response) + return unless captain_v1_assistant? + + OpenTelemetry::Trace.current_span.set_attribute( + format(ATTR_LANGFUSE_METADATA, 'credit_used'), + credit_used_for_response?(parsed_response).to_s + ) + rescue StandardError => e + Rails.logger.warn "#{self.class.name} Assistant: #{@assistant.id}, Failed to set credit usage metadata: #{e.message}" + end + + def credit_used_for_response?(parsed_response) + response = parsed_response['response'] + response.present? && response != 'conversation_handoff' + end + + def captain_v1_assistant? + feature_name == 'assistant' && !@assistant.account.feature_enabled?('captain_integration_v2') + end + def persist_thinking_message(tool_call) return if @copilot_thread.blank? diff --git a/enterprise/app/jobs/messages/audio_transcription_job.rb b/enterprise/app/jobs/messages/audio_transcription_job.rb index ce35405c8..5daf1e160 100644 --- a/enterprise/app/jobs/messages/audio_transcription_job.rb +++ b/enterprise/app/jobs/messages/audio_transcription_job.rb @@ -1,6 +1,15 @@ class Messages::AudioTranscriptionJob < ApplicationJob queue_as :low + discard_on Faraday::BadRequestError do |job, error| + log_context = { + attachment_id: job.arguments.first, + job_id: job.job_id, + status_code: error.response&.dig(:status) + } + + Rails.logger.warn("Discarding audio transcription job due to bad request: #{log_context}") + end retry_on ActiveStorage::FileNotFoundError, wait: 2.seconds, attempts: 3 def perform(attachment_id) diff --git a/enterprise/app/services/captain/assistant/agent_runner_service.rb b/enterprise/app/services/captain/assistant/agent_runner_service.rb index 9c4e56841..a40e096e6 100644 --- a/enterprise/app/services/captain/assistant/agent_runner_service.rb +++ b/enterprise/app/services/captain/assistant/agent_runner_service.rb @@ -1,6 +1,9 @@ require 'agents' +require 'agents/instrumentation' class Captain::Assistant::AgentRunnerService + include Integrations::LlmInstrumentationConstants + CONVERSATION_STATE_ATTRIBUTES = %i[ id display_id inbox_id contact_id status priority label_list custom_attributes additional_attributes @@ -22,7 +25,9 @@ class Captain::Assistant::AgentRunnerService context = build_context(message_history) message_to_process = extract_last_user_message(message_history) runner = Agents::Runner.with_agents(*agents) + runner = add_usage_metadata_callback(runner) runner = add_callbacks_to_runner(runner) if @callbacks.any? + install_instrumentation(runner) result = runner.run(message_to_process, context: context, max_turns: 100) process_agent_result(result) @@ -50,6 +55,7 @@ class Captain::Assistant::AgentRunnerService end { + session_id: "#{@assistant.account_id}_#{@conversation&.display_id}", conversation_history: conversation_history, state: build_state } @@ -124,6 +130,31 @@ class Captain::Assistant::AgentRunnerService [assistant_agent] + scenario_agents end + def install_instrumentation(runner) + return unless ChatwootApp.otel_enabled? + + Agents::Instrumentation.install( + runner, + tracer: OpentelemetryConfig.tracer, + trace_name: 'llm.captain_v2', + span_attributes: { + ATTR_LANGFUSE_TAGS => ['captain_v2'].to_json + }, + attribute_provider: ->(context_wrapper) { dynamic_trace_attributes(context_wrapper) } + ) + end + + def dynamic_trace_attributes(context_wrapper) + state = context_wrapper&.context&.dig(:state) || {} + conversation = state[:conversation] || {} + { + ATTR_LANGFUSE_USER_ID => state[:account_id], + format(ATTR_LANGFUSE_METADATA, 'assistant_id') => state[:assistant_id], + format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id], + format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id] + }.compact.transform_values(&:to_s) + end + def add_callbacks_to_runner(runner) runner = add_agent_thinking_callback(runner) if @callbacks[:on_agent_thinking] runner = add_tool_start_callback(runner) if @callbacks[:on_tool_start] @@ -132,6 +163,36 @@ class Captain::Assistant::AgentRunnerService runner end + def add_usage_metadata_callback(runner) + return runner unless ChatwootApp.otel_enabled? + + handoff_tool_name = Captain::Tools::HandoffTool.new(@assistant).name + + runner.on_tool_complete do |tool_name, _tool_result, context_wrapper| + track_handoff_usage(tool_name, handoff_tool_name, context_wrapper) + end + + runner.on_run_complete do |_agent_name, _result, context_wrapper| + write_credits_used_metadata(context_wrapper) + end + runner + end + + def track_handoff_usage(tool_name, handoff_tool_name, context_wrapper) + return unless context_wrapper&.context + return unless tool_name.to_s == handoff_tool_name + + context_wrapper.context[:captain_v2_handoff_tool_called] = true + end + + def write_credits_used_metadata(context_wrapper) + root_span = context_wrapper&.context&.dig(:__otel_tracing, :root_span) + return unless root_span + + credits_used = !context_wrapper.context[:captain_v2_handoff_tool_called] + root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'credits_used'), credits_used) + end + def add_agent_thinking_callback(runner) runner.on_agent_thinking do |*args| @callbacks[:on_agent_thinking].call(*args) diff --git a/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb b/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb index 46422f9bc..66cdc31e5 100644 --- a/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb +++ b/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb @@ -14,8 +14,11 @@ module Enterprise::AutoAssignment::AssignmentService end # Extend agent finding to add capacity checks - def find_available_agent - agents = filter_agents_by_rate_limit(inbox.available_agents) + def find_available_agent(conversation = nil) + agents = filter_agents_by_team(inbox.available_agents, conversation) + return nil if agents.nil? + + agents = filter_agents_by_rate_limit(agents) agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled? return nil if agents.empty? diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb index 1676dd862..4aa156f47 100644 --- a/enterprise/app/services/messages/audio_transcription_service.rb +++ b/enterprise/app/services/messages/audio_transcription_service.rb @@ -31,12 +31,20 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService end def fetch_audio_file + blob = attachment.file.blob temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions') FileUtils.mkdir_p(temp_dir) - temp_file_path = File.join(temp_dir, "#{attachment.file.blob.key}-#{attachment.file.filename}") + temp_file_name = "#{blob.key}-#{blob.filename}" + + if blob.filename.extension_without_delimiter.blank? + extension = extension_from_content_type(blob.content_type) + temp_file_name = "#{temp_file_name}.#{extension}" if extension.present? + end + + temp_file_path = File.join(temp_dir, temp_file_name) File.open(temp_file_path, 'wb') do |file| - attachment.file.blob.open do |blob_file| + blob.open do |blob_file| IO.copy_stream(blob_file, file) end end @@ -49,13 +57,12 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService return transcribed_text if transcribed_text.present? temp_file_path = fetch_audio_file - transcribed_text = nil File.open(temp_file_path, 'rb') do |file| response = @client.audio.transcribe( parameters: { - model: 'whisper-1', + model: WHISPER_MODEL, file: file, temperature: 0.4 } @@ -63,10 +70,10 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService transcribed_text = response['text'] end - FileUtils.rm_f(temp_file_path) - update_transcription(transcribed_text) transcribed_text + ensure + FileUtils.rm_f(temp_file_path) if temp_file_path.present? end def instrumentation_params(file_path) @@ -90,4 +97,15 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService message.reindex end + + def extension_from_content_type(content_type) + subtype = content_type.to_s.downcase.split(';').first.to_s.split('/').last.to_s + return if subtype.blank? + + { + 'x-m4a' => 'm4a', + 'x-wav' => 'wav', + 'x-mp3' => 'mp3' + }.fetch(subtype, subtype) + end end diff --git a/lib/captain/tool_instrumentation.rb b/lib/captain/tool_instrumentation.rb index af79a3fca..157aab829 100644 --- a/lib/captain/tool_instrumentation.rb +++ b/lib/captain/tool_instrumentation.rb @@ -15,6 +15,7 @@ module Captain::ToolInstrumentation response = yield executed = true span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json) + set_tool_session_error_attributes(span, response) if response.is_a?(Hash) end response rescue StandardError => e @@ -29,6 +30,14 @@ module Captain::ToolInstrumentation span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json) end + def set_tool_session_error_attributes(span, response) + error = response[:error] || response['error'] + return if error.blank? + + span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json) + span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000)) + end + def record_generation(chat, message, model) return unless ChatwootApp.otel_enabled? return unless message.respond_to?(:role) && message.role.to_s == 'assistant' diff --git a/lib/integrations/llm_instrumentation.rb b/lib/integrations/llm_instrumentation.rb index c3baf291e..326bb901e 100644 --- a/lib/integrations/llm_instrumentation.rb +++ b/lib/integrations/llm_instrumentation.rb @@ -37,6 +37,7 @@ module Integrations::LlmInstrumentation result = yield executed = true span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json) + set_error_attributes(span, result) if result.is_a?(Hash) result end rescue StandardError => e @@ -50,9 +51,11 @@ module Integrations::LlmInstrumentation return yield unless ChatwootApp.otel_enabled? tracer.in_span(format(TOOL_SPAN_NAME, tool_name)) do |span| + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool') span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, arguments.to_json) result = yield span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json) + set_error_attributes(span, result) if result.is_a?(Hash) result end end diff --git a/lib/integrations/llm_instrumentation_constants.rb b/lib/integrations/llm_instrumentation_constants.rb index 6ce296ee9..dfe1e7704 100644 --- a/lib/integrations/llm_instrumentation_constants.rb +++ b/lib/integrations/llm_instrumentation_constants.rb @@ -26,6 +26,7 @@ module Integrations::LlmInstrumentationConstants ATTR_LANGFUSE_METADATA = 'langfuse.trace.metadata.%s' ATTR_LANGFUSE_TRACE_INPUT = 'langfuse.trace.input' ATTR_LANGFUSE_TRACE_OUTPUT = 'langfuse.trace.output' + ATTR_LANGFUSE_OBSERVATION_TYPE = 'langfuse.observation.type' ATTR_LANGFUSE_OBSERVATION_INPUT = 'langfuse.observation.input' ATTR_LANGFUSE_OBSERVATION_OUTPUT = 'langfuse.observation.output' end diff --git a/lib/integrations/llm_instrumentation_spans.rb b/lib/integrations/llm_instrumentation_spans.rb index 824b6aa4a..85ea599f8 100644 --- a/lib/integrations/llm_instrumentation_spans.rb +++ b/lib/integrations/llm_instrumentation_spans.rb @@ -39,6 +39,7 @@ module Integrations::LlmInstrumentationSpans tool_name = tool_call.name.to_s span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name)) + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool') span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json) @pending_tool_spans ||= [] diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index 3c380c155..bc7b4097f 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -330,6 +330,7 @@ RSpec.describe 'Conversations API', type: :request do context 'when it is an authenticated user who has access to the inbox' do before do create(:inbox_member, user: agent, inbox: inbox) + create(:team_member, user: agent, team: team) end it 'creates a new conversation' do diff --git a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb index 2c05860e2..04fa0f967 100644 --- a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb +++ b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb @@ -75,6 +75,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do it 'runs agent with extracted user message and context' do expected_context = { + session_id: "#{account.id}_#{conversation.display_id}", conversation_history: [ { role: :user, content: 'Hello there', agent_name: nil }, { role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' }, @@ -306,6 +307,60 @@ RSpec.describe Captain::Assistant::AgentRunnerService do end end + describe '#add_usage_metadata_callback' do + it 'sets credits_used=false when handoff tool is used' do + service = described_class.new(assistant: assistant, conversation: conversation) + runner = instance_double(Agents::AgentRunner) + tool_complete_callback = nil + run_complete_callback = nil + span_class = Class.new do + def set_attribute(*); end + end + root_span = instance_double(span_class) + context_wrapper = Struct.new(:context).new({ __otel_tracing: { root_span: root_span } }) + + allow(ChatwootApp).to receive(:otel_enabled?).and_return(true) + allow(runner).to receive(:on_tool_complete) do |&block| + tool_complete_callback = block + runner + end + allow(runner).to receive(:on_run_complete) do |&block| + run_complete_callback = block + runner + end + + service.send(:add_usage_metadata_callback, runner) + + tool_complete_callback.call(Captain::Tools::HandoffTool.new(assistant).name, 'ok', context_wrapper) + + expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credits_used', false) + run_complete_callback.call('assistant', nil, context_wrapper) + end + + it 'sets credits_used=true when handoff tool is not used' do + service = described_class.new(assistant: assistant, conversation: conversation) + runner = instance_double(Agents::AgentRunner) + run_complete_callback = nil + span_class = Class.new do + def set_attribute(*); end + end + root_span = instance_double(span_class) + context_wrapper = Struct.new(:context).new({ __otel_tracing: { root_span: root_span } }) + + allow(ChatwootApp).to receive(:otel_enabled?).and_return(true) + allow(runner).to receive(:on_tool_complete).and_return(runner) + allow(runner).to receive(:on_run_complete) do |&block| + run_complete_callback = block + runner + end + + service.send(:add_usage_metadata_callback, runner) + + expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credits_used', true) + run_complete_callback.call('assistant', nil, context_wrapper) + end + end + describe 'constants' do it 'defines conversation state attributes' do expect(described_class::CONVERSATION_STATE_ATTRIBUTES).to include( diff --git a/spec/enterprise/services/messages/audio_transcription_service_spec.rb b/spec/enterprise/services/messages/audio_transcription_service_spec.rb index 41a4cae83..7ece2540a 100644 --- a/spec/enterprise/services/messages/audio_transcription_service_spec.rb +++ b/spec/enterprise/services/messages/audio_transcription_service_spec.rb @@ -8,8 +8,8 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do before do # Create required installation configs - create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-api-key') - create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini') + InstallationConfig.find_or_create_by!(name: 'CAPTAIN_OPEN_AI_API_KEY') { |config| config.value = 'test-api-key' } + InstallationConfig.find_or_create_by!(name: 'CAPTAIN_OPEN_AI_MODEL') { |config| config.value = 'gpt-4o-mini' } # Mock usage limits for transcription to be available allow(account).to receive(:usage_limits).and_return({ captain: { responses: { current_available: 100 } } }) @@ -64,4 +64,24 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do end end end + + describe '#fetch_audio_file' do + let(:service) { described_class.new(attachment) } + + before do + attachment.file.attach( + io: File.open(Rails.public_path.join('audio/widget/ding.mp3')), + filename: 'speech', + content_type: 'audio/mpeg' + ) + end + + it 'adds extension from content type when filename has no extension' do + temp_file_path = service.send(:fetch_audio_file) + + expect(File.extname(temp_file_path)).to eq('.mpeg') + ensure + FileUtils.rm_f(temp_file_path) if temp_file_path.present? + end + end end diff --git a/spec/services/auto_assignment/assignment_service_spec.rb b/spec/services/auto_assignment/assignment_service_spec.rb index 2139e5e78..36a8c7816 100644 --- a/spec/services/auto_assignment/assignment_service_spec.rb +++ b/spec/services/auto_assignment/assignment_service_spec.rb @@ -307,5 +307,52 @@ RSpec.describe AutoAssignment::AssignmentService do end end end + + context 'with team assignments' do + let(:team) { create(:team, account: account, allow_auto_assign: true) } + let(:team_member) { create(:user, account: account, role: :agent, availability: :online) } + let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) } + + before do + create(:team_member, team: team, user: team_member) + create(:inbox_member, inbox: inbox, user: team_member) + + allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ team_member.id.to_s => 'online' }) + + allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter) + allow(rate_limiter).to receive(:within_limit?).and_return(true) + allow(rate_limiter).to receive(:track_assignment) + + round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector) + allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector) + allow(round_robin_selector).to receive(:select_agent).and_return(team_member) + end + + it 'assigns conversation with team to team member' do + conversation_with_team = create(:conversation, inbox: inbox, team: team, assignee: nil) + + service.perform_bulk_assignment(limit: 1) + + expect(conversation_with_team.reload.assignee).to eq(team_member) + end + + it 'skips assignment when team has allow_auto_assign false' do + team.update!(allow_auto_assign: false) + conversation_with_team = create(:conversation, inbox: inbox, team: team, assignee: nil) + + service.perform_bulk_assignment(limit: 1) + + expect(conversation_with_team.reload.assignee).to be_nil + end + + it 'skips assignment when no team members are available' do + allow(OnlineStatusTracker).to receive(:get_available_users).and_return({}) + conversation_with_team = create(:conversation, inbox: inbox, team: team, assignee: nil) + + service.perform_bulk_assignment(limit: 1) + + expect(conversation_with_team.reload.assignee).to be_nil + end + end end end