From b269cca0bfa9e5ec01afeee2217bc6f769f1e3d9 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Wed, 3 Dec 2025 07:23:44 +0530 Subject: [PATCH 01/18] feat: Add AI credit topup flow for Stripe (#12988) Co-authored-by: Shivam Mishra Co-authored-by: Pranav --- Gemfile | 2 +- Gemfile.lock | 4 +- .../dashboard/api/enterprise/account.js | 4 + .../dashboard/composables/useCaptain.js | 6 +- .../dashboard/i18n/locale/en/settings.json | 22 ++- .../dashboard/settings/billing/Index.vue | 40 +++++- .../billing/components/CreditPackageCard.vue | 101 ++++++++++++++ .../components/PurchaseCreditsModal.vue | 128 ++++++++++++++++++ .../dashboard/store/modules/accounts.js | 4 + app/policies/account_policy.rb | 4 + config/locales/en.yml | 5 + config/routes.rb | 2 + .../enterprise/api/v1/accounts_controller.rb | 11 ++ .../billing/handle_stripe_event_service.rb | 65 ++++++++- .../billing/topup_checkout_service.rb | 99 ++++++++++++++ .../billing/topup_fulfillment_service.rb | 49 +++++++ .../handle_stripe_event_service_spec.rb | 10 ++ 17 files changed, 542 insertions(+), 14 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/billing/components/CreditPackageCard.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue create mode 100644 enterprise/app/services/enterprise/billing/topup_checkout_service.rb create mode 100644 enterprise/app/services/enterprise/billing/topup_fulfillment_service.rb diff --git a/Gemfile b/Gemfile index d5e844382..faf7c2dea 100644 --- a/Gemfile +++ b/Gemfile @@ -162,7 +162,7 @@ gem 'working_hours' gem 'pg_search' # Subscriptions, Billing -gem 'stripe' +gem 'stripe', '~> 18.0' ## - helper gems --## ## to populate db with sample data diff --git a/Gemfile.lock b/Gemfile.lock index 7ce74a223..e356c13a8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -928,7 +928,7 @@ GEM squasher (0.7.2) stackprof (0.2.25) statsd-ruby (1.5.0) - stripe (8.5.0) + stripe (18.0.1) telephone_number (1.4.20) test-prof (1.2.1) thor (1.4.0) @@ -1139,7 +1139,7 @@ DEPENDENCIES spring-watcher-listen squasher stackprof - stripe + stripe (~> 18.0) telephone_number test-prof tidewave diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js index 3f12dc007..9e6d40a62 100644 --- a/app/javascript/dashboard/api/enterprise/account.js +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -23,6 +23,10 @@ class EnterpriseAccountAPI extends ApiClient { action_type: action, }); } + + createTopupCheckout(credits) { + return axios.post(`${this.url}topup_checkout`, { credits }); + } } export default new EnterpriseAccountAPI(); diff --git a/app/javascript/dashboard/composables/useCaptain.js b/app/javascript/dashboard/composables/useCaptain.js index 3f93cfc58..a9eedcc9a 100644 --- a/app/javascript/dashboard/composables/useCaptain.js +++ b/app/javascript/dashboard/composables/useCaptain.js @@ -1,5 +1,5 @@ import { computed } from 'vue'; -import { useStore } from 'dashboard/composables/store.js'; +import { useMapGetter, useStore } from 'dashboard/composables/store.js'; import { useAccount } from 'dashboard/composables/useAccount'; import { useConfig } from 'dashboard/composables/useConfig'; import { useCamelCase } from 'dashboard/composables/useTransformKeys'; @@ -9,6 +9,7 @@ export function useCaptain() { const store = useStore(); const { isCloudFeatureEnabled, currentAccount } = useAccount(); const { isEnterprise } = useConfig(); + const uiFlags = useMapGetter('accounts/getUIFlags'); const captainEnabled = computed(() => { return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN); @@ -34,6 +35,8 @@ export function useCaptain() { return null; }); + const isFetchingLimits = computed(() => uiFlags.value.isFetchingLimits); + const fetchLimits = () => { if (isEnterprise) { store.dispatch('accounts/limits'); @@ -46,5 +49,6 @@ export function useCaptain() { documentLimits, responseLimits, fetchLimits, + isFetchingLimits, }; } diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index b53bfed7b..64be235a2 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -399,15 +399,31 @@ "DESCRIPTION": "Manage usage and credits for Captain AI.", "BUTTON_TXT": "Buy more credits", "DOCUMENTS": "Documents", - "RESPONSES": "Responses", - "UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more." + "RESPONSES": "Credits", + "UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more.", + "REFRESH_CREDITS": "Refresh" }, "CHAT_WITH_US": { "TITLE": "Need help?", "DESCRIPTION": "Do you face any issues in billing? We are here to help.", "BUTTON_TXT": "Chat with us" }, - "NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again." + "NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again.", + "TOPUP": { + "BUY_CREDITS": "Buy more credits", + "MODAL_TITLE": "Buy AI Credits", + "MODAL_DESCRIPTION": "Purchase additional credits for Captain AI.", + "CREDITS": "CREDITS", + "ONE_TIME": "one-time", + "POPULAR": "Most Popular", + "NOTE_TITLE": "Note:", + "NOTE_DESCRIPTION": "Credits are added immediately and expire in 6 months. An active subscription is required to use credits. Purchased credits are consumed after your monthly plan credits.", + "CANCEL": "Cancel", + "PURCHASE": "Purchase Credits", + "LOADING": "Loading options...", + "FETCH_ERROR": "Failed to load credit options. Please try again.", + "PURCHASE_ERROR": "Failed to process purchase. Please try again." + } }, "SECURITY_SETTINGS": { "TITLE": "Security", diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue index 4ae140120..4f0981967 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue @@ -11,6 +11,7 @@ import BillingMeter from './components/BillingMeter.vue'; import BillingCard from './components/BillingCard.vue'; import BillingHeader from './components/BillingHeader.vue'; import DetailItem from './components/DetailItem.vue'; +import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue'; import BaseSettingsHeader from '../components/BaseSettingsHeader.vue'; import SettingsLayout from '../SettingsLayout.vue'; import ButtonV4 from 'next/button/Button.vue'; @@ -23,6 +24,7 @@ const { documentLimits, responseLimits, fetchLimits, + isFetchingLimits, } = useCaptain(); const uiFlags = useMapGetter('accounts/getUIFlags'); @@ -32,6 +34,7 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted'; // State for handling refresh attempts and loading const isWaitingForBilling = ref(false); +const purchaseCreditsModalRef = ref(null); const customAttributes = computed(() => { return currentAccount.value.custom_attributes || {}; @@ -45,6 +48,11 @@ const planName = computed(() => { return customAttributes.value.plan_name; }); +const canPurchaseCredits = computed(() => { + const plan = planName.value?.toLowerCase(); + return plan && plan !== 'hacker'; +}); + /** * Computed property for subscribed quantity * @returns {number|undefined} @@ -71,8 +79,9 @@ const hasABillingPlan = computed(() => { const fetchAccountDetails = async () => { if (!hasABillingPlan.value) { await store.dispatch('accounts/subscription'); - fetchLimits(); } + // Always fetch limits for billing page to show credit usage + fetchLimits(); }; const handleBillingPageLogic = async () => { @@ -119,6 +128,10 @@ const onToggleChatWindow = () => { } }; +const openPurchaseCreditsModal = () => { + purchaseCreditsModalRef.value?.open(); +}; + onMounted(handleBillingPageLogic); @@ -178,9 +191,27 @@ onMounted(handleBillingPageLogic); :description="$t('BILLING_SETTINGS.CAPTAIN.DESCRIPTION')" >
+ diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/CreditPackageCard.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/CreditPackageCard.vue new file mode 100644 index 000000000..d557e9a71 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/CreditPackageCard.vue @@ -0,0 +1,101 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue new file mode 100644 index 000000000..ebbab0aa8 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue @@ -0,0 +1,128 @@ + + + diff --git a/app/javascript/dashboard/store/modules/accounts.js b/app/javascript/dashboard/store/modules/accounts.js index 1eef59cfe..44134e19d 100644 --- a/app/javascript/dashboard/store/modules/accounts.js +++ b/app/javascript/dashboard/store/modules/accounts.js @@ -18,6 +18,7 @@ const state = { isFetchingItem: false, isUpdating: false, isCheckoutInProcess: false, + isFetchingLimits: false, }, }; @@ -141,11 +142,14 @@ export const actions = { }, limits: async ({ commit }) => { + commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: true }); try { const response = await EnterpriseAccountAPI.getLimits(); commit(types.default.SET_ACCOUNT_LIMITS, response.data); } catch (error) { // silent error + } finally { + commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: false }); } }, diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index 61e02ae77..bd7b3cefe 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -30,4 +30,8 @@ class AccountPolicy < ApplicationPolicy def toggle_deletion? @account_user.administrator? end + + def topup_checkout? + @account_user.administrator? + end end diff --git a/config/locales/en.yml b/config/locales/en.yml index a624861e3..49c0f547c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -122,6 +122,11 @@ en: invalid_token: Invalid or expired MFA token invalid_credentials: Invalid credentials or verification code feature_unavailable: MFA feature is not available. Please configure encryption keys. + topup: + credits_required: Credits amount is required + invalid_credits: Invalid credits amount + invalid_option: Invalid topup option + plan_not_eligible: Top-ups are only available for paid plans. Please upgrade your plan first. profile: mfa: enabled: MFA enabled successfully diff --git a/config/routes.rb b/config/routes.rb index a8141531a..0ee18ef82 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,6 +24,7 @@ Rails.application.routes.draw do get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_instagram_inbox_agents' get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings' get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings' + get '/app/accounts/:account_id/settings/billing', to: 'dashboard#index', as: 'app_account_billing_settings' resource :widget, only: [:show] namespace :survey do @@ -438,6 +439,7 @@ Rails.application.routes.draw do post :subscription get :limits post :toggle_deletion + post :topup_checkout end end end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb index 339fbdf3c..2c2e5fca1 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -55,6 +55,17 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController end end + def topup_checkout + return render json: { error: I18n.t('errors.topup.credits_required') }, status: :unprocessable_entity if params[:credits].blank? + + service = Enterprise::Billing::TopupCheckoutService.new(account: @account) + redirect_url = service.create_checkout_session(credits: params[:credits].to_i) + render json: { redirect_url: redirect_url } + rescue Enterprise::Billing::TopupCheckoutService::Error => e + Rails.logger.error("Topup checkout failed for account #{@account.id}: #{e.message}") + render_could_not_create_error(e.message) + end + private def check_cloud_env diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb index 5364409a1..8525aaca0 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -1,5 +1,6 @@ class Enterprise::Billing::HandleStripeEventService CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze + CAPTAIN_CLOUD_PLAN_LIMITS = 'CAPTAIN_CLOUD_PLAN_LIMITS'.freeze # Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise # Each higher tier includes all features from the lower tiers @@ -33,6 +34,8 @@ class Enterprise::Billing::HandleStripeEventService process_subscription_updated when 'customer.subscription.deleted' process_subscription_deleted + when 'checkout.session.completed' + process_checkout_session_completed else Rails.logger.debug { "Unhandled event type: #{event.type}" } end @@ -46,9 +49,25 @@ class Enterprise::Billing::HandleStripeEventService # skipping self hosted plan events return if plan.blank? || account.blank? + previous_usage = capture_previous_usage update_account_attributes(subscription, plan) update_plan_features - reset_captain_usage + handle_subscription_credits(plan, previous_usage) + account.reset_response_usage + end + + def capture_previous_usage + { + responses: account.custom_attributes['captain_responses_usage'].to_i, + monthly: current_plan_credits[:responses] + } + end + + def current_plan_credits + plan_name = account.custom_attributes['plan_name'] + return { responses: 0, documents: 0 } if plan_name.blank? + + get_plan_credits(plan_name) end def update_account_attributes(subscription, plan) @@ -73,6 +92,31 @@ class Enterprise::Billing::HandleStripeEventService Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform end + def process_checkout_session_completed + session = @event.data.object + metadata = session.metadata + + # Only process topup checkout sessions + return unless metadata.present? && metadata['topup'] == 'true' + + topup_account = Account.find_by(id: metadata['account_id']) + return if topup_account.blank? + + credits = metadata['credits'].to_i + amount_cents = metadata['amount_cents'].to_i + currency = metadata['currency'] || 'usd' + + Rails.logger.info("Processing topup for account #{topup_account.id}: #{credits} credits, #{amount_cents} cents") + Enterprise::Billing::TopupFulfillmentService.new(account: topup_account).fulfill( + credits: credits, + amount_cents: amount_cents, + currency: currency + ) + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: topup_account).capture_exception + raise + end + def update_plan_features if default_plan? disable_all_premium_features @@ -101,8 +145,23 @@ class Enterprise::Billing::HandleStripeEventService enable_plan_specific_features end - def reset_captain_usage - account.reset_response_usage + def handle_subscription_credits(plan, previous_usage) + current_limits = account.limits || {} + + current_credits = current_limits['captain_responses'].to_i + new_plan_credits = get_plan_credits(plan['name'])[:responses] + + consumed_topup_credits = [previous_usage[:responses] - previous_usage[:monthly], 0].max + updated_credits = current_credits - consumed_topup_credits - previous_usage[:monthly] + new_plan_credits + + Rails.logger.info("Updating subscription credits for account #{account.id}: #{current_credits} -> #{updated_credits}") + account.update!(limits: current_limits.merge('captain_responses' => updated_credits)) + end + + def get_plan_credits(plan_name) + config = InstallationConfig.find_by(name: CAPTAIN_CLOUD_PLAN_LIMITS).value + config = JSON.parse(config) if config.is_a?(String) + config[plan_name.downcase]&.symbolize_keys end def enable_plan_specific_features diff --git a/enterprise/app/services/enterprise/billing/topup_checkout_service.rb b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb new file mode 100644 index 000000000..9211cd334 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb @@ -0,0 +1,99 @@ +class Enterprise::Billing::TopupCheckoutService + include BillingHelper + include Rails.application.routes.url_helpers + + class Error < StandardError; end + + TOPUP_OPTIONS = [ + { credits: 1000, amount: 20.0, currency: 'usd' }, + { credits: 2500, amount: 50.0, currency: 'usd' }, + { credits: 6000, amount: 100.0, currency: 'usd' }, + { credits: 12_000, amount: 200.0, currency: 'usd' } + ].freeze + + pattr_initialize [:account!] + + def create_checkout_session(credits:) + topup_option = validate_and_find_topup_option(credits) + session = create_stripe_session(topup_option, credits) + session.url + end + + private + + def validate_and_find_topup_option(credits) + raise Error, I18n.t('errors.topup.invalid_credits') unless credits.to_i.positive? + raise Error, I18n.t('errors.topup.plan_not_eligible') if default_plan?(account) + + topup_option = find_topup_option(credits) + raise Error, I18n.t('errors.topup.invalid_option') unless topup_option + + topup_option + end + + def create_stripe_session(topup_option, credits) + Stripe::Checkout::Session.create( + customer: stripe_customer_id, + mode: 'payment', + line_items: [build_line_item(topup_option, credits)], + success_url: success_url, + cancel_url: cancel_url, + metadata: session_metadata(credits, topup_option), + payment_method_types: ['card'], + # Show saved payment methods and allow saving new ones + saved_payment_method_options: { + payment_method_save: 'enabled', + allow_redisplay_filters: %w[always limited] + }, + # Create invoice for this payment so it appears in customer portal + invoice_creation: build_invoice_creation_data(credits, topup_option) + ) + end + + def build_invoice_creation_data(credits, topup_option) + { + enabled: true, + invoice_data: { + description: "AI Credits Topup: #{credits} credits", + metadata: session_metadata(credits, topup_option) + } + } + end + + def build_line_item(topup_option, credits) + { + price_data: { + currency: topup_option[:currency], + unit_amount: (topup_option[:amount] * 100).to_i, + product_data: { name: "AI Credits Topup: #{credits} credits" } + }, + quantity: 1 + } + end + + def session_metadata(credits, topup_option) + { + account_id: account.id.to_s, + credits: credits.to_s, + amount_cents: (topup_option[:amount] * 100).to_s, + currency: topup_option[:currency], + topup: 'true' + } + end + + def success_url + app_account_billing_settings_url(account_id: account.id, topup: 'success') + end + + def cancel_url + app_account_billing_settings_url(account_id: account.id) + end + + def stripe_customer_id + account.custom_attributes['stripe_customer_id'] + end + + def find_topup_option(credits) + TOPUP_OPTIONS.find { |opt| opt[:credits] == credits.to_i } + end +end diff --git a/enterprise/app/services/enterprise/billing/topup_fulfillment_service.rb b/enterprise/app/services/enterprise/billing/topup_fulfillment_service.rb new file mode 100644 index 000000000..ebdd158d8 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/topup_fulfillment_service.rb @@ -0,0 +1,49 @@ +class Enterprise::Billing::TopupFulfillmentService + pattr_initialize [:account!] + + def fulfill(credits:, amount_cents:, currency:) + account.with_lock do + create_stripe_credit_grant(credits, amount_cents, currency) + update_account_credits(credits) + end + end + + private + + def create_stripe_credit_grant(credits, amount_cents, currency) + Stripe::Billing::CreditGrant.create( + customer: stripe_customer_id, + name: "Topup: #{credits} credits", + amount: { + type: 'monetary', + monetary: { currency: currency, value: amount_cents } + }, + applicability_config: { + scope: { price_type: 'metered' } + }, + category: 'paid', + expires_at: 6.months.from_now.to_i, + metadata: { + account_id: account.id.to_s, + source: 'topup', + credits: credits.to_s + } + ) + end + + def update_account_credits(credits) + current_limits = account.limits || {} + current_total = current_limits['captain_responses'].to_i + new_total = current_total + credits + + account.update!( + limits: current_limits.merge( + 'captain_responses' => new_total + ) + ) + end + + def stripe_customer_id + account.custom_attributes['stripe_customer_id'] + end +end diff --git a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb index 67ccd168b..db029ad0f 100644 --- a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb @@ -19,6 +19,16 @@ describe Enterprise::Billing::HandleStripeEventService do { 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] } ] }) + + create(:installation_config, { + name: 'CAPTAIN_CLOUD_PLAN_LIMITS', + value: { + 'hacker' => { 'responses' => 0 }, + 'startups' => { 'responses' => 300 }, + 'business' => { 'responses' => 500 }, + 'enterprise' => { 'responses' => 800 } + } + }) # Setup common subscription mocks allow(event).to receive(:data).and_return(data) allow(data).to receive(:object).and_return(subscription) From e6a7e836a08bf4c6d0fdc044e15e598a9bfa11e0 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 3 Dec 2025 10:27:16 +0530 Subject: [PATCH 02/18] fix: Add support for `ig_post` attachment type in Instagram messages (#12992) Fixes https://linear.app/chatwoot/issue/CW-6055/argumenterror-ig-post-is-not-a-valid-file-type-argumenterror This PR fixes an issue where Instagram sends webhooks with both "type": "share" and "type": "ig_post" attachments when users share Instagram posts in direct messages. The system was failing on the ig_post type because it wasn't defined, causing ArgumentError exceptions. https://github.com/user-attachments/assets/577b8ebd-80e3-4c11-95f5-d8a8c3e16534 --- app/builders/messages/messenger/message_builder.rb | 2 +- app/models/attachment.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index 4e7f2849d..0709da8f4 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -27,7 +27,7 @@ class Messages::Messenger::MessageBuilder file_type = attachment['type'].to_sym params = { file_type: file_type, account_id: @message.account_id } - if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type + if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post].include? file_type params.merge!(file_type_params(attachment)) elsif file_type == :location params.merge!(location_params(attachment)) diff --git a/app/models/attachment.rb b/app/models/attachment.rb index 6e727609a..41c4a121f 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -40,7 +40,7 @@ class Attachment < ApplicationRecord validate :acceptable_file validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT } enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7, - :contact => 8, :ig_reel => 9 } + :contact => 8, :ig_reel => 9, :ig_post => 10 } def push_event_data return unless file_type From 5c3b85334b766f1bfa46ad424a12a429ff7df2f4 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 4 Dec 2025 05:20:47 +0530 Subject: [PATCH 03/18] feat: Add support for shared post and story attachment types in Instagram messages (#12997) When users share Instagram posts or stories via DM, Instagram sends webhooks with type `ig_post` and `ig_story` attachments. The system was failing on these types because they weren't defined in the file_types. This PR fixes the issue by handling all shared types and rendering them on the front end. **Shared post** CleanShot 2025-12-03 at 16 29
14@2x **Shared status** CleanShot 2025-12-03 at 16 10
25@2x Fixes https://linear.app/chatwoot/issue/CW-5441/argumenterror-ig-story-is-not-a-valid-file-type-argumenterror --- .../messages/messenger/message_builder.rb | 31 ++++++- .../components-next/message/Message.vue | 9 +- .../components-next/message/constants.js | 2 + app/models/attachment.rb | 2 +- config/locales/en.yml | 2 + .../instagram_message_create_event.rb | 79 ++++++++++++++++++ .../webhooks/instagram_events_job_spec.rb | 82 +++++++++++++++++++ 7 files changed, 201 insertions(+), 6 deletions(-) diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index 0709da8f4..7bc571f6c 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -9,6 +9,8 @@ class Messages::Messenger::MessageBuilder attachment_obj.save! attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url] fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention' + fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story' + fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post' update_attachment_file_type(attachment_obj) end @@ -27,7 +29,7 @@ class Messages::Messenger::MessageBuilder file_type = attachment['type'].to_sym params = { file_type: file_type, account_id: @message.account_id } - if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post].include? file_type + if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type params.merge!(file_type_params(attachment)) elsif file_type == :location params.merge!(location_params(attachment)) @@ -39,9 +41,17 @@ class Messages::Messenger::MessageBuilder end def file_type_params(attachment) + # Handle different URL field names for different attachment types + url = case attachment['type'].to_sym + when :ig_story + attachment['payload']['story_media_url'] + else + attachment['payload']['url'] + end + { - external_url: attachment['payload']['url'], - remote_file_url: attachment['payload']['url'] + external_url: url, + remote_file_url: url } end @@ -68,6 +78,21 @@ class Messages::Messenger::MessageBuilder message.save! end + def fetch_ig_story_link(attachment) + message = attachment.message + # For ig_story, we don't have the same API call as story_mention, so we'll set it up similarly but with generic content + message.content_attributes[:image_type] = 'ig_story' + message.content = I18n.t('conversations.messages.instagram_shared_story_content') + message.save! + end + + def fetch_ig_post_link(attachment) + message = attachment.message + message.content_attributes[:image_type] = 'ig_post' + message.content = I18n.t('conversations.messages.instagram_shared_post_content') + message.save! + end + # This is a placeholder method to be overridden by child classes def get_story_object_from_source_id(_source_id) {} diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index dd655d0cc..fabef6bc5 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -299,7 +299,12 @@ const componentToRender = computed(() => { return DyteBubble; } - if (props.contentAttributes.imageType === 'story_mention') { + const instagramSharedTypes = [ + ATTACHMENT_TYPES.STORY_MENTION, + ATTACHMENT_TYPES.IG_STORY, + ATTACHMENT_TYPES.IG_POST, + ]; + if (instagramSharedTypes.includes(props.contentAttributes.imageType)) { return InstagramStoryBubble; } @@ -476,7 +481,7 @@ provideMessageContext({
Date: Thu, 4 Dec 2025 12:51:35 +0530 Subject: [PATCH 04/18] feat: migrate editor to ruby-llm (#12961) Co-authored-by: aakashb95 Co-authored-by: Shivam Mishra --- Gemfile | 1 + Gemfile.lock | 6 +- ...ai_base_service.rb => llm_base_service.rb} | 100 +++++--- lib/integrations/llm_instrumentation.rb | 80 +++--- .../llm_instrumentation_helpers.rb | 40 +++ lib/integrations/openai/processor_service.rb | 2 +- lib/llm/config.rb | 47 ++++ .../openai/processor_service_spec.rb | 52 ++-- .../openai/processor_service_spec.rb | 240 ++++++++---------- 9 files changed, 333 insertions(+), 235 deletions(-) rename lib/integrations/{openai_base_service.rb => llm_base_service.rb} (61%) create mode 100644 lib/integrations/llm_instrumentation_helpers.rb create mode 100644 lib/llm/config.rb diff --git a/Gemfile b/Gemfile index faf7c2dea..f037583e7 100644 --- a/Gemfile +++ b/Gemfile @@ -194,6 +194,7 @@ gem 'ruby-openai' gem 'ai-agents', '>= 0.4.3' # TODO: Move this gem as a dependency of ai-agents +gem 'ruby_llm', '>= 1.9.1' gem 'ruby_llm-schema' # OpenTelemetry for LLM observability diff --git a/Gemfile.lock b/Gemfile.lock index e356c13a8..c3eec459b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -819,7 +819,7 @@ GEM ruby2ruby (2.5.0) ruby_parser (~> 3.1) sexp_processor (~> 4.6) - ruby_llm (1.5.1) + ruby_llm (1.9.1) base64 event_stream_parser (~> 1) faraday (>= 1.10.0) @@ -827,8 +827,9 @@ GEM faraday-net_http (>= 1) faraday-retry (>= 1) marcel (~> 1.0) + ruby_llm-schema (~> 0.2.1) zeitwerk (~> 2) - ruby_llm-schema (0.1.0) + ruby_llm-schema (0.2.5) ruby_parser (3.20.0) sexp_processor (~> 4.16) sass (3.7.4) @@ -1119,6 +1120,7 @@ DEPENDENCIES rubocop-rails rubocop-rspec ruby-openai + ruby_llm (>= 1.9.1) ruby_llm-schema scout_apm scss_lint diff --git a/lib/integrations/openai_base_service.rb b/lib/integrations/llm_base_service.rb similarity index 61% rename from lib/integrations/openai_base_service.rb rename to lib/integrations/llm_base_service.rb index c19f605c1..e8811121f 100644 --- a/lib/integrations/openai_base_service.rb +++ b/lib/integrations/llm_base_service.rb @@ -1,4 +1,4 @@ -class Integrations::OpenaiBaseService +class Integrations::LlmBaseService include Integrations::LlmInstrumentation # gpt-4o-mini supports 128,000 tokens @@ -6,8 +6,7 @@ class Integrations::OpenaiBaseService # sticking with 120000 to be safe # 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe) TOKEN_LIMIT = 400_000 - GPT_MODEL = ENV.fetch('OPENAI_GPT_MODEL', 'gpt-4o-mini').freeze - + GPT_MODEL = Llm::Config::DEFAULT_MODEL ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze CACHEABLE_EVENTS = %w[].freeze @@ -82,10 +81,10 @@ class Integrations::OpenaiBaseService self.class::CACHEABLE_EVENTS.include?(event_name) end - def api_url + def api_base endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/' endpoint = endpoint.chomp('/') - "#{endpoint}/v1/chat/completions" + "#{endpoint}/v1" end def make_api_call(body) @@ -93,10 +92,65 @@ class Integrations::OpenaiBaseService instrumentation_params = build_instrumentation_params(parsed_body) instrument_llm_call(instrumentation_params) do - execute_api_request(body, parsed_body['messages']) + execute_ruby_llm_request(parsed_body) end end + def execute_ruby_llm_request(parsed_body) + messages = parsed_body['messages'] + model = parsed_body['model'] + + Llm::Config.with_api_key(hook.settings['api_key'], api_base: api_base) do |context| + chat = context.chat(model: model) + setup_chat_with_messages(chat, messages) + end + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: hook.account).capture_exception + build_error_response_from_exception(e, messages) + end + + def setup_chat_with_messages(chat, messages) + apply_system_instructions(chat, messages) + response = send_conversation_messages(chat, messages) + return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if response.nil? + + build_ruby_llm_response(response, messages) + end + + def apply_system_instructions(chat, messages) + system_msg = messages.find { |m| m['role'] == 'system' } + chat.with_instructions(system_msg['content']) if system_msg + end + + def send_conversation_messages(chat, messages) + conversation_messages = messages.reject { |m| m['role'] == 'system' } + + return nil if conversation_messages.empty? + + return chat.ask(conversation_messages.first['content']) if conversation_messages.length == 1 + + add_conversation_history(chat, conversation_messages[0...-1]) + chat.ask(conversation_messages.last['content']) + end + + def add_conversation_history(chat, messages) + messages.each do |msg| + chat.add_message(role: msg['role'].to_sym, content: msg['content']) + end + end + + def build_ruby_llm_response(response, messages) + { + message: response.content, + usage: { + 'prompt_tokens' => response.input_tokens, + 'completion_tokens' => response.output_tokens, + 'total_tokens' => (response.input_tokens || 0) + (response.output_tokens || 0) + }, + request_messages: messages + } + end + def build_instrumentation_params(parsed_body) { span_name: "llm.#{event_name}", @@ -109,37 +163,7 @@ class Integrations::OpenaiBaseService } end - def execute_api_request(body, messages) - Rails.logger.info("OpenAI API request: #{body}") - response = HTTParty.post(api_url, headers: api_headers, body: body) - Rails.logger.info("OpenAI API response: #{response.body}") - - parse_api_response(response, messages) - end - - def api_headers - { - 'Content-Type' => 'application/json', - 'Authorization' => "Bearer #{hook.settings['api_key']}" - } - end - - def parse_api_response(response, messages) - return build_error_response(response, messages) unless response.success? - - parsed_response = JSON.parse(response.body) - build_success_response(parsed_response, messages) - end - - def build_error_response(response, messages) - { error: response.parsed_response, error_code: response.code, request_messages: messages } - end - - def build_success_response(parsed_response, messages) - choices = parsed_response['choices'] - usage = parsed_response['usage'] - message_content = choices.present? ? choices.first['message']['content'] : nil - - { message: message_content, usage: usage, request_messages: messages } + def build_error_response_from_exception(error, messages) + { error: error.message, error_code: 500, request_messages: messages } end end diff --git a/lib/integrations/llm_instrumentation.rb b/lib/integrations/llm_instrumentation.rb index 919713dd3..7c176b758 100644 --- a/lib/integrations/llm_instrumentation.rb +++ b/lib/integrations/llm_instrumentation.rb @@ -2,9 +2,19 @@ require 'opentelemetry_config' require_relative 'llm_instrumentation_constants' +require_relative 'llm_instrumentation_helpers' module Integrations::LlmInstrumentation include Integrations::LlmInstrumentationConstants + include Integrations::LlmInstrumentationHelpers + + PROVIDER_PREFIXES = { + 'openai' => %w[gpt- o1 o3 o4 text-embedding- whisper- tts-], + 'anthropic' => %w[claude-], + 'google' => %w[gemini-], + 'mistral' => %w[mistral- codestral-], + 'deepseek' => %w[deepseek-] + }.freeze def tracer @tracer ||= OpentelemetryConfig.tracer @@ -13,33 +23,38 @@ module Integrations::LlmInstrumentation def instrument_llm_call(params) return yield unless ChatwootApp.otel_enabled? + result = nil + executed = false tracer.in_span(params[:span_name]) do |span| setup_span_attributes(span, params) result = yield + executed = true record_completion(span, result) result end rescue StandardError => e - ChatwootExceptionTracker.new(e, account: params[:account]).capture_exception - yield + ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception + executed ? result : yield end def instrument_agent_session(params) return yield unless ChatwootApp.otel_enabled? + result = nil + executed = false tracer.in_span(params[:span_name]) do |span| set_metadata_attributes(span, params) # By default, the input and output of a trace are set from the root observation span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json) result = yield + executed = true span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json) - result end rescue StandardError => e - ChatwootExceptionTracker.new(e, account: params[:account]).capture_exception - yield + ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception + executed ? result : yield end def instrument_tool_call(tool_name, arguments) @@ -55,8 +70,27 @@ module Integrations::LlmInstrumentation end end + def determine_provider(model_name) + return 'openai' if model_name.blank? + + model = model_name.to_s.downcase + + PROVIDER_PREFIXES.each do |provider, prefixes| + return provider if prefixes.any? { |prefix| model.start_with?(prefix) } + end + + 'openai' + end + private + def resolve_account(params) + return params[:account] if params[:account].is_a?(Account) + return Account.find_by(id: params[:account_id]) if params[:account_id].present? + + nil + end + def setup_span_attributes(span, params) set_request_attributes(span, params) set_prompt_messages(span, params[:messages]) @@ -68,7 +102,8 @@ module Integrations::LlmInstrumentation end def set_request_attributes(span, params) - span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai') + provider = determine_provider(params[:model]) + span.set_attribute(ATTR_GEN_AI_PROVIDER, provider) span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model]) span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature] end @@ -95,37 +130,4 @@ module Integrations::LlmInstrumentation span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s) end end - - def set_completion_attributes(span, result) - set_completion_message(span, result) - set_usage_metrics(span, result) - set_error_attributes(span, result) - end - - def set_completion_message(span, result) - message = result[:message] || result.dig('choices', 0, 'message', 'content') - return if message.blank? - - span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant') - span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message) - end - - def set_usage_metrics(span, result) - usage = result[:usage] || result['usage'] - return if usage.blank? - - span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens'] - span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens'] - span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens'] - end - - def set_error_attributes(span, result) - error = result[:error] || result['error'] - return if error.blank? - - error_code = result[:error_code] || result['error_code'] - span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json) - span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR_CODE, error_code) if error_code - span.status = OpenTelemetry::Trace::Status.error("API Error: #{error_code}") - end end diff --git a/lib/integrations/llm_instrumentation_helpers.rb b/lib/integrations/llm_instrumentation_helpers.rb new file mode 100644 index 000000000..d1bf5c25c --- /dev/null +++ b/lib/integrations/llm_instrumentation_helpers.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Integrations::LlmInstrumentationHelpers + include Integrations::LlmInstrumentationConstants + + private + + def set_completion_attributes(span, result) + set_completion_message(span, result) + set_usage_metrics(span, result) + set_error_attributes(span, result) + end + + def set_completion_message(span, result) + message = result[:message] || result.dig('choices', 0, 'message', 'content') + return if message.blank? + + span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant') + span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message) + end + + def set_usage_metrics(span, result) + usage = result[:usage] || result['usage'] + return if usage.blank? + + span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens'] + span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens'] + span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens'] + end + + def set_error_attributes(span, result) + error = result[:error] || result['error'] + return if error.blank? + + error_code = result[:error_code] || result['error_code'] + span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json) + span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR_CODE, error_code) if error_code + span.status = OpenTelemetry::Trace::Status.error("API Error: #{error_code}") + end +end diff --git a/lib/integrations/openai/processor_service.rb b/lib/integrations/openai/processor_service.rb index 1359c6aff..0a0dfa8ae 100644 --- a/lib/integrations/openai/processor_service.rb +++ b/lib/integrations/openai/processor_service.rb @@ -1,4 +1,4 @@ -class Integrations::Openai::ProcessorService < Integrations::OpenaiBaseService +class Integrations::Openai::ProcessorService < Integrations::LlmBaseService AGENT_INSTRUCTION = 'You are a helpful support agent.'.freeze LANGUAGE_INSTRUCTION = 'Ensure that the reply should be in user language.'.freeze def reply_suggestion_message diff --git a/lib/llm/config.rb b/lib/llm/config.rb new file mode 100644 index 000000000..b2edc81e7 --- /dev/null +++ b/lib/llm/config.rb @@ -0,0 +1,47 @@ +require 'ruby_llm' + +module Llm::Config + DEFAULT_MODEL = 'gpt-4o-mini'.freeze + class << self + def initialized? + @initialized ||= false + end + + def initialize! + return if @initialized + + configure_ruby_llm + @initialized = true + end + + def reset! + @initialized = false + end + + def with_api_key(api_key, api_base: nil) + context = RubyLLM.context do |config| + config.openai_api_key = api_key + config.openai_api_base = api_base + end + + yield context + end + + private + + def configure_ruby_llm + RubyLLM.configure do |config| + config.openai_api_key = system_api_key if system_api_key.present? + config.openai_api_base = openai_endpoint.chomp('/') if openai_endpoint.present? + end + end + + def system_api_key + InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value + end + + def openai_endpoint + InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value + end + end +end diff --git a/spec/enterprise/lib/integrations/openai/processor_service_spec.rb b/spec/enterprise/lib/integrations/openai/processor_service_spec.rb index 4707aff87..88e75ea07 100644 --- a/spec/enterprise/lib/integrations/openai/processor_service_spec.rb +++ b/spec/enterprise/lib/integrations/openai/processor_service_spec.rb @@ -5,21 +5,39 @@ RSpec.describe Integrations::Openai::ProcessorService do let(:account) { create(:account) } let(:hook) { create(:integrations_hook, :openai, account: account) } - let(:expected_headers) { { 'Authorization' => "Bearer #{hook.settings['api_key']}" } } - let(:openai_response) do - { - 'choices' => [ - { - 'message' => { - 'content' => 'This is a reply from openai.' - } - } - ] - }.to_json + + # Mock RubyLLM objects + let(:mock_chat) { instance_double(RubyLLM::Chat) } + let(:mock_context) { instance_double(RubyLLM::Context) } + let(:mock_config) { OpenStruct.new } + let(:mock_response) do + instance_double( + RubyLLM::Message, + content: 'This is a reply from openai.', + input_tokens: nil, + output_tokens: nil + ) + end + let(:mock_empty_response) do + instance_double( + RubyLLM::Message, + content: '', + input_tokens: nil, + output_tokens: nil + ) end let(:conversation) { create(:conversation, account: account) } + before do + allow(RubyLLM).to receive(:context).and_yield(mock_config).and_return(mock_context) + allow(mock_context).to receive(:chat).and_return(mock_chat) + + allow(mock_chat).to receive(:with_instructions).and_return(mock_chat) + allow(mock_chat).to receive(:add_message).and_return(mock_chat) + allow(mock_chat).to receive(:ask).and_return(mock_response) + end + describe '#perform' do context 'when event name is label_suggestion with labels with < 3 messages' do let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } } @@ -49,21 +67,15 @@ RSpec.describe Integrations::Openai::ProcessorService do end it 'returns the label suggestions' do - stub_request(:post, 'https://api.openai.com/v1/chat/completions') - .with(body: anything, headers: expected_headers) - .to_return(status: 200, body: openai_response, headers: {}) - result = subject.perform - expect(result).to eq({ :message => 'This is a reply from openai.' }) + expect(result).to eq({ message: 'This is a reply from openai.' }) end it 'returns empty string if openai response is blank' do - stub_request(:post, 'https://api.openai.com/v1/chat/completions') - .with(body: anything, headers: expected_headers) - .to_return(status: 200, body: '{}', headers: {}) + allow(mock_chat).to receive(:ask).and_return(mock_empty_response) result = subject.perform - expect(result).to eq({ :message => '' }) + expect(result[:message]).to eq('') end end diff --git a/spec/lib/integrations/openai/processor_service_spec.rb b/spec/lib/integrations/openai/processor_service_spec.rb index 6f8e6ecf6..28488cc15 100644 --- a/spec/lib/integrations/openai/processor_service_spec.rb +++ b/spec/lib/integrations/openai/processor_service_spec.rb @@ -5,135 +5,104 @@ RSpec.describe Integrations::Openai::ProcessorService do let(:account) { create(:account) } let(:hook) { create(:integrations_hook, :openai, account: account) } - let(:expected_headers) { { 'Authorization' => "Bearer #{hook.settings['api_key']}" } } - let(:openai_response) do - { - 'choices' => [{ 'message' => { 'content' => 'This is a reply from openai.' } }] - }.to_json + + # Mock RubyLLM objects + let(:mock_chat) { instance_double(RubyLLM::Chat) } + let(:mock_context) { instance_double(RubyLLM::Context) } + let(:mock_config) { OpenStruct.new } + let(:mock_response) do + instance_double( + RubyLLM::Message, + content: 'This is a reply from openai.', + input_tokens: nil, + output_tokens: nil + ) end - let(:openai_response_with_usage) do - { - 'choices' => [{ 'message' => { 'content' => 'This is a reply from openai.' } }], - 'usage' => { - 'prompt_tokens' => 50, - 'completion_tokens' => 20, - 'total_tokens' => 70 - } - }.to_json + let(:mock_response_with_usage) do + instance_double( + RubyLLM::Message, + content: 'This is a reply from openai.', + input_tokens: 50, + output_tokens: 20 + ) + end + + before do + allow(RubyLLM).to receive(:context).and_yield(mock_config).and_return(mock_context) + allow(mock_context).to receive(:chat).and_return(mock_chat) + + allow(mock_chat).to receive(:with_instructions).and_return(mock_chat) + allow(mock_chat).to receive(:add_message).and_return(mock_chat) + allow(mock_chat).to receive(:ask).and_return(mock_response) end describe '#perform' do - shared_examples 'text transformation operation' do |event_name, system_prompt| - let(:event) { { 'name' => event_name, 'data' => { 'content' => 'This is a test' } } } - let(:expected_request_body) do - { - 'model' => 'gpt-4o-mini', - 'messages' => [ - { 'role' => 'system', 'content' => system_prompt }, - { 'role' => 'user', 'content' => 'This is a test' } - ] - }.to_json - end - - it "returns the #{event_name.tr('_', ' ')} text" do - stub_request(:post, 'https://api.openai.com/v1/chat/completions') - .with(body: expected_request_body, headers: expected_headers) - .to_return(status: 200, body: openai_response) - - result = service.perform - expect(result[:message]).to eq('This is a reply from openai.') - end - end - - shared_examples 'successful openai response' do - it 'returns the expected message' do - result = service.perform - expect(result[:message]).to eq('This is a reply from openai.') - end - end - describe 'text transformation operations' do - base_prompt = 'You are a helpful support agent. ' - language_suffix = 'Ensure that the reply should be in user language.' + shared_examples 'text transformation operation' do |event_name| + let(:event) { { 'name' => event_name, 'data' => { 'content' => 'This is a test' } } } - it_behaves_like 'text transformation operation', 'rephrase', - "#{base_prompt}Please rephrase the following response. #{language_suffix}" - it_behaves_like 'text transformation operation', 'fix_spelling_grammar', - "#{base_prompt}Please fix the spelling and grammar of the following response. #{language_suffix}" - it_behaves_like 'text transformation operation', 'shorten', - "#{base_prompt}Please shorten the following response. #{language_suffix}" - it_behaves_like 'text transformation operation', 'expand', - "#{base_prompt}Please expand the following response. #{language_suffix}" - it_behaves_like 'text transformation operation', 'make_friendly', - "#{base_prompt}Please make the following response more friendly. #{language_suffix}" - it_behaves_like 'text transformation operation', 'make_formal', - "#{base_prompt}Please make the following response more formal. #{language_suffix}" - it_behaves_like 'text transformation operation', 'simplify', - "#{base_prompt}Please simplify the following response. #{language_suffix}" + it 'returns the transformed text' do + result = service.perform + expect(result[:message]).to eq('This is a reply from openai.') + end + + it 'sends the user content to the LLM' do + service.perform + expect(mock_chat).to have_received(:ask).with('This is a test') + end + + it 'sets system instructions' do + service.perform + expect(mock_chat).to have_received(:with_instructions).with(a_string_including('You are a helpful support agent')) + end + end + + it_behaves_like 'text transformation operation', 'rephrase' + it_behaves_like 'text transformation operation', 'fix_spelling_grammar' + it_behaves_like 'text transformation operation', 'shorten' + it_behaves_like 'text transformation operation', 'expand' + it_behaves_like 'text transformation operation', 'make_friendly' + it_behaves_like 'text transformation operation', 'make_formal' + it_behaves_like 'text transformation operation', 'simplify' end describe 'conversation-based operations' do let!(:conversation) { create(:conversation, account: account) } - let!(:customer_message) do + + before do create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent') - end - let!(:agent_message) do create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer') end context 'with reply_suggestion event' do let(:event) { { 'name' => 'reply_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } } - let(:expected_request_body) do - { - 'model' => 'gpt-4o-mini', - 'messages' => [ - { role: 'system', content: Rails.root.join('lib/integrations/openai/openai_prompts/reply.txt').read }, - { role: 'user', content: customer_message.content }, - { role: 'assistant', content: agent_message.content } - ] - }.to_json + + it 'returns the suggested reply' do + result = service.perform + expect(result[:message]).to eq('This is a reply from openai.') end - before do - stub_request(:post, 'https://api.openai.com/v1/chat/completions') - .with(body: expected_request_body, headers: expected_headers) - .to_return(status: 200, body: openai_response) + it 'adds conversation history before asking' do + service.perform + # Should add the first message as history, then ask with the last message + expect(mock_chat).to have_received(:add_message).with(role: :user, content: 'hello agent') + expect(mock_chat).to have_received(:ask).with('hello customer') end - - it_behaves_like 'successful openai response' end context 'with summarize event' do let(:event) { { 'name' => 'summarize', 'data' => { 'conversation_display_id' => conversation.display_id } } } - let(:conversation_messages) do - "Customer #{customer_message.sender.name} : #{customer_message.content}\n" \ - "Agent #{agent_message.sender.name} : #{agent_message.content}\n" - end - let(:summary_prompt) do - if ChatwootApp.enterprise? - Rails.root.join('enterprise/lib/enterprise/integrations/openai_prompts/summary.txt').read - else - 'Please summarize the key points from the following conversation between support agents and customer as bullet points ' \ - "for the next support agent looking into the conversation. Reply in the user's language." - end - end - let(:expected_request_body) do - { - 'model' => 'gpt-4o-mini', - 'messages' => [ - { 'role' => 'system', 'content' => summary_prompt }, - { 'role' => 'user', 'content' => conversation_messages } - ] - }.to_json + + it 'returns the summary' do + result = service.perform + expect(result[:message]).to eq('This is a reply from openai.') end - before do - stub_request(:post, 'https://api.openai.com/v1/chat/completions') - .with(body: expected_request_body, headers: expected_headers) - .to_return(status: 200, body: openai_response) + it 'sends formatted conversation as a single message' do + service.perform + # Summarize sends conversation as a formatted string in one user message + expect(mock_chat).to have_received(:ask).with(a_string_matching(/Customer.*hello agent.*Agent.*hello customer/m)) end - - it_behaves_like 'successful openai response' end context 'with label_suggestion event and no labels' do @@ -160,37 +129,37 @@ RSpec.describe Integrations::Openai::ProcessorService do context 'when response includes usage data' do before do - stub_request(:post, 'https://api.openai.com/v1/chat/completions') - .with(body: anything, headers: expected_headers) - .to_return(status: 200, body: openai_response_with_usage) + allow(mock_chat).to receive(:ask).and_return(mock_response_with_usage) end - it 'returns message, usage, and request_messages' do + it 'returns message with usage data' do result = service.perform expect(result[:message]).to eq('This is a reply from openai.') - expect(result[:usage]).to eq({ - 'prompt_tokens' => 50, - 'completion_tokens' => 20, - 'total_tokens' => 70 - }) + expect(result[:usage]['prompt_tokens']).to eq(50) + expect(result[:usage]['completion_tokens']).to eq(20) + expect(result[:usage]['total_tokens']).to eq(70) + end + + it 'includes request_messages in response' do + result = service.perform + expect(result[:request_messages]).to be_an(Array) expect(result[:request_messages].length).to eq(2) end end context 'when response does not include usage data' do - before do - stub_request(:post, 'https://api.openai.com/v1/chat/completions') - .with(body: anything, headers: expected_headers) - .to_return(status: 200, body: openai_response) - end - - it 'returns message and request_messages with nil usage' do + it 'returns message with zero total tokens' do result = service.perform expect(result[:message]).to eq('This is a reply from openai.') - expect(result[:usage]).to be_nil + expect(result[:usage]['total_tokens']).to eq(0) + end + + it 'includes request_messages in response' do + result = service.perform + expect(result[:request_messages]).to be_an(Array) end end @@ -199,23 +168,17 @@ RSpec.describe Integrations::Openai::ProcessorService do describe 'endpoint configuration' do let(:event) { { 'name' => 'rephrase', 'data' => { 'content' => 'test message' } } } - shared_examples 'endpoint request' do |endpoint_url| - it "makes request to #{endpoint_url}" do - stub_request(:post, "#{endpoint_url}/v1/chat/completions") - .with(body: anything, headers: expected_headers) - .to_return(status: 200, body: openai_response) - - result = service.perform - expect(result[:message]).to eq('This is a reply from openai.') - expect(result[:request_messages]).to be_an(Array) - expect(result[:usage]).to be_nil - end - end - context 'without CAPTAIN_OPEN_AI_ENDPOINT configured' do before { InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy } - it_behaves_like 'endpoint request', 'https://api.openai.com' + it 'uses default OpenAI endpoint' do + expect(Llm::Config).to receive(:with_api_key).with( + hook.settings['api_key'], + api_base: 'https://api.openai.com/v1' + ).and_call_original + + service.perform + end end context 'with CAPTAIN_OPEN_AI_ENDPOINT configured' do @@ -224,7 +187,14 @@ RSpec.describe Integrations::Openai::ProcessorService do create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/') end - it_behaves_like 'endpoint request', 'https://custom.azure.com' + it 'uses custom endpoint' do + expect(Llm::Config).to receive(:with_api_key).with( + hook.settings['api_key'], + api_base: 'https://custom.azure.com/v1' + ).and_call_original + + service.perform + end end end end From 728a5a67101780b5d8bc7b2566ef871a3847b666 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Thu, 4 Dec 2025 13:32:51 +0530 Subject: [PATCH 05/18] fix: handle missing AccountUser in inbox_member API (#12993) Fixes https://linear.app/chatwoot/issue/CW-6065/actionviewtemplateerror-undefined-method-availability-status-for-nil --- app/views/api/v1/widget/inbox_members/index.json.jbuilder | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/api/v1/widget/inbox_members/index.json.jbuilder b/app/views/api/v1/widget/inbox_members/index.json.jbuilder index 417c23756..126482110 100644 --- a/app/views/api/v1/widget/inbox_members/index.json.jbuilder +++ b/app/views/api/v1/widget/inbox_members/index.json.jbuilder @@ -3,6 +3,6 @@ json.payload do json.id inbox_member.user.id json.name inbox_member.user.available_name json.avatar_url inbox_member.user.avatar_url - json.availability_status inbox_member.user.account_users.find_by(account_id: @current_account.id).availability_status + json.availability_status inbox_member.user.account_users.find_by(account_id: @current_account.id)&.availability_status end end From 57904a56a04ee35e62890705c869a82765655572 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 4 Dec 2025 15:28:12 +0530 Subject: [PATCH 06/18] chore: update vulnerable packages (#12996) Co-authored-by: Muhsin Keloth --- .nvmrc | 2 +- package.json | 8 +- pnpm-lock.yaml | 826 +++++++++++++++++++++++++------------------------ 3 files changed, 425 insertions(+), 411 deletions(-) diff --git a/.nvmrc b/.nvmrc index 6f7af3750..b88575e38 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.5.1 \ No newline at end of file +23.7.0 \ No newline at end of file diff --git a/package.json b/package.json index eb12fcf61..406e83b13 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@rails/actioncable": "6.1.3", "@rails/ujs": "^7.1.400", "@scmmishra/pico-search": "0.5.4", - "@sentry/vue": "^8.31.0", + "@sentry/vue": "^8.55.0", "@sindresorhus/slugify": "2.2.1", "@tailwindcss/typography": "^0.5.15", "@tanstack/vue-table": "^8.20.5", @@ -56,7 +56,7 @@ "@vueuse/components": "^12.0.0", "@vueuse/core": "^12.0.0", "activestorage": "^5.2.6", - "axios": "^1.8.2", + "axios": "^1.13.2", "camelcase-keys": "^9.1.3", "chart.js": "~4.4.4", "color2k": "^2.0.2", @@ -132,8 +132,8 @@ "fake-indexeddb": "^6.0.0", "histoire": "0.17.15", "husky": "^7.0.0", - "jsdom": "^24.1.3", - "lint-staged": "14.0.1", + "jsdom": "^27.2.0", + "lint-staged": "^16.2.7", "postcss": "^8.4.47", "postcss-preset-env": "^8.5.1", "prettier": "^3.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 645653706..65dacb287 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,8 +56,8 @@ importers: specifier: 0.5.4 version: 0.5.4 '@sentry/vue': - specifier: ^8.31.0 - version: 8.31.0(vue@3.5.12(typescript@5.6.2)) + specifier: ^8.55.0 + version: 8.55.0(pinia@3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2)))(vue@3.5.12(typescript@5.6.2)) '@sindresorhus/slugify': specifier: 2.2.1 version: 2.2.1 @@ -89,8 +89,8 @@ importers: specifier: ^5.2.6 version: 5.2.8 axios: - specifier: ^1.8.2 - version: 1.8.2 + specifier: ^1.13.2 + version: 1.13.2 camelcase-keys: specifier: ^9.1.3 version: 9.1.3 @@ -268,7 +268,7 @@ importers: version: 8.2.6(size-limit@8.2.6) '@vitest/coverage-v8': specifier: 3.0.5 - version: 3.0.5(vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0)) + version: 3.0.5(vitest@3.0.5(@types/node@22.7.0)(jsdom@27.2.0)(sass@1.79.3)(terser@5.33.0)) '@vue/test-utils': specifier: ^2.4.6 version: 2.4.6 @@ -312,11 +312,11 @@ importers: specifier: ^7.0.0 version: 7.0.4 jsdom: - specifier: ^24.1.3 - version: 24.1.3 + specifier: ^27.2.0 + version: 27.2.0 lint-staged: - specifier: 14.0.1 - version: 14.0.1(enquirer@2.4.1) + specifier: ^16.2.7 + version: 16.2.7 postcss: specifier: ^8.4.47 version: 8.4.47 @@ -343,7 +343,7 @@ importers: version: 5.0.0(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) vitest: specifier: 3.0.5 - version: 3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0) + version: 3.0.5(@types/node@22.7.0)(jsdom@27.2.0)(sass@1.79.3)(terser@5.33.0) packages: @@ -351,6 +351,9 @@ packages: resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} engines: {node: '>=0.10.0'} + '@acemir/cssom@0.9.24': + resolution: {integrity: sha512-5YjgMmAiT2rjJZU7XK1SNI7iqTy92DpaYVgG6x63FxkJ11UpYfLndHJATtinWJClAXiOlW9XWaUyAQf8pMrQPg==} + '@akryum/tinypool@0.3.1': resolution: {integrity: sha512-nznEC1ZA/m3hQDEnrGQ4c5gkaa9pcaVnw4LFJyzBAaR7E3nfiAPEHS3otnSafpZouVnoKeITl5D+2LsnwlnK8g==} engines: {node: '>=14.0.0'} @@ -369,6 +372,15 @@ packages: '@antfu/utils@0.7.10': resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + '@asamuzakjp/css-color@4.1.0': + resolution: {integrity: sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==} + + '@asamuzakjp/dom-selector@6.7.5': + resolution: {integrity: sha512-Eks6dY8zau4m4wNRQjRVaKQRTalNcPcBvU1ZQ35w5kKRk1gUeNCkVLsRiATurjASTp3TKM4H10wsI50nx3NZdw==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/helper-string-parser@7.25.9': resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} engines: {node: '>=6.9.0'} @@ -448,6 +460,10 @@ packages: resolution: {integrity: sha512-OWkqBa7PDzZuJ3Ha7T5bxdSVfSCfTq6K1mbAhbO1MD+GSULGjrp45i5RudyJOedstSarN/3mdwu9upJE7gDXfw==} engines: {node: ^14 || ^16 || >=18} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + '@csstools/css-calc@1.1.1': resolution: {integrity: sha512-Nh+iLCtjlooTzuR0lpmB8I6hPX/VupcGQ3Z1U2+wgJJ4fa8+cWkub+lCsbZcYPzBGsZLEL8fQAg+Na5dwEFJxg==} engines: {node: ^14 || ^16 || >=18} @@ -455,6 +471,13 @@ packages: '@csstools/css-parser-algorithms': ^2.1.1 '@csstools/css-tokenizer': ^2.1.1 + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-color-parser@1.2.1': resolution: {integrity: sha512-NcmaoJIEycIH0HnzZRrwRcBljPh1AWcXl4CNL8MAD3+Zy8XyIpdTtTMaY/phnLHHIYkyjaoSTdxAecss6+PCcg==} engines: {node: ^14 || ^16 || >=18} @@ -462,16 +485,37 @@ packages: '@csstools/css-parser-algorithms': ^2.1.1 '@csstools/css-tokenizer': ^2.1.1 + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-parser-algorithms@2.2.0': resolution: {integrity: sha512-9BoQ/jSrPq4vv3b9jjLW+PNNv56KlDH5JMx5yASSNrCtvq70FCNZUjXRvbCeR9hYj9ZyhURtqpU/RFIgg6kiOw==} engines: {node: ^14 || ^16 || >=18} peerDependencies: '@csstools/css-tokenizer': ^2.1.1 + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.20': + resolution: {integrity: sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ==} + engines: {node: '>=18'} + '@csstools/css-tokenizer@2.1.1': resolution: {integrity: sha512-GbrTj2Z8MCTUv+52GE0RbFGM527xuXZ0Xa5g0Z+YN573uveS4G0qi6WNOMyz3yrFM/jaILTTwJ0+umx81EzqfA==} engines: {node: ^14 || ^16 || >=18} + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@csstools/media-query-list-parser@2.1.1': resolution: {integrity: sha512-pUjtFbaKbiFNjJo8pprrIaXLvQvWIlwPiFnRI4sEnc4F0NIGTOsw8kaJSR3CmZAKEvV8QYckovgAnWQC0bgLLQ==} engines: {node: ^14 || ^16 || >=18} @@ -1166,43 +1210,39 @@ packages: '@scmmishra/pico-search@0.5.4': resolution: {integrity: sha512-JdV8KumQ+pE5tqgQ71xUT9biE/qV//tx3NCqTLkW9Z4tsjKGN0B6kVowmtaZBAtErqir9XiMxsKXRTMF/MpUww==} - '@sentry-internal/browser-utils@8.31.0': - resolution: {integrity: sha512-Bq7TFMhPr1PixRGYkB/6ar9ws7sj224XzQ+hgpz6OxGEc9fQakvD8t/Nn7dp14k3FI/hcBRA6BBvpOKUUuPgGA==} + '@sentry-internal/browser-utils@8.55.0': + resolution: {integrity: sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==} engines: {node: '>=14.18'} - '@sentry-internal/feedback@8.31.0': - resolution: {integrity: sha512-R3LcC2IaTe8lgi5AU9h0rMgyVPpaTiMSLRhRlVeQPVmAKCz8pSG/um13q37t0BsXpTaImW9yYQ71Aj6h6IrShQ==} + '@sentry-internal/feedback@8.55.0': + resolution: {integrity: sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==} engines: {node: '>=14.18'} - '@sentry-internal/replay-canvas@8.31.0': - resolution: {integrity: sha512-ConyrhWozx4HluRj0+9teN4XTC1ndXjxMdJQvDnbLFsQhCCEdwUfaZVshV1CFe9T08Bfyjruaw33yR7pDXYktw==} + '@sentry-internal/replay-canvas@8.55.0': + resolution: {integrity: sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==} engines: {node: '>=14.18'} - '@sentry-internal/replay@8.31.0': - resolution: {integrity: sha512-r8hmFDwWxeAxpdzBCRWTKQ/QHl8QanFw8XfM0fvFes/H1d/b43Vwc/IiUnsYoMOdooIP8hJFGDKlfq+Y5uVVGA==} + '@sentry-internal/replay@8.55.0': + resolution: {integrity: sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==} engines: {node: '>=14.18'} - '@sentry/browser@8.31.0': - resolution: {integrity: sha512-LZK0uLPGB4Al+qWc1eaad+H/1SR6CY9a0V2XWpUbNAT3+VkEo0Z/78bW1kb43N0cok87hNPOe+c66SfwdxphVQ==} + '@sentry/browser@8.55.0': + resolution: {integrity: sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==} engines: {node: '>=14.18'} - '@sentry/core@8.31.0': - resolution: {integrity: sha512-5zsMBOML18e5a/ZoR5XpcYF59e2kSxb6lTg13u52f/+NA27EPgxKgXim5dz6L/6+0cizgwwmFaZFGJiFc2qoAA==} + '@sentry/core@8.55.0': + resolution: {integrity: sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==} engines: {node: '>=14.18'} - '@sentry/types@8.31.0': - resolution: {integrity: sha512-prRM/n5nlP+xQZSpdEkSR8BwwZtgsLk0NbI8eCjTMu2isVlrlggop8pVaJb7y9HmElVtDA1Q6y4u8TD2htQKFQ==} - engines: {node: '>=14.18'} - - '@sentry/utils@8.31.0': - resolution: {integrity: sha512-9W2LZ9QIHKc0HSyH/7UmTolc01Q4vX/qMSZk7i1noinlkQtnRUmTP39r1DSITjKCrDHj6zvB/J1RPDUoRcTXxQ==} - engines: {node: '>=14.18'} - - '@sentry/vue@8.31.0': - resolution: {integrity: sha512-w512J2XLs43OZ7KBcdy4ho+IWMf37TQDJ5+JBONC+OLmGo7rixAZZxwIA7nI1/kZsBYEZ6JZL1uPCMrwwe/BsQ==} + '@sentry/vue@8.55.0': + resolution: {integrity: sha512-J6lcpzL39snV/spoGpwyk5Rp1wSFxOV4qV1NhQ9OlLHORVBp/Xpw7cEA0oKqG2w1wVtCV+gC5Jjf9HTmYiHQOQ==} engines: {node: '>=14.18'} peerDependencies: + pinia: 2.x || 3.x vue: 2.x || 3.x + peerDependenciesMeta: + pinia: + optional: true '@sindresorhus/slugify@2.2.1': resolution: {integrity: sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==} @@ -1508,8 +1548,8 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} - agent-base@7.1.1: - resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} ajv@6.12.6: @@ -1529,6 +1569,10 @@ packages: resolution: {integrity: sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==} engines: {node: '>=12'} + ansi-escapes@7.2.0: + resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1622,12 +1666,15 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axios@1.8.2: - resolution: {integrity: sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==} + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} @@ -1758,10 +1805,6 @@ packages: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} - cli-cursor@4.0.0: - resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -1770,9 +1813,9 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} - cli-truncate@3.1.0: - resolution: {integrity: sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-truncate@5.1.1: + resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==} + engines: {node: '>=20'} cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} @@ -1805,9 +1848,9 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} - commander@11.0.0: - resolution: {integrity: sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==} - engines: {node: '>=16'} + commander@14.0.2: + resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} + engines: {node: '>=20'} commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -1881,6 +1924,10 @@ packages: peerDependencies: postcss: ^8.4 + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssdb@7.6.0: resolution: {integrity: sha512-Nna7rph8V0jC6+JBY4Vk4ndErUmfJfV6NJCaZdurL0omggabiy+QB2HCQtu5c/ACLZ0I7REv7A4QyPIoYzZx0w==} @@ -1899,9 +1946,9 @@ packages: resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} engines: {node: '>=8'} - cssstyle@4.0.1: - resolution: {integrity: sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==} - engines: {node: '>=18'} + cssstyle@5.3.3: + resolution: {integrity: sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==} + engines: {node: '>=20'} csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -1910,9 +1957,9 @@ packages: resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} engines: {node: '>=12'} - data-urls@5.0.0: - resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} - engines: {node: '>=18'} + data-urls@6.0.0: + resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} + engines: {node: '>=20'} data-view-buffer@1.0.1: resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} @@ -1958,15 +2005,6 @@ packages: supports-color: optional: true - debug@4.3.4: - resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.3.5: resolution: {integrity: sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==} engines: {node: '>=6.0'} @@ -1985,6 +2023,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decamelize@1.2.0: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} @@ -1992,6 +2039,9 @@ packages: decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -2122,6 +2172,14 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + es-abstract@1.22.2: resolution: {integrity: sha512-YoxfFcDmhjOgWPWsV13+2RNjq1F6UQnfs+8TftwNqtzlmFzEXvlUwdrNrYeaizfjQzRMxkZ6ElWMOJIFKdVqwA==} engines: {node: '>= 0.4'} @@ -2130,10 +2188,6 @@ packages: resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==} engines: {node: '>= 0.4'} - es-define-property@1.0.0: - resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} - engines: {node: '>= 0.4'} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -2332,10 +2386,6 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} - execa@7.2.0: - resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==} - engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0} - expect-type@1.1.0: resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==} engines: {node: '>=12.0.0'} @@ -2423,8 +2473,8 @@ packages: '@nuxt/kit': optional: true - follow-redirects@1.15.9: - resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -2439,12 +2489,8 @@ packages: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} - form-data@4.0.0: - resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} - engines: {node: '>= 6'} - - form-data@4.0.2: - resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} fraction.js@4.3.7: @@ -2480,6 +2526,10 @@ packages: resolution: {integrity: sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==} engines: {node: '>=18'} + get-east-asian-width@1.4.0: + resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} + engines: {node: '>=18'} + get-intrinsic@1.2.4: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} @@ -2492,10 +2542,6 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - get-symbol-description@1.0.0: resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==} engines: {node: '>= 0.4'} @@ -2555,9 +2601,6 @@ packages: resolution: {integrity: sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - gopd@1.0.1: - resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2658,14 +2701,10 @@ packages: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} - https-proxy-agent@7.0.5: - resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - human-signals@4.3.1: - resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==} - engines: {node: '>=14.18.0'} - husky@7.0.4: resolution: {integrity: sha512-vbaCKN2QLtP/vD4yvs6iz6hBEo6wkSzs8HpRah1Z6aGmF2KW5PdYuAd7uX5a+OyBZHBhd+TFLqgjUgytQr4RvQ==} engines: {node: '>=12'} @@ -2771,9 +2810,9 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-fullwidth-code-point@4.0.0: - resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} - engines: {node: '>=12'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} is-function@1.0.2: resolution: {integrity: sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==} @@ -2831,10 +2870,6 @@ packages: resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} engines: {node: '>= 0.4'} - is-stream@3.0.0: - resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - is-string@1.0.7: resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} engines: {node: '>= 0.4'} @@ -2921,11 +2956,11 @@ packages: canvas: optional: true - jsdom@24.1.3: - resolution: {integrity: sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==} - engines: {node: '>=18'} + jsdom@27.2.0: + resolution: {integrity: sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: - canvas: ^2.11.2 + canvas: ^3.0.0 peerDependenciesMeta: canvas: optional: true @@ -3007,19 +3042,14 @@ packages: linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} - lint-staged@14.0.1: - resolution: {integrity: sha512-Mw0cL6HXnHN1ag0mN/Dg4g6sr8uf8sn98w2Oc1ECtFto9tvRF7nkXGJRbx8gPlHyoR0pLyBr2lQHbWwmUHe1Sw==} - engines: {node: ^16.14.0 || >=18.0.0} + lint-staged@16.2.7: + resolution: {integrity: sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==} + engines: {node: '>=20.17'} hasBin: true - listr2@6.6.1: - resolution: {integrity: sha512-+rAXGHh0fkEWdXBmX+L6mmfmXmXvDGEKzkjxO+8mP3+nI/r/CWznVBvsibXdxda9Zz0OW2e2ikphN3OwCT/jSg==} - engines: {node: '>=16.0.0'} - peerDependencies: - enquirer: '>= 2.3.0 < 3' - peerDependenciesMeta: - enquirer: - optional: true + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} lit-element@3.3.3: resolution: {integrity: sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==} @@ -3065,9 +3095,9 @@ packages: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} - log-update@5.0.1: - resolution: {integrity: sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} loupe@3.1.3: resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} @@ -3078,6 +3108,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.4: + resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + engines: {node: 20 || >=22} + lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} @@ -3146,23 +3180,19 @@ packages: md5@2.3.0: resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + mdurl@1.0.1: resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==} mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - micromatch@4.0.5: - resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} - engines: {node: '>=8.6'} - micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -3175,14 +3205,6 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - - mimic-fn@4.0.0: - resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} - engines: {node: '>=12'} - mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -3250,6 +3272,10 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nano-spawn@2.0.0: + resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==} + engines: {node: '>=20.17'} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3288,10 +3314,6 @@ packages: resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} engines: {node: '>=0.10.0'} - npm-run-path@5.1.0: - resolution: {integrity: sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -3348,14 +3370,6 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - onetime@6.0.0: - resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} - engines: {node: '>=12'} - onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -3418,6 +3432,9 @@ packages: parse5@7.1.2: resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -3444,10 +3461,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} - engines: {node: '>=12'} - path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -3895,10 +3908,6 @@ packages: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true - restore-cursor@4.0.0: - resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -3907,9 +3916,6 @@ packages: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rfdc@1.3.0: - resolution: {integrity: sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==} - rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -3926,12 +3932,6 @@ packages: rope-sequence@1.3.2: resolution: {integrity: sha512-ku6MFrwEVSVmXLvy3dYph3LAMNS0890K7fabn+0YIRQ2T96T9F4gkFf0vf0WW0JUraNWwGRtInEpH7yO4tbQZg==} - rrweb-cssom@0.6.0: - resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==} - - rrweb-cssom@0.7.1: - resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} - run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -4029,9 +4029,6 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -4057,9 +4054,9 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} - slice-ansi@5.0.0: - resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} - engines: {node: '>=12'} + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} @@ -4122,6 +4119,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.1.0: + resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} + engines: {node: '>=20'} + string.prototype.trim@1.2.8: resolution: {integrity: sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==} engines: {node: '>= 0.4'} @@ -4159,10 +4160,6 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} - strip-final-newline@3.0.0: - resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} - engines: {node: '>=12'} - strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -4254,6 +4251,13 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.19: + resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} + + tldts@7.0.19: + resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4266,13 +4270,17 @@ packages: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + tr46@3.0.0: resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} engines: {node: '>=12'} - tr46@5.0.0: - resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} - engines: {node: '>=18'} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -4617,6 +4625,10 @@ packages: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} + webidl-conversions@8.0.0: + resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==} + engines: {node: '>=20'} + webrtc-adapter@9.0.1: resolution: {integrity: sha512-1AQO+d4ElfVSXyzNVTOewgGT/tAomwwztX/6e3totvyyzXPvXIIuUUjAmyZGbKBKbZOXauuJooZm3g6IuFuiNQ==} engines: {node: '>=6.0.0', npm: '>=3.10.0'} @@ -4641,9 +4653,9 @@ packages: resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} engines: {node: '>=12'} - whatwg-url@14.0.0: - resolution: {integrity: sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==} - engines: {node: '>=18'} + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} which-boxed-primitive@1.0.2: resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} @@ -4704,6 +4716,18 @@ packages: utf-8-validate: optional: true + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} @@ -4729,15 +4753,16 @@ packages: resolution: {integrity: sha512-4wZWvE398hCP7O8n3nXKu/vdq1HcH01ixYlCREaJL5NUMwQ0g3MaGFUBNSlmBtKmhbtVG/Cm6lyYmSVTEVil8A==} engines: {node: ^14.17.0 || >=16.0.0} - yaml@2.3.1: - resolution: {integrity: sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==} - engines: {node: '>= 14'} - yaml@2.5.1: resolution: {integrity: sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==} engines: {node: '>= 14'} hasBin: true + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@18.1.3: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} engines: {node: '>=6'} @@ -4766,6 +4791,8 @@ snapshots: '@aashutoshrathi/word-wrap@1.2.6': {} + '@acemir/cssom@0.9.24': {} + '@akryum/tinypool@0.3.1': {} '@alloc/quick-lru@5.2.0': {} @@ -4782,6 +4809,24 @@ snapshots: '@antfu/utils@0.7.10': {} + '@asamuzakjp/css-color@4.1.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 11.2.4 + + '@asamuzakjp/dom-selector@6.7.5': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.4 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/helper-string-parser@7.25.9': {} '@babel/helper-validator-identifier@7.25.9': {} @@ -4888,11 +4933,18 @@ snapshots: '@csstools/color-helpers@2.1.0': {} + '@csstools/color-helpers@5.1.0': {} + '@csstools/css-calc@1.1.1(@csstools/css-parser-algorithms@2.2.0(@csstools/css-tokenizer@2.1.1))(@csstools/css-tokenizer@2.1.1)': dependencies: '@csstools/css-parser-algorithms': 2.2.0(@csstools/css-tokenizer@2.1.1) '@csstools/css-tokenizer': 2.1.1 + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-color-parser@1.2.1(@csstools/css-parser-algorithms@2.2.0(@csstools/css-tokenizer@2.1.1))(@csstools/css-tokenizer@2.1.1)': dependencies: '@csstools/color-helpers': 2.1.0 @@ -4900,12 +4952,27 @@ snapshots: '@csstools/css-parser-algorithms': 2.2.0(@csstools/css-tokenizer@2.1.1) '@csstools/css-tokenizer': 2.1.1 + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-parser-algorithms@2.2.0(@csstools/css-tokenizer@2.1.1)': dependencies: '@csstools/css-tokenizer': 2.1.1 + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.20': {} + '@csstools/css-tokenizer@2.1.1': {} + '@csstools/css-tokenizer@3.0.4': {} + '@csstools/media-query-list-parser@2.1.1(@csstools/css-parser-algorithms@2.2.0(@csstools/css-tokenizer@2.1.1))(@csstools/css-tokenizer@2.1.1)': dependencies: '@csstools/css-parser-algorithms': 2.2.0(@csstools/css-tokenizer@2.1.1) @@ -5590,60 +5657,41 @@ snapshots: '@scmmishra/pico-search@0.5.4': {} - '@sentry-internal/browser-utils@8.31.0': + '@sentry-internal/browser-utils@8.55.0': dependencies: - '@sentry/core': 8.31.0 - '@sentry/types': 8.31.0 - '@sentry/utils': 8.31.0 + '@sentry/core': 8.55.0 - '@sentry-internal/feedback@8.31.0': + '@sentry-internal/feedback@8.55.0': dependencies: - '@sentry/core': 8.31.0 - '@sentry/types': 8.31.0 - '@sentry/utils': 8.31.0 + '@sentry/core': 8.55.0 - '@sentry-internal/replay-canvas@8.31.0': + '@sentry-internal/replay-canvas@8.55.0': dependencies: - '@sentry-internal/replay': 8.31.0 - '@sentry/core': 8.31.0 - '@sentry/types': 8.31.0 - '@sentry/utils': 8.31.0 + '@sentry-internal/replay': 8.55.0 + '@sentry/core': 8.55.0 - '@sentry-internal/replay@8.31.0': + '@sentry-internal/replay@8.55.0': dependencies: - '@sentry-internal/browser-utils': 8.31.0 - '@sentry/core': 8.31.0 - '@sentry/types': 8.31.0 - '@sentry/utils': 8.31.0 + '@sentry-internal/browser-utils': 8.55.0 + '@sentry/core': 8.55.0 - '@sentry/browser@8.31.0': + '@sentry/browser@8.55.0': dependencies: - '@sentry-internal/browser-utils': 8.31.0 - '@sentry-internal/feedback': 8.31.0 - '@sentry-internal/replay': 8.31.0 - '@sentry-internal/replay-canvas': 8.31.0 - '@sentry/core': 8.31.0 - '@sentry/types': 8.31.0 - '@sentry/utils': 8.31.0 + '@sentry-internal/browser-utils': 8.55.0 + '@sentry-internal/feedback': 8.55.0 + '@sentry-internal/replay': 8.55.0 + '@sentry-internal/replay-canvas': 8.55.0 + '@sentry/core': 8.55.0 - '@sentry/core@8.31.0': + '@sentry/core@8.55.0': {} + + '@sentry/vue@8.55.0(pinia@3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2)))(vue@3.5.12(typescript@5.6.2))': dependencies: - '@sentry/types': 8.31.0 - '@sentry/utils': 8.31.0 - - '@sentry/types@8.31.0': {} - - '@sentry/utils@8.31.0': - dependencies: - '@sentry/types': 8.31.0 - - '@sentry/vue@8.31.0(vue@3.5.12(typescript@5.6.2))': - dependencies: - '@sentry/browser': 8.31.0 - '@sentry/core': 8.31.0 - '@sentry/types': 8.31.0 - '@sentry/utils': 8.31.0 + '@sentry/browser': 8.55.0 + '@sentry/core': 8.55.0 vue: 3.5.12(typescript@5.6.2) + optionalDependencies: + pinia: 3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2)) '@sindresorhus/slugify@2.2.1': dependencies: @@ -5739,7 +5787,7 @@ snapshots: vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) vue: 3.5.12(typescript@5.6.2) - '@vitest/coverage-v8@3.0.5(vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0))': + '@vitest/coverage-v8@3.0.5(vitest@3.0.5(@types/node@22.7.0)(jsdom@27.2.0)(sass@1.79.3)(terser@5.33.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -5753,7 +5801,7 @@ snapshots: std-env: 3.8.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0) + vitest: 3.0.5(@types/node@22.7.0)(jsdom@27.2.0)(sass@1.79.3)(terser@5.33.0) transitivePeerDependencies: - supports-color @@ -6048,11 +6096,7 @@ snapshots: transitivePeerDependencies: - supports-color - agent-base@7.1.1: - dependencies: - debug: 4.4.0 - transitivePeerDependencies: - - supports-color + agent-base@7.1.4: {} ajv@6.12.6: dependencies: @@ -6078,6 +6122,10 @@ snapshots: dependencies: type-fest: 1.4.0 + ansi-escapes@7.2.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} ansi-regex@6.0.1: {} @@ -6153,7 +6201,7 @@ snapshots: call-bind: 1.0.2 define-properties: 1.2.0 es-abstract: 1.22.2 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 is-array-buffer: 3.0.2 is-shared-array-buffer: 1.0.2 @@ -6190,16 +6238,20 @@ snapshots: dependencies: possible-typed-array-names: 1.0.0 - axios@1.8.2: + axios@1.13.2: dependencies: - follow-redirects: 1.15.9 - form-data: 4.0.2 + follow-redirects: 1.15.11 + form-data: 4.0.5 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug balanced-match@1.0.2: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.2.0: {} birpc@0.1.1: {} @@ -6261,11 +6313,11 @@ snapshots: call-bind@1.0.2: dependencies: function-bind: 1.1.2 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 call-bind@1.0.7: dependencies: - es-define-property: 1.0.0 + es-define-property: 1.0.1 es-errors: 1.3.0 function-bind: 1.1.2 get-intrinsic: 1.2.4 @@ -6368,20 +6420,16 @@ snapshots: cli-boxes@3.0.0: {} - cli-cursor@4.0.0: - dependencies: - restore-cursor: 4.0.0 - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 cli-spinners@2.9.2: {} - cli-truncate@3.1.0: + cli-truncate@5.1.1: dependencies: - slice-ansi: 5.0.0 - string-width: 5.1.2 + slice-ansi: 7.1.2 + string-width: 8.1.0 cliui@6.0.0: dependencies: @@ -6413,7 +6461,7 @@ snapshots: commander@10.0.1: {} - commander@11.0.0: {} + commander@14.0.2: {} commander@2.20.3: optional: true @@ -6486,6 +6534,11 @@ snapshots: dependencies: postcss: 8.4.47 + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + cssdb@7.6.0: {} cssesc@3.0.0: {} @@ -6498,9 +6551,11 @@ snapshots: dependencies: cssom: 0.3.8 - cssstyle@4.0.1: + cssstyle@5.3.3: dependencies: - rrweb-cssom: 0.6.0 + '@asamuzakjp/css-color': 4.1.0 + '@csstools/css-syntax-patches-for-csstree': 1.0.20 + css-tree: 3.1.0 csstype@3.1.3: {} @@ -6510,10 +6565,10 @@ snapshots: whatwg-mimetype: 3.0.0 whatwg-url: 11.0.0 - data-urls@5.0.0: + data-urls@6.0.0: dependencies: whatwg-mimetype: 4.0.0 - whatwg-url: 14.0.0 + whatwg-url: 15.1.0 data-view-buffer@1.0.1: dependencies: @@ -6553,10 +6608,6 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.3.4: - dependencies: - ms: 2.1.2 - debug@4.3.5: dependencies: ms: 2.1.2 @@ -6565,19 +6616,25 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + decamelize@1.2.0: {} decimal.js@10.4.3: {} + decimal.js@10.6.0: {} + deep-eql@5.0.2: {} deep-is@0.1.4: {} define-data-property@1.1.4: dependencies: - es-define-property: 1.0.0 + es-define-property: 1.0.1 es-errors: 1.3.0 - gopd: 1.0.1 + gopd: 1.2.0 define-properties@1.2.0: dependencies: @@ -6689,6 +6746,10 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: {} + + environment@1.1.0: {} + es-abstract@1.22.2: dependencies: array-buffer-byte-length: 1.0.0 @@ -6698,14 +6759,14 @@ snapshots: es-set-tostringtag: 2.1.0 es-to-primitive: 1.2.1 function.prototype.name: 1.1.6 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 get-symbol-description: 1.0.0 globalthis: 1.0.3 - gopd: 1.0.1 + gopd: 1.2.0 has: 1.0.3 has-property-descriptors: 1.0.2 has-proto: 1.0.1 - has-symbols: 1.0.3 + has-symbols: 1.1.0 internal-slot: 1.0.5 is-array-buffer: 3.0.2 is-callable: 1.2.7 @@ -6740,7 +6801,7 @@ snapshots: data-view-buffer: 1.0.1 data-view-byte-length: 1.0.1 data-view-byte-offset: 1.0.0 - es-define-property: 1.0.0 + es-define-property: 1.0.1 es-errors: 1.3.0 es-object-atoms: 1.0.0 es-set-tostringtag: 2.1.0 @@ -6749,10 +6810,10 @@ snapshots: get-intrinsic: 1.2.4 get-symbol-description: 1.0.2 globalthis: 1.0.4 - gopd: 1.0.1 + gopd: 1.2.0 has-property-descriptors: 1.0.2 has-proto: 1.0.3 - has-symbols: 1.0.3 + has-symbols: 1.1.0 hasown: 2.0.2 internal-slot: 1.0.7 is-array-buffer: 3.0.4 @@ -6780,10 +6841,6 @@ snapshots: unbox-primitive: 1.0.2 which-typed-array: 1.1.15 - es-define-property@1.0.0: - dependencies: - get-intrinsic: 1.2.4 - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -7048,18 +7105,6 @@ snapshots: eventemitter3@5.0.1: {} - execa@7.2.0: - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 4.3.1 - is-stream: 3.0.0 - merge-stream: 2.0.0 - npm-run-path: 5.1.0 - onetime: 6.0.0 - signal-exit: 3.0.7 - strip-final-newline: 3.0.0 - expect-type@1.1.0: {} extend-shallow@2.0.1: @@ -7150,7 +7195,7 @@ snapshots: vue: 3.5.12(typescript@5.6.2) vue-resize: 2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2)) - follow-redirects@1.15.9: {} + follow-redirects@1.15.11: {} for-each@0.3.3: dependencies: @@ -7161,17 +7206,12 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.0: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - mime-types: 2.1.35 - - form-data@4.0.2: + form-data@4.0.5: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 + hasown: 2.0.2 mime-types: 2.1.35 fraction.js@4.3.7: {} @@ -7202,12 +7242,14 @@ snapshots: get-east-asian-width@1.2.0: {} + get-east-asian-width@1.4.0: {} + get-intrinsic@1.2.4: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 has-proto: 1.0.3 - has-symbols: 1.0.3 + has-symbols: 1.1.0 hasown: 2.0.2 get-intrinsic@1.3.0: @@ -7228,12 +7270,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - get-stream@6.0.1: {} - get-symbol-description@1.0.0: dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 get-symbol-description@1.0.2: dependencies: @@ -7291,7 +7331,7 @@ snapshots: globalthis@1.0.4: dependencies: define-properties: 1.2.1 - gopd: 1.0.1 + gopd: 1.2.0 globby@11.1.0: dependencies: @@ -7310,10 +7350,6 @@ snapshots: merge2: 1.4.1 slash: 4.0.0 - gopd@1.0.1: - dependencies: - get-intrinsic: 1.2.4 - gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -7337,7 +7373,7 @@ snapshots: has-property-descriptors@1.0.2: dependencies: - es-define-property: 1.0.0 + es-define-property: 1.0.1 has-proto@1.0.1: {} @@ -7445,8 +7481,8 @@ snapshots: http-proxy-agent@7.0.2: dependencies: - agent-base: 7.1.1 - debug: 4.4.0 + agent-base: 7.1.4 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -7457,15 +7493,13 @@ snapshots: transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.5: + https-proxy-agent@7.0.6: dependencies: - agent-base: 7.1.1 - debug: 4.4.0 + agent-base: 7.1.4 + debug: 4.4.3 transitivePeerDependencies: - supports-color - human-signals@4.3.1: {} - husky@7.0.4: {} iconv-lite@0.6.3: @@ -7503,7 +7537,7 @@ snapshots: internal-slot@1.0.5: dependencies: - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 has: 1.0.3 side-channel: 1.0.6 @@ -7516,7 +7550,7 @@ snapshots: is-array-buffer@3.0.2: dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 is-typed-array: 1.1.12 is-array-buffer@3.0.4: @@ -7559,7 +7593,9 @@ snapshots: is-fullwidth-code-point@3.0.0: {} - is-fullwidth-code-point@4.0.0: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.4.0 is-function@1.0.2: {} @@ -7607,15 +7643,13 @@ snapshots: dependencies: call-bind: 1.0.7 - is-stream@3.0.0: {} - is-string@1.0.7: dependencies: has-tostringtag: 1.0.2 is-symbol@1.0.4: dependencies: - has-symbols: 1.0.3 + has-symbols: 1.1.0 is-typed-array@1.1.12: dependencies: @@ -7698,7 +7732,7 @@ snapshots: decimal.js: 10.4.3 domexception: 4.0.0 escodegen: 2.1.0 - form-data: 4.0.2 + form-data: 4.0.5 html-encoding-sniffer: 3.0.0 http-proxy-agent: 5.0.0 https-proxy-agent: 5.0.1 @@ -7720,28 +7754,27 @@ snapshots: - supports-color - utf-8-validate - jsdom@24.1.3: + jsdom@27.2.0: dependencies: - cssstyle: 4.0.1 - data-urls: 5.0.0 - decimal.js: 10.4.3 - form-data: 4.0.0 + '@acemir/cssom': 0.9.24 + '@asamuzakjp/dom-selector': 6.7.5 + cssstyle: 5.3.3 + data-urls: 6.0.0 + decimal.js: 10.6.0 html-encoding-sniffer: 4.0.0 http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.5 + https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.12 - parse5: 7.1.2 - rrweb-cssom: 0.7.1 + parse5: 8.0.0 saxes: 6.0.0 symbol-tree: 3.2.4 - tough-cookie: 4.1.4 + tough-cookie: 6.0.0 w3c-xmlserializer: 5.0.0 - webidl-conversions: 7.0.0 + webidl-conversions: 8.0.0 whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 - whatwg-url: 14.0.0 - ws: 8.18.0 + whatwg-url: 15.1.0 + ws: 8.18.3 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -7819,32 +7852,24 @@ snapshots: dependencies: uc.micro: 2.1.0 - lint-staged@14.0.1(enquirer@2.4.1): + lint-staged@16.2.7: dependencies: - chalk: 5.3.0 - commander: 11.0.0 - debug: 4.3.4 - execa: 7.2.0 - lilconfig: 2.1.0 - listr2: 6.6.1(enquirer@2.4.1) - micromatch: 4.0.5 + commander: 14.0.2 + listr2: 9.0.5 + micromatch: 4.0.8 + nano-spawn: 2.0.0 pidtree: 0.6.0 string-argv: 0.3.2 - yaml: 2.3.1 - transitivePeerDependencies: - - enquirer - - supports-color + yaml: 2.8.2 - listr2@6.6.1(enquirer@2.4.1): + listr2@9.0.5: dependencies: - cli-truncate: 3.1.0 + cli-truncate: 5.1.1 colorette: 2.0.20 eventemitter3: 5.0.1 - log-update: 5.0.1 - rfdc: 1.3.0 - wrap-ansi: 8.1.0 - optionalDependencies: - enquirer: 2.4.1 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.0 lit-element@3.3.3: dependencies: @@ -7894,13 +7919,13 @@ snapshots: chalk: 5.3.0 is-unicode-supported: 1.3.0 - log-update@5.0.1: + log-update@6.1.0: dependencies: - ansi-escapes: 5.0.0 - cli-cursor: 4.0.0 - slice-ansi: 5.0.0 + ansi-escapes: 7.2.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 strip-ansi: 7.1.0 - wrap-ansi: 8.1.0 + wrap-ansi: 9.0.0 loupe@3.1.3: {} @@ -7910,6 +7935,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.2.4: {} + lru-cache@6.0.0: dependencies: yallist: 4.0.0 @@ -7990,19 +8017,14 @@ snapshots: crypt: 0.0.2 is-buffer: 1.1.6 + mdn-data@2.12.2: {} + mdurl@1.0.1: {} mdurl@2.0.0: {} - merge-stream@2.0.0: {} - merge2@1.4.1: {} - micromatch@4.0.5: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -8014,10 +8036,6 @@ snapshots: dependencies: mime-db: 1.52.0 - mimic-fn@2.1.0: {} - - mimic-fn@4.0.0: {} - mimic-function@5.0.1: {} min-document@2.19.0: @@ -8081,6 +8099,8 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nano-spawn@2.0.0: {} + nanoid@3.3.11: {} nanoid@3.3.8: {} @@ -8108,10 +8128,6 @@ snapshots: normalize-range@0.1.2: {} - npm-run-path@5.1.0: - dependencies: - path-key: 4.0.0 - nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -8139,7 +8155,7 @@ snapshots: dependencies: call-bind: 1.0.7 define-properties: 1.2.1 - has-symbols: 1.0.3 + has-symbols: 1.1.0 object-keys: 1.1.1 object.entries@1.1.7: @@ -8175,14 +8191,6 @@ snapshots: dependencies: wrappy: 1.0.2 - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - onetime@6.0.0: - dependencies: - mimic-fn: 4.0.0 - onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -8255,6 +8263,10 @@ snapshots: dependencies: entities: 4.5.0 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} pascal-case@3.1.2: @@ -8275,8 +8287,6 @@ snapshots: path-key@3.1.1: {} - path-key@4.0.0: {} - path-parse@1.0.7: {} path-scurry@1.11.1: @@ -8758,11 +8768,6 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - restore-cursor@4.0.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -8770,8 +8775,6 @@ snapshots: reusify@1.0.4: {} - rfdc@1.3.0: {} - rfdc@1.4.1: {} rimraf@3.0.2: @@ -8808,10 +8811,6 @@ snapshots: rope-sequence@1.3.2: {} - rrweb-cssom@0.6.0: {} - - rrweb-cssom@0.7.1: {} - run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -8827,15 +8826,15 @@ snapshots: safe-array-concat@1.0.1: dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.4 - has-symbols: 1.0.3 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 isarray: 2.0.5 safe-array-concat@1.1.2: dependencies: call-bind: 1.0.7 get-intrinsic: 1.2.4 - has-symbols: 1.0.3 + has-symbols: 1.1.0 isarray: 2.0.5 safe-json-parse@4.0.0: @@ -8845,7 +8844,7 @@ snapshots: safe-regex-test@1.0.0: dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 is-regex: 1.1.4 safe-regex-test@1.0.3: @@ -8896,7 +8895,7 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 get-intrinsic: 1.2.4 - gopd: 1.0.1 + gopd: 1.2.0 has-property-descriptors: 1.0.2 set-function-name@2.0.2: @@ -8925,8 +8924,6 @@ snapshots: siginfo@2.0.0: {} - signal-exit@3.0.7: {} - signal-exit@4.1.0: {} sirv@2.0.4: @@ -8954,10 +8951,10 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 - slice-ansi@5.0.0: + slice-ansi@7.1.2: dependencies: ansi-styles: 6.2.1 - is-fullwidth-code-point: 4.0.0 + is-fullwidth-code-point: 5.1.0 snake-case@3.0.4: dependencies: @@ -9017,6 +9014,11 @@ snapshots: get-east-asian-width: 1.2.0 strip-ansi: 7.1.0 + string-width@8.1.0: + dependencies: + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.0 + string.prototype.trim@1.2.8: dependencies: call-bind: 1.0.2 @@ -9066,8 +9068,6 @@ snapshots: strip-bom@3.0.0: {} - strip-final-newline@3.0.0: {} - strip-json-comments@3.1.1: {} style-mod@4.1.2: {} @@ -9182,6 +9182,12 @@ snapshots: tinyspy@3.0.2: {} + tldts-core@7.0.19: {} + + tldts@7.0.19: + dependencies: + tldts-core: 7.0.19 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -9195,11 +9201,15 @@ snapshots: universalify: 0.2.0 url-parse: 1.5.10 + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.19 + tr46@3.0.0: dependencies: punycode: 2.3.1 - tr46@5.0.0: + tr46@6.0.0: dependencies: punycode: 2.3.1 @@ -9231,7 +9241,7 @@ snapshots: typed-array-buffer@1.0.0: dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 is-typed-array: 1.1.12 typed-array-buffer@1.0.2: @@ -9251,7 +9261,7 @@ snapshots: dependencies: call-bind: 1.0.7 for-each: 0.3.3 - gopd: 1.0.1 + gopd: 1.2.0 has-proto: 1.0.3 is-typed-array: 1.1.13 @@ -9268,7 +9278,7 @@ snapshots: available-typed-arrays: 1.0.7 call-bind: 1.0.7 for-each: 0.3.3 - gopd: 1.0.1 + gopd: 1.2.0 has-proto: 1.0.3 is-typed-array: 1.1.13 @@ -9282,7 +9292,7 @@ snapshots: dependencies: call-bind: 1.0.7 for-each: 0.3.3 - gopd: 1.0.1 + gopd: 1.2.0 has-proto: 1.0.3 is-typed-array: 1.1.13 possible-typed-array-names: 1.0.0 @@ -9300,7 +9310,7 @@ snapshots: dependencies: call-bind: 1.0.7 has-bigints: 1.0.2 - has-symbols: 1.0.3 + has-symbols: 1.1.0 which-boxed-primitive: 1.0.2 undici-types@6.19.8: {} @@ -9419,7 +9429,7 @@ snapshots: sass: 1.79.3 terser: 5.33.0 - vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0): + vitest@3.0.5(@types/node@22.7.0)(jsdom@27.2.0)(sass@1.79.3)(terser@5.33.0): dependencies: '@vitest/expect': 3.0.5 '@vitest/mocker': 3.0.5(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) @@ -9443,7 +9453,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.7.0 - jsdom: 24.1.3 + jsdom: 27.2.0 transitivePeerDependencies: - less - lightningcss @@ -9577,6 +9587,8 @@ snapshots: webidl-conversions@7.0.0: {} + webidl-conversions@8.0.0: {} + webrtc-adapter@9.0.1: dependencies: sdp: 3.2.0 @@ -9598,10 +9610,10 @@ snapshots: tr46: 3.0.0 webidl-conversions: 7.0.0 - whatwg-url@14.0.0: + whatwg-url@15.1.0: dependencies: - tr46: 5.0.0 - webidl-conversions: 7.0.0 + tr46: 6.0.0 + webidl-conversions: 8.0.0 which-boxed-primitive@1.0.2: dependencies: @@ -9618,7 +9630,7 @@ snapshots: available-typed-arrays: 1.0.5 call-bind: 1.0.2 for-each: 0.3.3 - gopd: 1.0.1 + gopd: 1.2.0 has-tostringtag: 1.0.2 which-typed-array@1.1.15: @@ -9626,7 +9638,7 @@ snapshots: available-typed-arrays: 1.0.7 call-bind: 1.0.7 for-each: 0.3.3 - gopd: 1.0.1 + gopd: 1.2.0 has-tostringtag: 1.0.2 which@2.0.2: @@ -9670,6 +9682,8 @@ snapshots: ws@8.18.0: {} + ws@8.18.3: {} + xml-name-validator@4.0.0: {} xml-name-validator@5.0.0: {} @@ -9688,10 +9702,10 @@ snapshots: lodash: 4.17.21 yaml: 2.5.1 - yaml@2.3.1: {} - yaml@2.5.1: {} + yaml@2.8.2: {} + yargs-parser@18.1.3: dependencies: camelcase: 5.3.1 From 0a17976913adde20a77a380337ca16a39af7f1e8 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 4 Dec 2025 16:09:04 +0530 Subject: [PATCH 07/18] fix: Filter out unsupported ephemeral message attachments (#13003) Fixes https://linear.app/chatwoot/issue/CW-6070/argumenterror-ephemeral-is-not-a-valid-file-type-argumenterror **Problem** The instagram webhooks containing ephemeral (disappearing) message were causing ArgumentError exceptions because this attachment type is not supported and was not in the enum validation. **Solution** - Added ephemeral to the unsupported_file_type? filter - Ephemeral attachments are now silently filtered out before processing, following the same pattern as existing unsupported types (template, unsupported_type) --- app/builders/messages/messenger/message_builder.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index 7bc571f6c..8821da9d5 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -101,6 +101,6 @@ class Messages::Messenger::MessageBuilder private def unsupported_file_type?(attachment_type) - [:template, :unsupported_type].include? attachment_type.to_sym + [:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym end end From eed2eaceb05375b63c282cc20143d630b388c6be Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:26:10 +0530 Subject: [PATCH 08/18] feat: Migrate ruby llm captain (#12981) Co-authored-by: aakashb95 Co-authored-by: Shivam Mishra --- .rubocop.yml | 2 +- enterprise/app/helpers/captain/chat_helper.rb | 84 ++++++--- .../helpers/captain/chat_response_helper.rb | 52 ++++++ .../helpers/captain/tool_execution_helper.rb | 83 --------- .../services/captain/copilot/chat_service.rb | 36 ++-- .../captain/llm/assistant_chat_service.rb | 12 +- .../captain/llm/contact_attributes_service.rb | 2 +- .../captain/llm/contact_notes_service.rb | 2 +- .../captain/llm/conversation_faq_service.rb | 2 +- .../services/captain/llm/embedding_service.rb | 2 +- .../captain/llm/faq_generator_service.rb | 2 +- .../llm/paginated_faq_generator_service.rb | 2 +- .../captain/llm/pdf_processing_service.rb | 2 +- .../onboarding/website_analyzer_service.rb | 2 +- .../app/services/captain/tools/base_tool.rb | 26 +++ .../tools/copilot/get_article_service.rb | 31 +--- .../tools/copilot/get_contact_service.rb | 31 +--- .../tools/copilot/get_conversation_service.rb | 30 +--- .../tools/copilot/search_articles_service.rb | 56 ++---- .../tools/copilot/search_contacts_service.rb | 46 +---- .../copilot/search_conversations_service.rb | 38 +--- .../copilot/search_linear_issues_service.rb | 30 +--- .../tools/search_documentation_service.rb | 25 +-- .../content_evaluator_service.rb | 2 +- .../app/services/llm/base_ai_service.rb | 33 ++++ ...vice.rb => legacy_base_open_ai_service.rb} | 10 +- .../messages/audio_transcription_service.rb | 2 +- lib/integrations/llm_instrumentation.rb | 28 +-- .../llm_instrumentation_helpers.rb | 13 ++ lib/integrations/llm_instrumentation_spans.rb | 92 ++++++++++ lib/llm/config.rb | 1 + .../captain/copilot/chat_service_spec.rb | 166 ++++-------------- .../tools/copilot/get_article_service_spec.rb | 23 +-- .../tools/copilot/get_contact_service_spec.rb | 25 +-- .../copilot/get_conversation_service_spec.rb | 29 +-- .../copilot/search_articles_service_spec.rb | 40 +---- .../copilot/search_contacts_service_spec.rb | 31 +--- .../search_conversations_service_spec.rb | 18 +- .../search_linear_issues_service_spec.rb | 31 ++-- .../search_documentation_service_spec.rb | 19 +- .../integrations/llm_instrumentation_spec.rb | 47 ----- 41 files changed, 474 insertions(+), 734 deletions(-) create mode 100644 enterprise/app/helpers/captain/chat_response_helper.rb delete mode 100644 enterprise/app/helpers/captain/tool_execution_helper.rb create mode 100644 enterprise/app/services/captain/tools/base_tool.rb create mode 100644 enterprise/app/services/llm/base_ai_service.rb rename enterprise/app/services/llm/{base_open_ai_service.rb => legacy_base_open_ai_service.rb} (69%) create mode 100644 lib/integrations/llm_instrumentation_spans.rb diff --git a/.rubocop.yml b/.rubocop.yml index f5b8a2c1c..22c4220d9 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -88,7 +88,7 @@ Metrics/ModuleLength: Rails/HelperInstanceVariable: Exclude: - enterprise/app/helpers/captain/chat_helper.rb - - 'enterprise/app/helpers/captain/tool_execution_helper.rb' + - enterprise/app/helpers/captain/chat_response_helper.rb Rails/ApplicationController: Exclude: - 'app/controllers/api/v1/widget/messages_controller.rb' diff --git a/enterprise/app/helpers/captain/chat_helper.rb b/enterprise/app/helpers/captain/chat_helper.rb index c2f4311a7..a60e3015f 100644 --- a/enterprise/app/helpers/captain/chat_helper.rb +++ b/enterprise/app/helpers/captain/chat_helper.rb @@ -1,31 +1,83 @@ module Captain::ChatHelper include Integrations::LlmInstrumentation - include Captain::ToolExecutionHelper + include Captain::ChatResponseHelper def request_chat_completion log_chat_completion_request + chat = build_chat + + add_messages_to_chat(chat) with_agent_session do - response = instrument_llm_call(instrumentation_params) do - @client.chat( - parameters: chat_parameters - ) - end - handle_response(response) + response = chat.ask(conversation_messages.last[:content]) + build_response(response) end rescue StandardError => e Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}" raise e end - def instrumentation_params + private + + def build_chat + llm_chat = chat(model: @model, temperature: temperature) + llm_chat.with_params(response_format: { type: 'json_object' }) + + llm_chat = setup_tools(llm_chat) + setup_system_instructions(llm_chat) + setup_event_handlers(llm_chat) + + llm_chat + end + + def setup_tools(chat) + @tools&.each do |tool| + chat.with_tool(tool) + end + chat + end + + def setup_system_instructions(chat) + system_messages = @messages.select { |m| m[:role] == 'system' || m[:role] == :system } + combined_instructions = system_messages.pluck(:content).join("\n\n") + chat.with_instructions(combined_instructions) + end + + def setup_event_handlers(chat) + chat.on_new_message { start_llm_turn_span(instrumentation_params(chat)) } + chat.on_end_message { |message| end_llm_turn_span(message) } + chat.on_tool_call { |tool_call| handle_tool_call(tool_call) } + chat.on_tool_result { |result| handle_tool_result(result) } + + chat + end + + def handle_tool_call(tool_call) + persist_thinking_message(tool_call) + start_tool_span(tool_call) + @pending_tool_calls ||= [] + @pending_tool_calls.push(tool_call) + end + + def handle_tool_result(result) + end_tool_span(result) + persist_tool_completion + end + + def add_messages_to_chat(chat) + conversation_messages[0...-1].each do |msg| + chat.add_message(role: msg[:role].to_sym, content: msg[:content]) + end + end + + def instrumentation_params(chat = nil) { span_name: "llm.captain.#{feature_name}", account_id: resolved_account_id, conversation_id: @conversation_id, feature_name: feature_name, model: @model, - messages: @messages, + messages: chat ? chat.messages.map { |m| { role: m.role.to_s, content: m.content.to_s } } : @messages, temperature: temperature, metadata: { assistant_id: @assistant&.id @@ -33,14 +85,8 @@ module Captain::ChatHelper } end - def chat_parameters - { - model: @model, - messages: @messages, - tools: @tool_registry&.registered_tools || [], - response_format: { type: 'json_object' }, - temperature: temperature - } + def conversation_messages + @messages.reject { |m| m[:role] == 'system' || m[:role] == :system } end def temperature @@ -51,8 +97,6 @@ module Captain::ChatHelper @account&.id || @assistant&.account_id end - private - # Ensures all LLM calls and tool executions within an agentic loop # are grouped under a single trace/session in Langfuse. # @@ -78,7 +122,7 @@ module Captain::ChatHelper def log_chat_completion_request Rails.logger.info( "#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion - for messages #{@messages} with #{@tool_registry&.registered_tools&.length || 0} tools + for messages #{@messages} with #{@tools&.length || 0} tools " ) end diff --git a/enterprise/app/helpers/captain/chat_response_helper.rb b/enterprise/app/helpers/captain/chat_response_helper.rb new file mode 100644 index 000000000..e0323996f --- /dev/null +++ b/enterprise/app/helpers/captain/chat_response_helper.rb @@ -0,0 +1,52 @@ +module Captain::ChatResponseHelper + private + + def build_response(response) + Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" } + + parsed = parse_json_response(response.content) + + persist_message(parsed, 'assistant') + parsed + end + + def parse_json_response(content) + content = content.gsub('```json', '').gsub('```', '') + content = content.strip + JSON.parse(content) + rescue JSON::ParserError => e + Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error parsing JSON response: #{e.message}" + { 'content' => content } + end + + def persist_thinking_message(tool_call) + return if @copilot_thread.blank? + + tool_name = tool_call.name.to_s + + persist_message( + { + 'content' => "Using #{tool_name}", + 'function_name' => tool_name + }, + 'assistant_thinking' + ) + end + + def persist_tool_completion + return if @copilot_thread.blank? + + tool_call = @pending_tool_calls&.pop + return unless tool_call + + tool_name = tool_call.name.to_s + + persist_message( + { + 'content' => "Completed #{tool_name}", + 'function_name' => tool_name + }, + 'assistant_thinking' + ) + end +end diff --git a/enterprise/app/helpers/captain/tool_execution_helper.rb b/enterprise/app/helpers/captain/tool_execution_helper.rb deleted file mode 100644 index 45c546c9c..000000000 --- a/enterprise/app/helpers/captain/tool_execution_helper.rb +++ /dev/null @@ -1,83 +0,0 @@ -module Captain::ToolExecutionHelper - private - - def handle_response(response) - Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" } - message = response.dig('choices', 0, 'message') - - if message['tool_calls'] - process_tool_calls(message['tool_calls']) - else - message = JSON.parse(message['content'].strip) - persist_message(message, 'assistant') - message - end - end - - def process_tool_calls(tool_calls) - append_tool_calls(tool_calls) - tool_calls.each { |tool_call| process_tool_call(tool_call) } - request_chat_completion - end - - def process_tool_call(tool_call) - arguments = JSON.parse(tool_call['function']['arguments']) - function_name = tool_call['function']['name'] - tool_call_id = tool_call['id'] - - if @tool_registry.respond_to?(function_name) - execute_tool(function_name, arguments, tool_call_id) - else - process_invalid_tool_call(function_name, tool_call_id) - end - end - - def execute_tool(function_name, arguments, tool_call_id) - persist_tool_status(function_name, 'captain.copilot.using_tool') - result = perform_tool_call(function_name, arguments) - persist_tool_status(function_name, 'captain.copilot.completed_tool_call') - append_tool_response(result, tool_call_id) - end - - def perform_tool_call(function_name, arguments) - instrument_tool_call(function_name, arguments) do - @tool_registry.send(function_name, arguments) - end - rescue StandardError => e - Rails.logger.error "Tool #{function_name} failed: #{e.message}" - "Error executing #{function_name}: #{e.message}" - end - - def persist_tool_status(function_name, translation_key) - persist_message( - { - content: I18n.t(translation_key, function_name: function_name), - function_name: function_name - }, - 'assistant_thinking' - ) - end - - def append_tool_calls(tool_calls) - @messages << { - role: 'assistant', - tool_calls: tool_calls - } - end - - def process_invalid_tool_call(function_name, tool_call_id) - persist_message( - { content: I18n.t('captain.copilot.invalid_tool_call'), function_name: function_name }, - 'assistant_thinking' - ) - append_tool_response(I18n.t('captain.copilot.tool_not_available'), tool_call_id) - end - - def append_tool_response(content, tool_call_id) - @messages << { - role: 'tool', - tool_call_id: tool_call_id, - content: content - } - end -end diff --git a/enterprise/app/services/captain/copilot/chat_service.rb b/enterprise/app/services/captain/copilot/chat_service.rb index 6db7a973b..229bf90ca 100644 --- a/enterprise/app/services/captain/copilot/chat_service.rb +++ b/enterprise/app/services/captain/copilot/chat_service.rb @@ -1,6 +1,4 @@ -require 'openai' - -class Captain::Copilot::ChatService < Llm::BaseOpenAiService +class Captain::Copilot::ChatService < Llm::BaseAiService include Captain::ChatHelper attr_reader :assistant, :account, :user, :copilot_thread, :previous_history, :messages @@ -14,9 +12,10 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService @copilot_thread = nil @previous_history = [] @conversation_id = config[:conversation_id] + setup_user(config) setup_message_history(config) - register_tools + @tools = build_tools @messages = build_messages(config) end @@ -60,16 +59,19 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService end end - def register_tools - @tool_registry = Captain::ToolRegistryService.new(@assistant, user: @user) - @tool_registry.register_tool(Captain::Tools::SearchDocumentationService) - @tool_registry.register_tool(Captain::Tools::Copilot::GetArticleService) - @tool_registry.register_tool(Captain::Tools::Copilot::GetContactService) - @tool_registry.register_tool(Captain::Tools::Copilot::GetConversationService) - @tool_registry.register_tool(Captain::Tools::Copilot::SearchArticlesService) - @tool_registry.register_tool(Captain::Tools::Copilot::SearchContactsService) - @tool_registry.register_tool(Captain::Tools::Copilot::SearchConversationsService) - @tool_registry.register_tool(Captain::Tools::Copilot::SearchLinearIssuesService) + def build_tools + tools = [] + + tools << Captain::Tools::SearchDocumentationService.new(@assistant, user: @user) + tools << Captain::Tools::Copilot::GetConversationService.new(@assistant, user: @user) + tools << Captain::Tools::Copilot::SearchConversationsService.new(@assistant, user: @user) + tools << Captain::Tools::Copilot::GetContactService.new(@assistant, user: @user) + tools << Captain::Tools::Copilot::GetArticleService.new(@assistant, user: @user) + tools << Captain::Tools::Copilot::SearchArticlesService.new(@assistant, user: @user) + tools << Captain::Tools::Copilot::SearchContactsService.new(@assistant, user: @user) + tools << Captain::Tools::Copilot::SearchLinearIssuesService.new(@assistant, user: @user) + + tools.select(&:active?) end def system_message @@ -77,12 +79,16 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService role: 'system', content: Captain::Llm::SystemPromptsService.copilot_response_generator( @assistant.config['product_name'], - @tool_registry.tools_summary, + tools_summary, @assistant.config ) } end + def tools_summary + @tools.map { |tool| "- #{tool.class.name}: #{tool.class.description}" }.join("\n") + end + def account_id_context { role: 'system', diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb index 54e13e1d0..5a2976e39 100644 --- a/enterprise/app/services/captain/llm/assistant_chat_service.rb +++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb @@ -1,6 +1,4 @@ -require 'openai' - -class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService +class Captain::Llm::AssistantChatService < Llm::BaseAiService include Captain::ChatHelper def initialize(assistant: nil, conversation_id: nil) @@ -8,9 +6,10 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService @assistant = assistant @conversation_id = conversation_id + @messages = [system_message] @response = '' - register_tools + @tools = build_tools end # additional_message: A single message (String) from the user that should be appended to the chat. @@ -28,9 +27,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService private - def register_tools - @tool_registry = Captain::ToolRegistryService.new(@assistant, user: nil) - @tool_registry.register_tool(Captain::Tools::SearchDocumentationService) + def build_tools + [Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)] end def system_message diff --git a/enterprise/app/services/captain/llm/contact_attributes_service.rb b/enterprise/app/services/captain/llm/contact_attributes_service.rb index e7976bf27..f2c48de57 100644 --- a/enterprise/app/services/captain/llm/contact_attributes_service.rb +++ b/enterprise/app/services/captain/llm/contact_attributes_service.rb @@ -1,4 +1,4 @@ -class Captain::Llm::ContactAttributesService < Llm::BaseOpenAiService +class Captain::Llm::ContactAttributesService < Llm::LegacyBaseOpenAiService def initialize(assistant, conversation) super() @assistant = assistant diff --git a/enterprise/app/services/captain/llm/contact_notes_service.rb b/enterprise/app/services/captain/llm/contact_notes_service.rb index 480225a03..4ee4fcb5e 100644 --- a/enterprise/app/services/captain/llm/contact_notes_service.rb +++ b/enterprise/app/services/captain/llm/contact_notes_service.rb @@ -1,4 +1,4 @@ -class Captain::Llm::ContactNotesService < Llm::BaseOpenAiService +class Captain::Llm::ContactNotesService < Llm::LegacyBaseOpenAiService def initialize(assistant, conversation) super() @assistant = assistant diff --git a/enterprise/app/services/captain/llm/conversation_faq_service.rb b/enterprise/app/services/captain/llm/conversation_faq_service.rb index 077791a2c..47d1433bd 100644 --- a/enterprise/app/services/captain/llm/conversation_faq_service.rb +++ b/enterprise/app/services/captain/llm/conversation_faq_service.rb @@ -1,4 +1,4 @@ -class Captain::Llm::ConversationFaqService < Llm::BaseOpenAiService +class Captain::Llm::ConversationFaqService < Llm::LegacyBaseOpenAiService DISTANCE_THRESHOLD = 0.3 def initialize(assistant, conversation) diff --git a/enterprise/app/services/captain/llm/embedding_service.rb b/enterprise/app/services/captain/llm/embedding_service.rb index 1dbb0ce5b..5190ed28a 100644 --- a/enterprise/app/services/captain/llm/embedding_service.rb +++ b/enterprise/app/services/captain/llm/embedding_service.rb @@ -1,6 +1,6 @@ require 'openai' -class Captain::Llm::EmbeddingService < Llm::BaseOpenAiService +class Captain::Llm::EmbeddingService < Llm::LegacyBaseOpenAiService class EmbeddingsError < StandardError; end def self.embedding_model diff --git a/enterprise/app/services/captain/llm/faq_generator_service.rb b/enterprise/app/services/captain/llm/faq_generator_service.rb index 5cecc0e81..634385b36 100644 --- a/enterprise/app/services/captain/llm/faq_generator_service.rb +++ b/enterprise/app/services/captain/llm/faq_generator_service.rb @@ -1,4 +1,4 @@ -class Captain::Llm::FaqGeneratorService < Llm::BaseOpenAiService +class Captain::Llm::FaqGeneratorService < Llm::LegacyBaseOpenAiService def initialize(content, language = 'english') super() @language = language diff --git a/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb b/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb index 2d326f081..ebfef4b5c 100644 --- a/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb +++ b/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb @@ -1,4 +1,4 @@ -class Captain::Llm::PaginatedFaqGeneratorService < Llm::BaseOpenAiService +class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService # Default pages per chunk - easily configurable DEFAULT_PAGES_PER_CHUNK = 10 MAX_ITERATIONS = 20 # Safety limit to prevent infinite loops diff --git a/enterprise/app/services/captain/llm/pdf_processing_service.rb b/enterprise/app/services/captain/llm/pdf_processing_service.rb index 026ef4e48..3ab616577 100644 --- a/enterprise/app/services/captain/llm/pdf_processing_service.rb +++ b/enterprise/app/services/captain/llm/pdf_processing_service.rb @@ -1,4 +1,4 @@ -class Captain::Llm::PdfProcessingService < Llm::BaseOpenAiService +class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService def initialize(document) super() @document = document diff --git a/enterprise/app/services/captain/onboarding/website_analyzer_service.rb b/enterprise/app/services/captain/onboarding/website_analyzer_service.rb index d3e7d4983..02ba35571 100644 --- a/enterprise/app/services/captain/onboarding/website_analyzer_service.rb +++ b/enterprise/app/services/captain/onboarding/website_analyzer_service.rb @@ -1,4 +1,4 @@ -class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseOpenAiService +class Captain::Onboarding::WebsiteAnalyzerService < Llm::LegacyBaseOpenAiService MAX_CONTENT_LENGTH = 8000 def initialize(website_url) diff --git a/enterprise/app/services/captain/tools/base_tool.rb b/enterprise/app/services/captain/tools/base_tool.rb new file mode 100644 index 000000000..dbc2902d8 --- /dev/null +++ b/enterprise/app/services/captain/tools/base_tool.rb @@ -0,0 +1,26 @@ +class Captain::Tools::BaseTool < RubyLLM::Tool + attr_accessor :assistant + + def initialize(assistant, user: nil) + @assistant = assistant + @user = user + super() + end + + def active? + true + end + + private + + def user_has_permission(permission) + return false if @user.blank? + + account_user = AccountUser.find_by(account_id: @assistant.account_id, user_id: @user.id) + return false if account_user.blank? + + return account_user.custom_role.permissions.include?(permission) if account_user.custom_role.present? + + account_user.administrator? || account_user.agent? + end +end diff --git a/enterprise/app/services/captain/tools/copilot/get_article_service.rb b/enterprise/app/services/captain/tools/copilot/get_article_service.rb index 9c2ee02da..e870afd06 100644 --- a/enterprise/app/services/captain/tools/copilot/get_article_service.rb +++ b/enterprise/app/services/captain/tools/copilot/get_article_service.rb @@ -1,32 +1,11 @@ -class Captain::Tools::Copilot::GetArticleService < Captain::Tools::BaseService - def name +class Captain::Tools::Copilot::GetArticleService < Captain::Tools::BaseTool + def self.name 'get_article' end + description 'Get details of an article including its content and metadata' + param :article_id, type: :number, desc: 'The ID of the article to retrieve', required: true - def description - 'Get details of an article including its content and metadata' - end - - def parameters - { - type: 'object', - properties: { - article_id: { - type: 'number', - description: 'The ID of the article to retrieve' - } - }, - required: %w[article_id] - } - end - - def execute(arguments) - article_id = arguments['article_id'] - - Rails.logger.info { "#{self.class.name}: Article ID: #{article_id}" } - - return 'Missing required parameters' if article_id.blank? - + def execute(article_id:) article = Article.find_by(id: article_id, account_id: @assistant.account_id) return 'Article not found' if article.nil? diff --git a/enterprise/app/services/captain/tools/copilot/get_contact_service.rb b/enterprise/app/services/captain/tools/copilot/get_contact_service.rb index 290433301..da5453cae 100644 --- a/enterprise/app/services/captain/tools/copilot/get_contact_service.rb +++ b/enterprise/app/services/captain/tools/copilot/get_contact_service.rb @@ -1,32 +1,11 @@ -class Captain::Tools::Copilot::GetContactService < Captain::Tools::BaseService - def name +class Captain::Tools::Copilot::GetContactService < Captain::Tools::BaseTool + def self.name 'get_contact' end + description 'Get details of a contact including their profile information' + param :contact_id, type: :number, desc: 'The ID of the contact to retrieve', required: true - def description - 'Get details of a contact including their profile information' - end - - def parameters - { - type: 'object', - properties: { - contact_id: { - type: 'number', - description: 'The ID of the contact to retrieve' - } - }, - required: %w[contact_id] - } - end - - def execute(arguments) - contact_id = arguments['contact_id'] - - Rails.logger.info "#{self.class.name}: Contact ID: #{contact_id}" - - return 'Missing required parameters' if contact_id.blank? - + def execute(contact_id:) contact = Contact.find_by(id: contact_id, account_id: @assistant.account_id) return 'Contact not found' if contact.nil? diff --git a/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb b/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb index 6f942ee8e..6b52a147d 100644 --- a/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb +++ b/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb @@ -1,32 +1,12 @@ -class Captain::Tools::Copilot::GetConversationService < Captain::Tools::BaseService - def name +class Captain::Tools::Copilot::GetConversationService < Captain::Tools::BaseTool + def self.name 'get_conversation' end + description 'Get details of a conversation including messages and contact information' - def description - 'Get details of a conversation including messages and contact information' - end - - def parameters - { - type: 'object', - properties: { - conversation_id: { - type: 'number', - description: 'The ID of the conversation to retrieve' - } - }, - required: %w[conversation_id] - } - end - - def execute(arguments) - conversation_id = arguments['conversation_id'] - - Rails.logger.info "#{self.class.name}: Conversation ID: #{conversation_id}" - - return 'Missing required parameters' if conversation_id.blank? + param :conversation_id, type: :integer, desc: 'ID of the conversation to retrieve', required: true + def execute(conversation_id:) conversation = Conversation.find_by(display_id: conversation_id, account_id: @assistant.account_id) return 'Conversation not found' if conversation.blank? diff --git a/enterprise/app/services/captain/tools/copilot/search_articles_service.rb b/enterprise/app/services/captain/tools/copilot/search_articles_service.rb index 5061968ad..24385a932 100644 --- a/enterprise/app/services/captain/tools/copilot/search_articles_service.rb +++ b/enterprise/app/services/captain/tools/copilot/search_articles_service.rb @@ -1,36 +1,22 @@ -class Captain::Tools::Copilot::SearchArticlesService < Captain::Tools::BaseService - def name +class Captain::Tools::Copilot::SearchArticlesService < Captain::Tools::BaseTool + def self.name 'search_articles' end - - def description - 'Search articles based on parameters' + description 'Search articles based on parameters' + params do + string :query, description: 'Search articles by title or content (partial match)' + number :category_id, description: 'Filter articles by category ID' + any_of :status, description: 'Filter articles by status' do + string enum: %w[draft published archived] + end end - def parameters - { - type: 'object', - properties: properties, - required: ['query'] - } - end - - def execute(arguments) - query = arguments['query'] - category_id = arguments['category_id'] - status = arguments['status'] - - Rails.logger.info "#{self.class.name}: Query: #{query}, Category ID: #{category_id}, Status: #{status}" - - return 'Missing required parameters' if query.blank? - - articles = fetch_articles(query, category_id, status) - + def execute(query: nil, category_id: nil, status: nil) + articles = fetch_articles(query: query, category_id: category_id, status: status) return 'No articles found' unless articles.exists? total_count = articles.count articles = articles.limit(100) - <<~RESPONSE #{total_count > 100 ? "Found #{total_count} articles (showing first 100)" : "Total number of articles: #{total_count}"} #{articles.map(&:to_llm_text).join("\n---\n")} @@ -43,29 +29,11 @@ class Captain::Tools::Copilot::SearchArticlesService < Captain::Tools::BaseServi private - def fetch_articles(query, category_id, status) + def fetch_articles(query:, category_id:, status:) articles = Article.where(account_id: @assistant.account_id) articles = articles.where('title ILIKE :query OR content ILIKE :query', query: "%#{query}%") if query.present? articles = articles.where(category_id: category_id) if category_id.present? articles = articles.where(status: status) if status.present? articles end - - def properties - { - query: { - type: 'string', - description: 'Search articles by title or content (partial match)' - }, - category_id: { - type: 'number', - description: 'Filter articles by category ID' - }, - status: { - type: 'string', - enum: %w[draft published archived], - description: 'Filter articles by status' - } - } - end end diff --git a/enterprise/app/services/captain/tools/copilot/search_contacts_service.rb b/enterprise/app/services/captain/tools/copilot/search_contacts_service.rb index 557fc731a..5c49cf674 100644 --- a/enterprise/app/services/captain/tools/copilot/search_contacts_service.rb +++ b/enterprise/app/services/captain/tools/copilot/search_contacts_service.rb @@ -1,27 +1,14 @@ -class Captain::Tools::Copilot::SearchContactsService < Captain::Tools::BaseService - def name +class Captain::Tools::Copilot::SearchContactsService < Captain::Tools::BaseTool + def self.name 'search_contacts' end - def description - 'Search contacts based on query parameters' - end - - def parameters - { - type: 'object', - properties: properties, - required: [] - } - end - - def execute(arguments) - email = arguments['email'] - phone_number = arguments['phone_number'] - name = arguments['name'] - - Rails.logger.info "#{self.class.name} Email: #{email}, Phone Number: #{phone_number}, Name: #{name}" + description 'Search contacts based on query parameters' + param :email, type: :string, desc: 'Filter contacts by email' + param :phone_number, type: :string, desc: 'Filter contacts by phone number' + param :name, type: :string, desc: 'Filter contacts by name (partial match)' + def execute(email: nil, phone_number: nil, name: nil) contacts = Contact.where(account_id: @assistant.account_id) contacts = contacts.where(email: email) if email.present? contacts = contacts.where(phone_number: phone_number) if phone_number.present? @@ -39,23 +26,4 @@ class Captain::Tools::Copilot::SearchContactsService < Captain::Tools::BaseServi def active? user_has_permission('contact_manage') end - - private - - def properties - { - email: { - type: 'string', - description: 'Filter contacts by email' - }, - phone_number: { - type: 'string', - description: 'Filter contacts by phone number' - }, - name: { - type: 'string', - description: 'Filter contacts by name (partial match)' - } - } - end end diff --git a/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb b/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb index 6dfdf3897..9e824d1f5 100644 --- a/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb +++ b/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb @@ -1,26 +1,15 @@ -class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::BaseService - def name - 'search_conversations' +class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::BaseTool + def self.name + 'search_conversation' end + description 'Search conversations based on parameters' - def description - 'Search conversations based on parameters' - end - - def parameters - { - type: 'object', - properties: properties, - required: [] - } - end - - def execute(arguments) - status = arguments['status'] - contact_id = arguments['contact_id'] - priority = arguments['priority'] - labels = arguments['labels'] + param :status, type: :string, desc: 'Status of the conversation' + param :contact_id, type: :number, desc: 'Contact id' + param :priority, type: :string, desc: 'Priority of conversation' + param :labels, type: :string, desc: 'Labels available' + def execute(status: nil, contact_id: nil, priority: nil, labels: nil) conversations = get_conversations(status, contact_id, priority, labels) return 'No conversations found' unless conversations.exists? @@ -58,13 +47,4 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base @assistant.account ).perform end - - def properties - { - contact_id: { type: 'number', description: 'Filter conversations by contact ID' }, - status: { type: 'string', enum: %w[open resolved pending snoozed], description: 'Filter conversations by status' }, - priority: { type: 'string', enum: %w[low medium high urgent], description: 'Filter conversations by priority' }, - labels: { type: 'array', items: { type: 'string' }, description: 'Filter conversations by labels' } - } - end end diff --git a/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb b/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb index 0d59e194d..0d19534e7 100644 --- a/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb +++ b/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb @@ -1,34 +1,14 @@ -class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseService - def name +class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseTool + def self.name 'search_linear_issues' end - def description - 'Search Linear issues based on a search term' - end + description 'Search Linear issues based on a search term' + param :term, type: :string, desc: 'The search term to find Linear issues', required: true - def parameters - { - type: 'object', - properties: { - term: { - type: 'string', - description: 'The search term to find Linear issues' - } - }, - required: %w[term] - } - end - - def execute(arguments) + def execute(term:) return 'Linear integration is not enabled' unless active? - term = arguments['term'] - - Rails.logger.info "#{self.class.name}: Service called with the search term #{term}" - - return 'Missing required parameters' if term.blank? - linear_service = Integrations::Linear::ProcessorService.new(account: @assistant.account) result = linear_service.search_issue(term) diff --git a/enterprise/app/services/captain/tools/search_documentation_service.rb b/enterprise/app/services/captain/tools/search_documentation_service.rb index 672baf24a..fbc8f4154 100644 --- a/enterprise/app/services/captain/tools/search_documentation_service.rb +++ b/enterprise/app/services/captain/tools/search_documentation_service.rb @@ -1,27 +1,12 @@ -class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseService - def name +class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool + def self.name 'search_documentation' end + description 'Search and retrieve documentation from knowledge base' - def description - 'Search and retrieve documentation from knowledge base' - end + param :query, desc: 'Search Query', required: true - def parameters - { - type: 'object', - properties: { - search_query: { - type: 'string', - description: 'The search query to look up in the documentation.' - } - }, - required: ['search_query'] - } - end - - def execute(arguments) - query = arguments['search_query'] + def execute(query:) Rails.logger.info { "#{self.class.name}: #{query}" } responses = assistant.responses.approved.search(query) diff --git a/enterprise/app/services/internal/account_analysis/content_evaluator_service.rb b/enterprise/app/services/internal/account_analysis/content_evaluator_service.rb index e88c46091..586e9fad0 100644 --- a/enterprise/app/services/internal/account_analysis/content_evaluator_service.rb +++ b/enterprise/app/services/internal/account_analysis/content_evaluator_service.rb @@ -1,4 +1,4 @@ -class Internal::AccountAnalysis::ContentEvaluatorService < Llm::BaseOpenAiService +class Internal::AccountAnalysis::ContentEvaluatorService < Llm::LegacyBaseOpenAiService def initialize super() diff --git a/enterprise/app/services/llm/base_ai_service.rb b/enterprise/app/services/llm/base_ai_service.rb new file mode 100644 index 000000000..504685e3a --- /dev/null +++ b/enterprise/app/services/llm/base_ai_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Base service for LLM operations using RubyLLM. +# New features should inherit from this class. +class Llm::BaseAiService + DEFAULT_MODEL = Llm::Config::DEFAULT_MODEL + DEFAULT_TEMPERATURE = 1.0 + + attr_reader :model, :temperature + + def initialize + Llm::Config.initialize! + setup_model + setup_temperature + end + + # Returns a configured RubyLLM chat instance. + # Subclasses can override model/temperature via instance variables or pass them explicitly. + def chat(model: @model, temperature: @temperature) + RubyLLM.chat(model: model).with_temperature(temperature) + end + + private + + def setup_model + config_value = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value + @model = (config_value.presence || DEFAULT_MODEL) + end + + def setup_temperature + @temperature = DEFAULT_TEMPERATURE + end +end diff --git a/enterprise/app/services/llm/base_open_ai_service.rb b/enterprise/app/services/llm/legacy_base_open_ai_service.rb similarity index 69% rename from enterprise/app/services/llm/base_open_ai_service.rb rename to enterprise/app/services/llm/legacy_base_open_ai_service.rb index e3e88453c..ede9f0d8f 100644 --- a/enterprise/app/services/llm/base_open_ai_service.rb +++ b/enterprise/app/services/llm/legacy_base_open_ai_service.rb @@ -1,5 +1,11 @@ -class Llm::BaseOpenAiService - DEFAULT_MODEL = 'gpt-4o-mini'.freeze +# frozen_string_literal: true + +# DEPRECATED: This class uses the legacy OpenAI Ruby gem directly. +# New features should use Llm::BaseAiService with RubyLLM instead. +# This class will be removed once all services are migrated to RubyLLM. +class Llm::LegacyBaseOpenAiService + DEFAULT_MODEL = 'gpt-4o-mini' + attr_reader :client, :model def initialize diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb index 278f41d71..a200c1496 100644 --- a/enterprise/app/services/messages/audio_transcription_service.rb +++ b/enterprise/app/services/messages/audio_transcription_service.rb @@ -1,4 +1,4 @@ -class Messages::AudioTranscriptionService < Llm::BaseOpenAiService +class Messages::AudioTranscriptionService < Llm::LegacyBaseOpenAiService attr_reader :attachment, :message, :account def initialize(attachment) diff --git a/lib/integrations/llm_instrumentation.rb b/lib/integrations/llm_instrumentation.rb index 7c176b758..aff41fbce 100644 --- a/lib/integrations/llm_instrumentation.rb +++ b/lib/integrations/llm_instrumentation.rb @@ -1,12 +1,11 @@ # frozen_string_literal: true require 'opentelemetry_config' -require_relative 'llm_instrumentation_constants' -require_relative 'llm_instrumentation_helpers' module Integrations::LlmInstrumentation include Integrations::LlmInstrumentationConstants include Integrations::LlmInstrumentationHelpers + include Integrations::LlmInstrumentationSpans PROVIDER_PREFIXES = { 'openai' => %w[gpt- o1 o3 o4 text-embedding- whisper- tts-], @@ -16,10 +15,6 @@ module Integrations::LlmInstrumentation 'deepseek' => %w[deepseek-] }.freeze - def tracer - @tracer ||= OpentelemetryConfig.tracer - end - def instrument_llm_call(params) return yield unless ChatwootApp.otel_enabled? @@ -43,6 +38,7 @@ module Integrations::LlmInstrumentation result = nil executed = false tracer.in_span(params[:span_name]) do |span| + set_request_attributes(span, params) set_metadata_attributes(span, params) # By default, the input and output of a trace are set from the root observation @@ -98,7 +94,12 @@ module Integrations::LlmInstrumentation end def record_completion(span, result) - set_completion_attributes(span, result) if result.is_a?(Hash) + if result.respond_to?(:content) + span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, result.role.to_s) if result.respond_to?(:role) + span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, result.content.to_s) + elsif result.is_a?(Hash) + set_completion_attributes(span, result) if result.is_a?(Hash) + end end def set_request_attributes(span, params) @@ -117,17 +118,4 @@ module Integrations::LlmInstrumentation span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), content.to_s) end end - - def set_metadata_attributes(span, params) - session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil - span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id] - span.set_attribute(ATTR_LANGFUSE_SESSION_ID, session_id) if session_id.present? - span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) - - return unless params[:metadata].is_a?(Hash) - - params[:metadata].each do |key, value| - span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s) - end - end end diff --git a/lib/integrations/llm_instrumentation_helpers.rb b/lib/integrations/llm_instrumentation_helpers.rb index d1bf5c25c..1fc73f3f0 100644 --- a/lib/integrations/llm_instrumentation_helpers.rb +++ b/lib/integrations/llm_instrumentation_helpers.rb @@ -37,4 +37,17 @@ module Integrations::LlmInstrumentationHelpers span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR_CODE, error_code) if error_code span.status = OpenTelemetry::Trace::Status.error("API Error: #{error_code}") end + + def set_metadata_attributes(span, params) + session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil + span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id] + span.set_attribute(ATTR_LANGFUSE_SESSION_ID, session_id) if session_id.present? + span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) + + return unless params[:metadata].is_a?(Hash) + + params[:metadata].each do |key, value| + span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s) + end + end end diff --git a/lib/integrations/llm_instrumentation_spans.rb b/lib/integrations/llm_instrumentation_spans.rb new file mode 100644 index 000000000..9199aaa10 --- /dev/null +++ b/lib/integrations/llm_instrumentation_spans.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'opentelemetry_config' +require_relative 'llm_instrumentation_constants' + +module Integrations::LlmInstrumentationSpans + include Integrations::LlmInstrumentationConstants + + def tracer + @tracer ||= OpentelemetryConfig.tracer + end + + def start_llm_turn_span(params) + return unless ChatwootApp.otel_enabled? + + span = tracer.start_span(params[:span_name]) + set_llm_turn_request_attributes(span, params) + set_llm_turn_prompt_attributes(span, params[:messages]) if params[:messages] + + @pending_llm_turn_spans ||= [] + @pending_llm_turn_spans.push(span) + rescue StandardError => e + Rails.logger.warn "Failed to start LLM turn span: #{e.message}" + end + + def end_llm_turn_span(message) + return unless ChatwootApp.otel_enabled? + + span = @pending_llm_turn_spans&.pop + return unless span + + set_llm_turn_response_attributes(span, message) if message + span.finish + rescue StandardError => e + Rails.logger.warn "Failed to end LLM turn span: #{e.message}" + end + + def start_tool_span(tool_call) + return unless ChatwootApp.otel_enabled? + + tool_name = tool_call.name.to_s + span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name)) + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json) + + @pending_tool_spans ||= [] + @pending_tool_spans.push(span) + rescue StandardError => e + Rails.logger.warn "Failed to start tool span: #{e.message}" + end + + def end_tool_span(result) + return unless ChatwootApp.otel_enabled? + + span = @pending_tool_spans&.pop + return unless span + + output = result.is_a?(String) ? result : result.to_json + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, output) + span.finish + rescue StandardError => e + Rails.logger.warn "Failed to end tool span: #{e.message}" + end + + private + + def set_llm_turn_request_attributes(span, params) + provider = determine_provider(params[:model]) + span.set_attribute(ATTR_GEN_AI_PROVIDER, provider) + span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model]) if params[:model] + span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature] + end + + def set_llm_turn_prompt_attributes(span, messages) + messages.each_with_index do |msg, idx| + span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), msg[:role]) + span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), msg[:content]) + end + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, messages.to_json) + end + + def set_llm_turn_response_attributes(span, message) + span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, message.role.to_s) if message.respond_to?(:role) + span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message.content.to_s) if message.respond_to?(:content) + set_llm_turn_usage_attributes(span, message) + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, message.content.to_s) if message.respond_to?(:content) + end + + def set_llm_turn_usage_attributes(span, message) + span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens) if message.respond_to?(:input_tokens) && message.input_tokens + span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, message.output_tokens) if message.respond_to?(:output_tokens) && message.output_tokens + end +end diff --git a/lib/llm/config.rb b/lib/llm/config.rb index b2edc81e7..94836d746 100644 --- a/lib/llm/config.rb +++ b/lib/llm/config.rb @@ -33,6 +33,7 @@ module Llm::Config RubyLLM.configure do |config| config.openai_api_key = system_api_key if system_api_key.present? config.openai_api_base = openai_endpoint.chomp('/') if openai_endpoint.present? + config.logger = Rails.logger end end diff --git a/spec/enterprise/services/captain/copilot/chat_service_spec.rb b/spec/enterprise/services/captain/copilot/chat_service_spec.rb index a02063b5e..4903fb6a5 100644 --- a/spec/enterprise/services/captain/copilot/chat_service_spec.rb +++ b/spec/enterprise/services/captain/copilot/chat_service_spec.rb @@ -7,7 +7,6 @@ RSpec.describe Captain::Copilot::ChatService do let(:assistant) { create(:captain_assistant, account: account) } let(:contact) { create(:contact, account: account) } let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } - let(:mock_openai_client) { instance_double(OpenAI::Client) } let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) } let!(:copilot_message) do create( @@ -20,13 +19,29 @@ RSpec.describe Captain::Copilot::ChatService do { user_id: user.id, copilot_thread_id: copilot_thread.id, conversation_id: conversation.display_id } end + # RubyLLM mocks + let(:mock_chat) { instance_double(RubyLLM::Chat) } + let(:mock_response) do + instance_double(RubyLLM::Message, content: '{ "content": "Hey", "reasoning": "Test reasoning", "reply_suggestion": false }') + end + before do - create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') - create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://api.openai.com/') - allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client) - allow(mock_openai_client).to receive(:chat).and_return({ - choices: [{ message: { content: '{ "content": "Hey" }' } }] - }.with_indifferent_access) + InstallationConfig.find_or_create_by(name: 'CAPTAIN_OPEN_AI_API_KEY') do |c| + c.value = 'test-key' + end + + allow(RubyLLM).to receive(:chat).and_return(mock_chat) + allow(mock_chat).to receive(:with_temperature).and_return(mock_chat) + allow(mock_chat).to receive(:with_params).and_return(mock_chat) + allow(mock_chat).to receive(:with_tool).and_return(mock_chat) + allow(mock_chat).to receive(:with_instructions).and_return(mock_chat) + allow(mock_chat).to receive(:add_message).and_return(mock_chat) + allow(mock_chat).to receive(:on_new_message).and_return(mock_chat) + allow(mock_chat).to receive(:on_end_message).and_return(mock_chat) + allow(mock_chat).to receive(:on_tool_call).and_return(mock_chat) + allow(mock_chat).to receive(:on_tool_result).and_return(mock_chat) + allow(mock_chat).to receive(:messages).and_return([]) + allow(mock_chat).to receive(:ask).and_return(mock_response) end describe '#initialize' do @@ -48,48 +63,6 @@ RSpec.describe Captain::Copilot::ChatService do expect(messages.second[:role]).to eq('system') expect(messages.second[:content]).to include(account.id.to_s) end - - it 'initializes OpenAI client with configured endpoint' do - expect(OpenAI::Client).to receive(:new).with( - access_token: 'test-key', - uri_base: 'https://api.openai.com/', - log_errors: Rails.env.development? - ) - - described_class.new(assistant, config) - end - - context 'when CAPTAIN_OPEN_AI_ENDPOINT is not configured' do - before do - InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy - end - - it 'uses default OpenAI endpoint' do - expect(OpenAI::Client).to receive(:new).with( - access_token: 'test-key', - uri_base: 'https://api.openai.com/', - log_errors: Rails.env.development? - ) - - described_class.new(assistant, config) - end - end - - context 'when custom endpoint is configured' do - before do - InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT').update!(value: 'https://custom.azure.com/') - end - - it 'uses custom endpoint for OpenAI client' do - expect(OpenAI::Client).to receive(:new).with( - access_token: 'test-key', - uri_base: 'https://custom.azure.com/', - log_errors: Rails.env.development? - ) - - described_class.new(assistant, config) - end - end end describe '#generate_response' do @@ -112,82 +85,19 @@ RSpec.describe Captain::Copilot::ChatService do end it 'returns the response from request_chat_completion' do - expect(service.generate_response('Hello')).to eq({ 'content' => 'Hey' }) + result = service.generate_response('Hello') + + expect(result).to eq({ 'content' => 'Hey', 'reasoning' => 'Test reasoning', 'reply_suggestion' => false }) end - context 'when response contains tool calls' do - before do - allow(mock_openai_client).to receive(:chat).and_return( - { - choices: [{ message: { 'tool_calls' => tool_calls } }] - }.with_indifferent_access, - { - choices: [{ message: { content: '{ "content": "Tool response processed" }' } }] - }.with_indifferent_access - ) - end - - context 'when tool call is valid' do - let(:tool_calls) do - [{ - 'id' => 'call_123', - 'function' => { - 'name' => 'get_conversation', - 'arguments' => "{ \"conversation_id\": #{conversation.display_id} }" - } - }] - end - - it 'processes tool calls and appends them to messages' do - result = service.generate_response("Find conversation #{conversation.id}") - - expect(result).to eq({ 'content' => 'Tool response processed' }) - expect(service.messages).to include( - { role: 'assistant', tool_calls: tool_calls } - ) - expect(service.messages).to include( - { - role: 'tool', tool_call_id: 'call_123', content: conversation.to_llm_text - } - ) - - expect(result).to eq({ 'content' => 'Tool response processed' }) - end - end - - context 'when tool call is invalid' do - let(:tool_calls) do - [{ - 'id' => 'call_123', - 'function' => { - 'name' => 'get_settings', - 'arguments' => '{}' - } - }] - end - - it 'handles invalid tool calls' do - result = service.generate_response('Find settings') - - expect(result).to eq({ 'content' => 'Tool response processed' }) - expect(service.messages).to include( - { - role: 'assistant', tool_calls: tool_calls - } - ) - expect(service.messages).to include( - { - role: 'tool', - tool_call_id: 'call_123', - content: 'Tool not available' - } - ) - end - end + it 'increments response usage for the account' do + expect do + service.generate_response('Hello') + end.to(change { account.reload.custom_attributes['captain_responses_usage'].to_i }.by(1)) end end - describe '#setup_user' do + describe 'user setup behavior' do it 'sets user when user_id is present in config' do service = described_class.new(assistant, { user_id: user.id }) expect(service.user).to eq(user) @@ -199,7 +109,7 @@ RSpec.describe Captain::Copilot::ChatService do end end - describe '#setup_message_history' do + describe 'message history behavior' do context 'when copilot_thread_id is present' do it 'finds the copilot thread and sets previous history from it' do service = described_class.new(assistant, { copilot_thread_id: copilot_thread.id }) @@ -227,7 +137,7 @@ RSpec.describe Captain::Copilot::ChatService do end end - describe '#build_messages' do + describe 'message building behavior' do it 'includes system message and account context' do service = described_class.new(assistant, {}) messages = service.messages @@ -257,13 +167,9 @@ RSpec.describe Captain::Copilot::ChatService do end end - describe '#persist_message' do + describe 'message persistence behavior' do context 'when copilot_thread is present' do - it 'creates a copilot message' do - allow(mock_openai_client).to receive(:chat).and_return({ - choices: [{ message: { content: '{ "content": "Hey" }' } }] - }.with_indifferent_access) - + it 'creates a copilot message with the response' do expect do described_class.new(assistant, { copilot_thread_id: copilot_thread.id }).generate_response('Hello') end.to change(CopilotMessage, :count).by(1) @@ -276,10 +182,6 @@ RSpec.describe Captain::Copilot::ChatService do context 'when copilot_thread is not present' do it 'does not create a copilot message' do - allow(mock_openai_client).to receive(:chat).and_return({ - choices: [{ message: { content: '{ "content": "Hey" }' } }] - }.with_indifferent_access) - expect do described_class.new(assistant, {}).generate_response('Hello') end.not_to(change(CopilotMessage, :count)) diff --git a/spec/enterprise/services/captain/tools/copilot/get_article_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/get_article_service_spec.rb index 72f4e1cb4..5227a3201 100644 --- a/spec/enterprise/services/captain/tools/copilot/get_article_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/get_article_service_spec.rb @@ -19,19 +19,8 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do end describe '#parameters' do - it 'returns the expected parameter schema' do - expect(service.parameters).to eq( - { - type: 'object', - properties: { - article_id: { - type: 'number', - description: 'The ID of the article to retrieve' - } - }, - required: %w[article_id] - } - ) + it 'defines article_id parameter' do + expect(service.parameters.keys).to contain_exactly(:article_id) end end @@ -79,13 +68,13 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do describe '#execute' do context 'when article_id is blank' do it 'returns error message' do - expect(service.execute({})).to eq('Missing required parameters') + expect(service.execute(article_id: nil)).to eq('Article not found') end end context 'when article is not found' do it 'returns not found message' do - expect(service.execute({ 'article_id' => 999 })).to eq('Article not found') + expect(service.execute(article_id: 999)).to eq('Article not found') end end @@ -94,7 +83,7 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do let(:article) { create(:article, account: account, portal: portal, author: user, title: 'Test Article', content: 'Content') } it 'returns the article in llm text format' do - result = service.execute({ 'article_id' => article.id }) + result = service.execute(article_id: article.id) expect(result).to eq(article.to_llm_text) end @@ -104,7 +93,7 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do let(:other_article) { create(:article, account: other_account, portal: other_portal, author: user, title: 'Other Article') } it 'returns not found message' do - expect(service.execute({ 'article_id' => other_article.id })).to eq('Article not found') + expect(service.execute(article_id: other_article.id)).to eq('Article not found') end end end diff --git a/spec/enterprise/services/captain/tools/copilot/get_contact_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/get_contact_service_spec.rb index de319bfa1..df2e5c159 100644 --- a/spec/enterprise/services/captain/tools/copilot/get_contact_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/get_contact_service_spec.rb @@ -19,19 +19,8 @@ RSpec.describe Captain::Tools::Copilot::GetContactService do end describe '#parameters' do - it 'returns the expected parameter schema' do - expect(service.parameters).to eq( - { - type: 'object', - properties: { - contact_id: { - type: 'number', - description: 'The ID of the contact to retrieve' - } - }, - required: %w[contact_id] - } - ) + it 'defines contact_id parameter' do + expect(service.parameters.keys).to contain_exactly(:contact_id) end end @@ -78,14 +67,14 @@ RSpec.describe Captain::Tools::Copilot::GetContactService do describe '#execute' do context 'when contact_id is blank' do - it 'returns error message' do - expect(service.execute({})).to eq('Missing required parameters') + it 'returns not found message' do + expect(service.execute(contact_id: nil)).to eq('Contact not found') end end context 'when contact is not found' do it 'returns not found message' do - expect(service.execute({ 'contact_id' => 999 })).to eq('Contact not found') + expect(service.execute(contact_id: 999)).to eq('Contact not found') end end @@ -93,7 +82,7 @@ RSpec.describe Captain::Tools::Copilot::GetContactService do let(:contact) { create(:contact, account: account) } it 'returns the contact in llm text format' do - result = service.execute({ 'contact_id' => contact.id }) + result = service.execute(contact_id: contact.id) expect(result).to eq(contact.to_llm_text) end @@ -102,7 +91,7 @@ RSpec.describe Captain::Tools::Copilot::GetContactService do let(:other_contact) { create(:contact, account: other_account) } it 'returns not found message' do - expect(service.execute({ 'contact_id' => other_contact.id })).to eq('Contact not found') + expect(service.execute(contact_id: other_contact.id)).to eq('Contact not found') end end end diff --git a/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb index b8265bbbf..ad2a78cc3 100644 --- a/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb @@ -19,19 +19,8 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do end describe '#parameters' do - it 'returns the expected parameter schema' do - expect(service.parameters).to eq( - { - type: 'object', - properties: { - conversation_id: { - type: 'number', - description: 'The ID of the conversation to retrieve' - } - }, - required: %w[conversation_id] - } - ) + it 'defines conversation_id parameter' do + expect(service.parameters.keys).to contain_exactly(:conversation_id) end end @@ -107,15 +96,9 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do end describe '#execute' do - context 'when conversation_id is blank' do - it 'returns error message' do - expect(service.execute({})).to eq('Missing required parameters') - end - end - context 'when conversation is not found' do it 'returns not found message' do - expect(service.execute({ 'conversation_id' => 999 })).to eq('Conversation not found') + expect(service.execute(conversation_id: 999)).to eq('Conversation not found') end end @@ -124,7 +107,7 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do let(:conversation) { create(:conversation, account: account, inbox: inbox) } it 'returns the conversation in llm text format' do - result = service.execute({ 'conversation_id' => conversation.display_id }) + result = service.execute(conversation_id: conversation.display_id) expect(result).to eq(conversation.to_llm_text) end @@ -143,7 +126,7 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do content: 'Private note content', private: true) - result = service.execute({ 'conversation_id' => conversation.display_id }) + result = service.execute(conversation_id: conversation.display_id) # Verify that the result includes both regular and private messages expect(result).to include('Regular message') @@ -157,7 +140,7 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do let(:other_conversation) { create(:conversation, account: other_account, inbox: other_inbox) } it 'returns not found message' do - expect(service.execute({ 'conversation_id' => other_conversation.display_id })).to eq('Conversation not found') + expect(service.execute(conversation_id: other_conversation.display_id)).to eq('Conversation not found') end end end diff --git a/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb index eb11e0d69..5623dce3d 100644 --- a/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb @@ -18,32 +18,6 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do end end - describe '#parameters' do - it 'returns the expected parameter schema' do - expect(service.parameters).to eq( - { - type: 'object', - properties: { - query: { - type: 'string', - description: 'Search articles by title or content (partial match)' - }, - category_id: { - type: 'number', - description: 'Filter articles by category ID' - }, - status: { - type: 'string', - enum: %w[draft published archived], - description: 'Filter articles by status' - } - }, - required: ['query'] - } - ) - end - end - describe '#active?' do context 'when user is an admin' do let(:user) { create(:user, :administrator, account: account) } @@ -95,15 +69,9 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do end describe '#execute' do - context 'when query is blank' do - it 'returns error message' do - expect(service.execute({})).to eq('Missing required parameters') - end - end - context 'when no articles are found' do it 'returns no articles found message' do - expect(service.execute({ 'query' => 'test' })).to eq('No articles found') + expect(service.execute(query: 'test', category_id: nil, status: nil)).to eq('No articles found') end end @@ -113,7 +81,7 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do let!(:article2) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 2', content: 'Content 2') } it 'returns formatted articles with count' do - result = service.execute({ 'query' => 'Test' }) + result = service.execute(query: 'Test', category_id: nil, status: nil) expect(result).to include('Total number of articles: 2') expect(result).to include(article1.to_llm_text) expect(result).to include(article2.to_llm_text) @@ -124,7 +92,7 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do let!(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') } it 'returns only articles from the specified category' do - result = service.execute({ 'query' => 'Test', 'category_id' => category.id }) + result = service.execute(query: 'Test', category_id: category.id, status: nil) expect(result).to include('Total number of articles: 1') expect(result).to include(article3.to_llm_text) expect(result).not_to include(article1.to_llm_text) @@ -137,7 +105,7 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do let!(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') } it 'returns only articles with the specified status' do - result = service.execute({ 'query' => 'Test', 'status' => 'published' }) + result = service.execute(query: 'Test', category_id: nil, status: 'published') expect(result).to include(article3.to_llm_text) expect(result).not_to include(article4.to_llm_text) end diff --git a/spec/enterprise/services/captain/tools/copilot/search_contacts_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_contacts_service_spec.rb index f54b2eddf..09bd09ec2 100644 --- a/spec/enterprise/services/captain/tools/copilot/search_contacts_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/search_contacts_service_spec.rb @@ -19,27 +19,8 @@ RSpec.describe Captain::Tools::Copilot::SearchContactsService do end describe '#parameters' do - it 'returns the expected parameter schema' do - expect(service.parameters).to eq( - { - type: 'object', - properties: { - email: { - type: 'string', - description: 'Filter contacts by email' - }, - phone_number: { - type: 'string', - description: 'Filter contacts by phone number' - }, - name: { - type: 'string', - description: 'Filter contacts by name (partial match)' - } - }, - required: [] - } - ) + it 'defines email, phone_number, and name parameters' do + expect(service.parameters.keys).to contain_exactly(:email, :phone_number, :name) end end @@ -86,25 +67,25 @@ RSpec.describe Captain::Tools::Copilot::SearchContactsService do end it 'returns contacts when filtered by email' do - result = service.execute({ 'email' => 'test1@example.com' }) + result = service.execute(email: 'test1@example.com') expect(result).to include(contact1.to_llm_text) expect(result).not_to include(contact2.to_llm_text) end it 'returns contacts when filtered by phone number' do - result = service.execute({ 'phone_number' => '+1234567890' }) + result = service.execute(phone_number: '+1234567890') expect(result).to include(contact1.to_llm_text) expect(result).not_to include(contact2.to_llm_text) end it 'returns contacts when filtered by name' do - result = service.execute({ 'name' => 'Contact 1' }) + result = service.execute(name: 'Contact 1') expect(result).to include(contact1.to_llm_text) expect(result).not_to include(contact2.to_llm_text) end it 'returns all matching contacts when no filters are provided' do - result = service.execute({}) + result = service.execute expect(result).to include(contact1.to_llm_text) expect(result).to include(contact2.to_llm_text) end diff --git a/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb index ab0865bc2..a02d05404 100644 --- a/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do describe '#name' do it 'returns the correct service name' do - expect(service.name).to eq('search_conversations') + expect(service.name).to eq('search_conversation') end end @@ -19,10 +19,8 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do end describe '#parameters' do - it 'returns the correct parameter schema' do - params = service.parameters - expect(params[:type]).to eq('object') - expect(params[:properties]).to include(:contact_id, :status, :priority) + it 'defines the expected parameters' do + expect(service.parameters.keys).to contain_exactly(:status, :contact_id, :priority, :labels) end end @@ -90,35 +88,35 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do let!(:resolved_conversation) { create(:conversation, account: account, status: 'resolved', priority: 'low') } it 'returns all conversations when no filters are applied' do - result = service.execute({}) + result = service.execute expect(result).to include('Total number of conversations: 2') expect(result).to include(open_conversation.to_llm_text(include_contact_details: true)) expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true)) end it 'filters conversations by status' do - result = service.execute({ 'status' => 'open' }) + result = service.execute(status: 'open') expect(result).to include('Total number of conversations: 1') expect(result).to include(open_conversation.to_llm_text(include_contact_details: true)) expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true)) end it 'filters conversations by contact_id' do - result = service.execute({ 'contact_id' => contact.id }) + result = service.execute(contact_id: contact.id) expect(result).to include('Total number of conversations: 1') expect(result).to include(open_conversation.to_llm_text(include_contact_details: true)) expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true)) end it 'filters conversations by priority' do - result = service.execute({ 'priority' => 'high' }) + result = service.execute(priority: 'high') expect(result).to include('Total number of conversations: 1') expect(result).to include(open_conversation.to_llm_text(include_contact_details: true)) expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true)) end it 'returns appropriate message when no conversations are found' do - result = service.execute({ 'status' => 'snoozed' }) + result = service.execute(status: 'snoozed') expect(result).to eq('No conversations found') end end diff --git a/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb index f987b7a6a..a88504bb1 100644 --- a/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb @@ -19,19 +19,8 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do end describe '#parameters' do - it 'returns the expected parameter schema' do - expect(service.parameters).to eq( - { - type: 'object', - properties: { - term: { - type: 'string', - description: 'The search term to find Linear issues' - } - }, - required: %w[term] - } - ) + it 'defines term parameter' do + expect(service.parameters.keys).to contain_exactly(:term) end end @@ -76,7 +65,7 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do describe '#execute' do context 'when Linear integration is not enabled' do it 'returns error message' do - expect(service.execute({ 'term' => 'test' })).to eq('Linear integration is not enabled') + expect(service.execute(term: 'test')).to eq('Linear integration is not enabled') end end @@ -89,8 +78,12 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do end context 'when term is blank' do - it 'returns error message' do - expect(service.execute({ 'term' => '' })).to eq('Missing required parameters') + before do + allow(linear_service).to receive(:search_issue).with('').and_return({ data: [] }) + end + + it 'returns no issues found message' do + expect(service.execute(term: '')).to eq('No issues found, I should try another similar search term') end end @@ -100,7 +93,7 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do end it 'returns the error message' do - expect(service.execute({ 'term' => 'test' })).to eq('API Error') + expect(service.execute(term: 'test')).to eq('API Error') end end @@ -110,7 +103,7 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do end it 'returns no issues found message' do - expect(service.execute({ 'term' => 'test' })).to eq('No issues found, I should try another similar search term') + expect(service.execute(term: 'test')).to eq('No issues found, I should try another similar search term') end end @@ -131,7 +124,7 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do end it 'returns formatted issues' do - result = service.execute({ 'term' => 'test' }) + result = service.execute(term: 'test') expect(result).to include('Total number of issues: 1') expect(result).to include('Title: Test Issue') expect(result).to include('ID: TEST-123') diff --git a/spec/enterprise/services/captain/tools/search_documentation_service_spec.rb b/spec/enterprise/services/captain/tools/search_documentation_service_spec.rb index 9f5586e6b..41cec35f5 100644 --- a/spec/enterprise/services/captain/tools/search_documentation_service_spec.rb +++ b/spec/enterprise/services/captain/tools/search_documentation_service_spec.rb @@ -20,19 +20,8 @@ RSpec.describe Captain::Tools::SearchDocumentationService do end describe '#parameters' do - it 'returns the required parameters schema' do - expected_schema = { - type: 'object', - properties: { - search_query: { - type: 'string', - description: 'The search query to look up in the documentation.' - } - }, - required: ['search_query'] - } - - expect(service.parameters).to eq(expected_schema) + it 'defines query parameter' do + expect(service.parameters.keys).to contain_exactly(:query) end end @@ -56,7 +45,7 @@ RSpec.describe Captain::Tools::SearchDocumentationService do end it 'returns formatted responses for the search query' do - result = service.execute({ 'search_query' => question }) + result = service.execute(query: question) expect(result).to include(question) expect(result).to include(answer) @@ -70,7 +59,7 @@ RSpec.describe Captain::Tools::SearchDocumentationService do end it 'returns an empty string' do - expect(service.execute({ 'search_query' => question })).to eq('No FAQs found for the given query') + expect(service.execute(query: question)).to eq('No FAQs found for the given query') end end end diff --git a/spec/lib/integrations/llm_instrumentation_spec.rb b/spec/lib/integrations/llm_instrumentation_spec.rb index f7f0c45e4..f567a91b7 100644 --- a/spec/lib/integrations/llm_instrumentation_spec.rb +++ b/spec/lib/integrations/llm_instrumentation_spec.rb @@ -256,52 +256,5 @@ RSpec.describe Integrations::LlmInstrumentation do end end end - - describe '#instrument_tool_call' do - let(:tool_name) { 'search_documents' } - let(:arguments) { { query: 'test query' } } - - context 'when OTEL provider is not configured' do - before { otel_config.update(value: '') } - - it 'executes the block without tracing' do - result = instance.instrument_tool_call(tool_name, arguments) { 'tool_result' } - expect(result).to eq('tool_result') - end - end - - context 'when OTEL provider is configured' do - let(:mock_span) { instance_double(OpenTelemetry::Trace::Span) } - let(:mock_tracer) { instance_double(OpenTelemetry::Trace::Tracer) } - - before do - allow(mock_span).to receive(:set_attribute) - allow(instance).to receive(:tracer).and_return(mock_tracer) - allow(mock_tracer).to receive(:in_span).and_yield(mock_span) - end - - it 'executes the block and returns the result' do - result = instance.instrument_tool_call(tool_name, arguments) { 'tool_result' } - expect(result).to eq('tool_result') - end - - it 'propagates instrumentation errors' do - allow(mock_tracer).to receive(:in_span).and_raise(StandardError.new('Instrumentation failed')) - - expect do - instance.instrument_tool_call(tool_name, arguments) { 'tool_result' } - end.to raise_error(StandardError, 'Instrumentation failed') - end - - it 'creates a span with tool name and sets observation attributes' do - tool_result = { documents: ['doc1'] } - instance.instrument_tool_call(tool_name, arguments) { tool_result } - - expect(mock_tracer).to have_received(:in_span).with('tool.search_documents') - expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.input', arguments.to_json) - expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.output', tool_result.to_json) - end - end - end end end From efc3b5e7d44ce0514a1c2533df98182a7205bf82 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 4 Dec 2025 18:53:50 +0530 Subject: [PATCH 09/18] fix: Handle Instagram API error codes properly in message processing (#13002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Problem Instagram webhook processing was failing with: > TypeError: no implicit conversion of String into Integer This occurred when handling **echo messages** (outgoing messages) that: * Contained `unsupported_type` attachments, and * Were sent to a recipient user that could **not** be fetched via the Instagram API. In these cases, the webhook job crashed while trying to create or find the contact for the recipient. ### Root Cause The Instagram message service does not correctly handle Instagram API error code **100**: > "Object with ID does not exist, cannot be loaded due to missing permissions, or does not support this operation" When this error occurred during `fetch_instagram_user`: 1. The method fell through to exception tracking without an explicit return. 2. `ChatwootExceptionTracker.capture_exception` returned `true`. 3. As a result, `fetch_instagram_user` effectively returned `true` instead of a hash or empty hash. 4. `ensure_contact` then called `find_or_create_contact(true)` because `true.present?` is `true`. 5. `find_or_create_contact` crashed when it tried to access `true['id']`. So the chain was: ```txt fetch_instagram_user -> returns true ensure_contact -> find_or_create_contact(true) find_or_create_contact -> true['id'] -> TypeError ``` **Example Webhook Payload** ``` { "object": "instagram", "entry": [{ "time": 1764822592663, "id": "17841454414819988", "messaging": [{ "sender": { "id": "17841454414819988" }, // Business account "recipient": { "id": "1170166904857608" }, // User that can't be fetched "timestamp": 1764822591874, "message": { "attachments": [{ "type": "unsupported_type", "payload": { "url": "https://..." } }], "is_echo": true } }] }] } ``` **Corresponding Instagram API error:** ``` { "error": { "message": "The requested user cannot be found.", "type": "IGApiException", "code": 100, "error_subcode": 2534014 } } ``` **Debug Logs (Before Fix)** ``` [InstagramUserFetchError]: Unsupported get request. Object with ID '17841454414819988' does not exist... 100 [DEBUG] result: true [DEBUG] result.present?: true [DEBUG] find_or_create_contact called [DEBUG] user: true [DEBUG] Invalid user parameter - expected hash with id, got TrueClass: true ``` ### Solution ### 1\. Handle Error Code 100 Explicitly We now treat Instagram API error code **100** as a valid case for creating an “unknown” contact, similar to how we already handle error code `9010`: ``` # Handle error code 100: Object doesn't exist or missing permissions # This typically occurs when trying to fetch a user that doesn't exist # or has privacy restrictions. We can safely create an unknown contact. return unknown_user(ig_scope_id) if error_code == 100 ``` This ensures: * `fetch_instagram_user` returns a valid hash for unknown users. * `ensure_contact` can proceed safely without crashing. ### 2\. Prevent Exception Tracker Results from Leaking Through For any **unhandled** error codes, we now explicitly return an empty hash after logging the exception: ``` exception = StandardError.new( "#{error_message} (Code: #{error_code}, IG Scope ID: #{ig_scope_id})" ) ChatwootExceptionTracker.new(exception, account: @inbox.account).capture_exception # Explicitly return empty hash for any unhandled error codes # This prevents the exception tracker result (true/false) from being returned. {} ``` This guarantees that `fetch_instagram_user` always returns either: * A valid user hash, * An “unknown” user hash * An empty hash Fixes https://linear.app/chatwoot/issue/CW-6068/typeerror-no-implicit-conversion-of-string-into-integer-typeerror --- app/services/instagram/message_text.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/services/instagram/message_text.rb b/app/services/instagram/message_text.rb index 3b371a2f4..3bdc569eb 100644 --- a/app/services/instagram/message_text.rb +++ b/app/services/instagram/message_text.rb @@ -59,11 +59,20 @@ class Instagram::MessageText < Instagram::BaseMessageText # We can safely create an unknown contact, making this integration work. return unknown_user(ig_scope_id) if error_code == 9010 + # Handle error code 100: Object doesn't exist or missing permissions + # This typically occurs when trying to fetch a user that doesn't exist or has privacy restrictions + # We can safely create an unknown contact, similar to error 9010 + return unknown_user(ig_scope_id) if error_code == 100 + Rails.logger.warn("[InstagramUserFetchError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id} ig_scope_id #{ig_scope_id}") Rails.logger.warn("[InstagramUserFetchError]: #{error_message} #{error_code}") exception = StandardError.new("#{error_message} (Code: #{error_code}, IG Scope ID: #{ig_scope_id})") ChatwootExceptionTracker.new(exception, account: @inbox.account).capture_exception + + # Explicitly return empty hash for any unhandled error codes + # This prevents the exception tracker result from being returned + {} end def base_uri From 67dc21ea5fc6cda0102fe07a0aca39fb2a14466c Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Thu, 4 Dec 2025 20:32:46 +0530 Subject: [PATCH 10/18] fix: Hardcoded 500 in AI api error response(#13005) ## Description Please include a summary of the change and issue(s) fixed. Also, mention relevant motivation, context, and any dependencies that this change requires. Fixes false new relic alerts set due to hardcoding an error code ## Type of change Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Before image the 500 was hardcoded. RubyLLM doesn't send any error codes, so i removed the error code argument and just pass the error message Langfuse gets just the error message image local logs only show error image Better fix is to handle each case and show the user wherever necessary ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: aakashb95 --- lib/integrations/llm_base_service.rb | 2 +- lib/integrations/llm_instrumentation_helpers.rb | 4 +--- spec/lib/integrations/llm_instrumentation_spec.rb | 10 ++++------ 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/lib/integrations/llm_base_service.rb b/lib/integrations/llm_base_service.rb index e8811121f..ca9459fc8 100644 --- a/lib/integrations/llm_base_service.rb +++ b/lib/integrations/llm_base_service.rb @@ -164,6 +164,6 @@ class Integrations::LlmBaseService end def build_error_response_from_exception(error, messages) - { error: error.message, error_code: 500, request_messages: messages } + { error: error.message, request_messages: messages } end end diff --git a/lib/integrations/llm_instrumentation_helpers.rb b/lib/integrations/llm_instrumentation_helpers.rb index 1fc73f3f0..c03e3e9c7 100644 --- a/lib/integrations/llm_instrumentation_helpers.rb +++ b/lib/integrations/llm_instrumentation_helpers.rb @@ -32,10 +32,8 @@ module Integrations::LlmInstrumentationHelpers error = result[:error] || result['error'] return if error.blank? - error_code = result[:error_code] || result['error_code'] span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json) - span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR_CODE, error_code) if error_code - span.status = OpenTelemetry::Trace::Status.error("API Error: #{error_code}") + span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000)) end def set_metadata_attributes(span, params) diff --git a/spec/lib/integrations/llm_instrumentation_spec.rb b/spec/lib/integrations/llm_instrumentation_spec.rb index f567a91b7..97a45a414 100644 --- a/spec/lib/integrations/llm_instrumentation_spec.rb +++ b/spec/lib/integrations/llm_instrumentation_spec.rb @@ -200,17 +200,15 @@ RSpec.describe Integrations::LlmInstrumentation do result = instance.instrument_llm_call(params) do { - error: { message: 'API rate limit exceeded' }, - error_code: 'rate_limit_exceeded' + error: 'API rate limit exceeded' } end - expect(result[:error_code]).to eq('rate_limit_exceeded') + expect(result[:error]).to eq('API rate limit exceeded') expect(mock_span).to have_received(:set_attribute) - .with('gen_ai.response.error', '{"message":"API rate limit exceeded"}') - expect(mock_span).to have_received(:set_attribute).with('gen_ai.response.error_code', 'rate_limit_exceeded') + .with('gen_ai.response.error', '"API rate limit exceeded"') expect(mock_span).to have_received(:status=).with(mock_status) - expect(OpenTelemetry::Trace::Status).to have_received(:error).with('API Error: rate_limit_exceeded') + expect(OpenTelemetry::Trace::Status).to have_received(:error).with('API rate limit exceeded') end end From a971ff00f824e83686bdf04b1fd60062336f62f7 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 5 Dec 2025 10:52:13 +0530 Subject: [PATCH 11/18] fix: ruby_llm version conflicts with ai-agents (#13011) Co-authored-by: aakashb95 --- Gemfile | 4 ++-- Gemfile.lock | 11 +++++------ .../captain/tools/copilot/search_articles_service.rb | 10 +++------- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/Gemfile b/Gemfile index f037583e7..46b11ef1d 100644 --- a/Gemfile +++ b/Gemfile @@ -191,10 +191,10 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' -gem 'ai-agents', '>= 0.4.3' +gem 'ai-agents', '>= 0.7.0' # TODO: Move this gem as a dependency of ai-agents -gem 'ruby_llm', '>= 1.9.1' +gem 'ruby_llm', '>= 1.8.2' gem 'ruby_llm-schema' # OpenTelemetry for LLM observability diff --git a/Gemfile.lock b/Gemfile.lock index c3eec459b..55cfdef7e 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.4.3) - ruby_llm (~> 1.3) + ai-agents (0.7.0) + ruby_llm (~> 1.8.2) annotate (3.2.0) activerecord (>= 3.2, < 8.0) rake (>= 10.4, < 14.0) @@ -819,7 +819,7 @@ GEM ruby2ruby (2.5.0) ruby_parser (~> 3.1) sexp_processor (~> 4.6) - ruby_llm (1.9.1) + ruby_llm (1.8.2) base64 event_stream_parser (~> 1) faraday (>= 1.10.0) @@ -827,7 +827,6 @@ GEM faraday-net_http (>= 1) faraday-retry (>= 1) marcel (~> 1.0) - ruby_llm-schema (~> 0.2.1) zeitwerk (~> 2) ruby_llm-schema (0.2.5) ruby_parser (3.20.0) @@ -1018,7 +1017,7 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) - ai-agents (>= 0.4.3) + ai-agents (>= 0.7.0) annotate attr_extras audited (~> 5.4, >= 5.4.1) @@ -1120,7 +1119,7 @@ DEPENDENCIES rubocop-rails rubocop-rspec ruby-openai - ruby_llm (>= 1.9.1) + ruby_llm (>= 1.8.2) ruby_llm-schema scout_apm scss_lint diff --git a/enterprise/app/services/captain/tools/copilot/search_articles_service.rb b/enterprise/app/services/captain/tools/copilot/search_articles_service.rb index 24385a932..1647c939b 100644 --- a/enterprise/app/services/captain/tools/copilot/search_articles_service.rb +++ b/enterprise/app/services/captain/tools/copilot/search_articles_service.rb @@ -3,13 +3,9 @@ class Captain::Tools::Copilot::SearchArticlesService < Captain::Tools::BaseTool 'search_articles' end description 'Search articles based on parameters' - params do - string :query, description: 'Search articles by title or content (partial match)' - number :category_id, description: 'Filter articles by category ID' - any_of :status, description: 'Filter articles by status' do - string enum: %w[draft published archived] - end - end + param :query, desc: 'Search articles by title or content (partial match)', required: false + param :category_id, type: :number, desc: 'Filter articles by category ID', required: false + param :status, type: :string, desc: 'Filter articles by status - MUST BE ONE OF: draft, published, archived', required: false def execute(query: nil, category_id: nil, status: nil) articles = fetch_articles(query: query, category_id: category_id, status: status) From cc86b8c7f1dff9b47fcdfce76d4c21fc5e3b4149 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Fri, 5 Dec 2025 13:02:53 -0800 Subject: [PATCH 12/18] fix: stream attachment handling in workers (#12870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We’ve been watching Sidekiq workers climb from ~600 MB at boot to 1.4–1.5 GB after an hour whenever attachment-heavy jobs run. This PR is an experiment to curb that growth by streaming attachments instead of loading the whole blob into Ruby: reply-mailer inline attachments, Telegram uploads, and audio transcriptions now read/write in chunks. If this keeps RSS stable in production we’ll keep it; otherwise we’ll roll it back and keep digging --- .rubocop.yml | 7 +++ app/jobs/data_import_job.rb | 51 +++++++++++++------ ...ersation_reply_mailer_attachment_helper.rb | 51 +++++++++++++++++++ .../conversation_reply_mailer_helper.rb | 30 +---------- .../telegram/send_attachments_service.rb | 11 ++-- .../captain/llm/pdf_processing_service.rb | 12 +++-- .../messages/audio_transcription_service.rb | 36 ++++++++----- .../slack/send_on_slack_service.rb | 31 ++++++++--- rubocop/attachment_download.rb | 17 +++++++ .../llm/pdf_processing_service_spec.rb | 7 +-- spec/jobs/data_import_job_spec.rb | 9 +++- .../slack/send_on_slack_service_spec.rb | 15 ++++++ 12 files changed, 203 insertions(+), 74 deletions(-) create mode 100644 app/mailers/conversation_reply_mailer_attachment_helper.rb create mode 100644 rubocop/attachment_download.rb diff --git a/.rubocop.yml b/.rubocop.yml index 22c4220d9..d87f08bfd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,6 +7,7 @@ plugins: require: - ./rubocop/use_from_email.rb - ./rubocop/custom_cop_location.rb + - ./rubocop/attachment_download.rb - ./rubocop/one_class_per_file.rb Layout/LineLength: @@ -41,6 +42,12 @@ Style/SymbolArray: Style/OpenStructUse: Enabled: false +Chatwoot/AttachmentDownload: + Enabled: true + Exclude: + - 'spec/**/*' + - 'test/**/*' + Style/OptionalBooleanParameter: Exclude: - 'app/services/email_templates/db_resolver_service.rb' diff --git a/app/jobs/data_import_job.rb b/app/jobs/data_import_job.rb index 6146336fa..db1411e68 100644 --- a/app/jobs/data_import_job.rb +++ b/app/jobs/data_import_job.rb @@ -30,21 +30,15 @@ class DataImportJob < ApplicationJob def parse_csv_and_build_contacts contacts = [] rejected_contacts = [] - # Ensuring that importing non utf-8 characters will not throw error - data = @data_import.import_file.download - utf8_data = data.force_encoding('UTF-8') - # Ensure that the data is valid UTF-8, preserving valid characters - clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8') - - csv = CSV.parse(clean_data, headers: true) - - csv.each do |row| - current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access) - if current_contact.valid? - contacts << current_contact - else - append_rejected_contact(row, current_contact, rejected_contacts) + with_import_file do |file| + csv_reader(file).each do |row| + current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access) + if current_contact.valid? + contacts << current_contact + else + append_rejected_contact(row, current_contact, rejected_contacts) + end end end @@ -75,7 +69,7 @@ class DataImportJob < ApplicationJob end def generate_csv_data(rejected_contacts) - headers = CSV.parse(@data_import.import_file.download, headers: true).headers + headers = csv_headers headers << 'errors' return if rejected_contacts.blank? @@ -99,4 +93,31 @@ class DataImportJob < ApplicationJob def send_import_failed_notification_to_admin AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_failed.deliver_later end + + def csv_headers + header_row = nil + with_import_file do |file| + header_row = csv_reader(file).first + end + header_row&.headers || [] + end + + def csv_reader(file) + file.rewind + raw_data = file.read + utf8_data = raw_data.force_encoding('UTF-8') + clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8') + + CSV.new(StringIO.new(clean_data), headers: true) + end + + def with_import_file + temp_dir = Rails.root.join('tmp/imports') + FileUtils.mkdir_p(temp_dir) + + @data_import.import_file.open(tmpdir: temp_dir) do |file| + file.binmode + yield file + end + end end diff --git a/app/mailers/conversation_reply_mailer_attachment_helper.rb b/app/mailers/conversation_reply_mailer_attachment_helper.rb new file mode 100644 index 000000000..f4c0e2282 --- /dev/null +++ b/app/mailers/conversation_reply_mailer_attachment_helper.rb @@ -0,0 +1,51 @@ +# Handles attachment processing for ConversationReplyMailer flows. +module ConversationReplyMailerAttachmentHelper + private + + def process_attachments_as_files_for_email_reply + # Attachment processing for direct email replies (when replying to a single message) + # + # How attachments are handled: + # 1. Total file size (<20MB): Added directly to the email as proper attachments + # 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email + + @options[:attachments] = [] + @large_attachments = [] + current_total_size = 0 + + @message.attachments.each do |attachment| + current_total_size = handle_attachment_inline(current_total_size, attachment) + end + end + + def read_blob_content(blob) + buffer = +'' + blob.open do |file| + while (chunk = file.read(64.kilobytes)) + buffer << chunk + end + end + buffer + end + + def handle_attachment_inline(current_total_size, attachment) + blob = attachment.file.blob + return current_total_size if blob.blank? + + file_size = blob.byte_size + attachment_name = attachment.file.filename.to_s + + if current_total_size + file_size <= 20.megabytes + content = read_blob_content(blob) + mail.attachments[attachment_name] = { + mime_type: attachment.file.content_type || 'application/octet-stream', + content: content + } + @options[:attachments] << { name: attachment_name } + current_total_size + file_size + else + @large_attachments << attachment + current_total_size + end + end +end diff --git a/app/mailers/conversation_reply_mailer_helper.rb b/app/mailers/conversation_reply_mailer_helper.rb index 2fe3695f9..cce236ec7 100644 --- a/app/mailers/conversation_reply_mailer_helper.rb +++ b/app/mailers/conversation_reply_mailer_helper.rb @@ -1,4 +1,6 @@ module ConversationReplyMailerHelper + include ConversationReplyMailerAttachmentHelper + def prepare_mail(cc_bcc_enabled) @options = { to: to_emails, @@ -27,34 +29,6 @@ module ConversationReplyMailerHelper mail(@options) end - def process_attachments_as_files_for_email_reply - # Attachment processing for direct email replies (when replying to a single message) - # - # How attachments are handled: - # 1. Total file size (<20MB): Added directly to the email as proper attachments - # 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email - - @options[:attachments] = [] - @large_attachments = [] - current_total_size = 0 - - @message.attachments.each do |attachment| - raw_data = attachment.file.download - attachment_name = attachment.file.filename.to_s - file_size = raw_data.bytesize - - # Attach files directly until we hit 20MB total - # After reaching 20MB, send remaining files as links - if current_total_size + file_size <= 20.megabytes - mail.attachments[attachment_name] = raw_data - @options[:attachments] << { name: attachment_name } - current_total_size += file_size - else - @large_attachments << attachment - end - end - end - private def oauth_smtp_settings diff --git a/app/services/telegram/send_attachments_service.rb b/app/services/telegram/send_attachments_service.rb index e214fe78f..5ba8fd4ae 100644 --- a/app/services/telegram/send_attachments_service.rb +++ b/app/services/telegram/send_attachments_service.rb @@ -96,11 +96,16 @@ class Telegram::SendAttachmentsService # Telegram picks up the file name from original field name, so we need to save the file with the original name. # Hence not using Tempfile here. def save_attachment_to_tempfile(attachment) - raw_data = attachment.file.download - temp_dir = Rails.root.join('tmp/uploads') + temp_dir = Rails.root.join('tmp/uploads', "telegram-#{attachment.message_id}") FileUtils.mkdir_p(temp_dir) temp_file_path = File.join(temp_dir, attachment.file.filename.to_s) - File.write(temp_file_path, raw_data, mode: 'wb') + + File.open(temp_file_path, 'wb') do |file| + attachment.file.blob.open do |blob_file| + IO.copy_stream(blob_file, file) + end + end + temp_file_path end diff --git a/enterprise/app/services/captain/llm/pdf_processing_service.rb b/enterprise/app/services/captain/llm/pdf_processing_service.rb index 3ab616577..55177b78d 100644 --- a/enterprise/app/services/captain/llm/pdf_processing_service.rb +++ b/enterprise/app/services/captain/llm/pdf_processing_service.rb @@ -29,12 +29,16 @@ class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService end end - def with_tempfile(&) + def with_tempfile Tempfile.create(['pdf_upload', '.pdf'], binmode: true) do |temp_file| - temp_file.write(document.pdf_file.download) - temp_file.close + document.pdf_file.blob.open do |blob_file| + IO.copy_stream(blob_file, temp_file) + end - File.open(temp_file.path, 'rb', &) + temp_file.flush + temp_file.rewind + + yield temp_file end end end diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb index a200c1496..d3cc91376 100644 --- a/enterprise/app/services/messages/audio_transcription_service.rb +++ b/enterprise/app/services/messages/audio_transcription_service.rb @@ -27,10 +27,16 @@ class Messages::AudioTranscriptionService < Llm::LegacyBaseOpenAiService end def fetch_audio_file - temp_dir = Rails.root.join('tmp/uploads') + temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions') FileUtils.mkdir_p(temp_dir) - temp_file_path = File.join(temp_dir, attachment.file.filename.to_s) - File.write(temp_file_path, attachment.file.download, mode: 'wb') + temp_file_path = File.join(temp_dir, "#{attachment.file.blob.key}-#{attachment.file.filename}") + + File.open(temp_file_path, 'wb') do |file| + attachment.file.blob.open do |blob_file| + IO.copy_stream(blob_file, file) + end + end + temp_file_path end @@ -40,18 +46,24 @@ class Messages::AudioTranscriptionService < Llm::LegacyBaseOpenAiService temp_file_path = fetch_audio_file - response = @client.audio.transcribe( - parameters: { - model: 'whisper-1', - file: File.open(temp_file_path), - temperature: 0.4 - } - ) + response_text = nil + + File.open(temp_file_path, 'rb') do |file| + response = @client.audio.transcribe( + parameters: { + model: 'whisper-1', + file: file, + temperature: 0.4 + } + ) + + response_text = response['text'] + end FileUtils.rm_f(temp_file_path) - update_transcription(response['text']) - response['text'] + update_transcription(response_text) + response_text end def update_transcription(transcribed_text) diff --git a/lib/integrations/slack/send_on_slack_service.rb b/lib/integrations/slack/send_on_slack_service.rb index 46c111a83..4e959c469 100644 --- a/lib/integrations/slack/send_on_slack_service.rb +++ b/lib/integrations/slack/send_on_slack_service.rb @@ -121,8 +121,6 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService end def upload_files - return unless message.attachments.any? - files = build_files_array return if files.empty? @@ -136,6 +134,8 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService Rails.logger.info "slack_upload_result: #{result}" rescue Slack::Web::Api::Errors::SlackError => e Rails.logger.error "Failed to upload files: #{e.message}" + ensure + files.each { |file| file[:content]&.clear } end end @@ -143,14 +143,31 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService message.attachments.filter_map do |attachment| next unless attachment.with_attached_file? - { - filename: attachment.file.filename.to_s, - content: attachment.file.download, - title: attachment.file.filename.to_s - } + build_file_payload(attachment) end end + def build_file_payload(attachment) + content = download_attachment_content(attachment) + return if content.blank? + + { + filename: attachment.file.filename.to_s, + content: content, + title: attachment.file.filename.to_s + } + end + + def download_attachment_content(attachment) + buffer = +'' + attachment.file.blob.open do |file| + while (chunk = file.read(64.kilobytes)) + buffer << chunk + end + end + buffer + end + def sender_name(sender) sender.try(:name) ? "#{sender.try(:name)} (#{sender_type(sender)})" : sender_type(sender) end diff --git a/rubocop/attachment_download.rb b/rubocop/attachment_download.rb new file mode 100644 index 000000000..14c56203a --- /dev/null +++ b/rubocop/attachment_download.rb @@ -0,0 +1,17 @@ +require 'rubocop' + +module RuboCop::Cop::Chatwoot; end + +class RuboCop::Cop::Chatwoot::AttachmentDownload < RuboCop::Cop::Base + MSG = 'Avoid calling `.file/.blob.download`; use `blob.open` or streaming IO instead.'.freeze + + def_node_matcher :unsafe_download?, <<~PATTERN + (send (send _ {:file :blob}) :download ...) + PATTERN + + def on_send(node) + return unless unsafe_download?(node) + + add_offense(node.loc.selector, message: MSG) + end +end diff --git a/spec/enterprise/services/captain/llm/pdf_processing_service_spec.rb b/spec/enterprise/services/captain/llm/pdf_processing_service_spec.rb index 9dc416685..fd519dcdd 100644 --- a/spec/enterprise/services/captain/llm/pdf_processing_service_spec.rb +++ b/spec/enterprise/services/captain/llm/pdf_processing_service_spec.rb @@ -28,13 +28,14 @@ RSpec.describe Captain::Llm::PdfProcessingService do context 'when uploading PDF to OpenAI' do let(:mock_client) { instance_double(OpenAI::Client) } let(:pdf_content) { 'PDF content' } + let(:blob_double) { instance_double(ActiveStorage::Blob) } + let(:pdf_file) { instance_double(ActiveStorage::Attachment) } before do allow(document).to receive(:openai_file_id).and_return(nil) - - # Use a simple double for ActiveStorage since it's a complex Rails object - pdf_file = double('pdf_file', download: pdf_content) # rubocop:disable RSpec/VerifiedDoubles allow(document).to receive(:pdf_file).and_return(pdf_file) + allow(pdf_file).to receive(:blob).and_return(blob_double) + allow(blob_double).to receive(:open).and_yield(StringIO.new(pdf_content)) allow(OpenAI::Client).to receive(:new).and_return(mock_client) # Use a simple double for OpenAI::Files as it may not be loaded diff --git a/spec/jobs/data_import_job_spec.rb b/spec/jobs/data_import_job_spec.rb index fb1057f4a..19178b177 100644 --- a/spec/jobs/data_import_job_spec.rb +++ b/spec/jobs/data_import_job_spec.rb @@ -15,8 +15,11 @@ RSpec.describe DataImportJob do describe 'retrying the job' do context 'when ActiveStorage::FileNotFoundError is raised' do + let(:import_file_double) { instance_double(ActiveStorage::Blob) } + before do - allow(data_import.import_file).to receive(:download).and_raise(ActiveStorage::FileNotFoundError) + allow(data_import).to receive(:import_file).and_return(import_file_double) + allow(import_file_double).to receive(:open).and_raise(ActiveStorage::FileNotFoundError) end it 'retries the job' do @@ -158,7 +161,9 @@ RSpec.describe DataImportJob do end before do - allow(data_import.import_file).to receive(:download).and_return(invalid_csv_content) + import_file_double = instance_double(ActiveStorage::Blob) + allow(data_import).to receive(:import_file).and_return(import_file_double) + allow(import_file_double).to receive(:open).and_yield(StringIO.new(invalid_csv_content)) end it 'does not import any data and handles the MalformedCSVError' do diff --git a/spec/lib/integrations/slack/send_on_slack_service_spec.rb b/spec/lib/integrations/slack/send_on_slack_service_spec.rb index 87d58b79b..5d75a8c2b 100644 --- a/spec/lib/integrations/slack/send_on_slack_service_spec.rb +++ b/spec/lib/integrations/slack/send_on_slack_service_spec.rb @@ -206,6 +206,21 @@ describe Integrations::Slack::SendOnSlackService do expect(message.attachments.count).to eq 2 end + it 'streams attachment blobs and uploads only once' do + expect(slack_client).to receive(:chat_postMessage).and_return(slack_message) + + attachment = message.attachments.new(account_id: message.account_id, file_type: :image) + attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') + blob = attachment.file.blob + allow(blob).to receive(:open).and_call_original + + expect(blob).to receive(:open).and_call_original + expect(slack_client).to receive(:files_upload_v2).once.and_return(file_attachment) + + message.save! + builder.perform + end + it 'handles file upload errors gracefully' do expect(slack_client).to receive(:chat_postMessage).with( channel: hook.reference_id, From eb759255d87d489decfefed37bb483fb4726fc91 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Mon, 8 Dec 2025 10:52:17 +0530 Subject: [PATCH 13/18] perf: update the logic to purchase credits (#12998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description - Replaces Stripe Checkout session flow with direct card charging for AI credit top-ups - Adds a two-step confirmation modal (select package → confirm purchase) for better UX - Creates Stripe invoice directly and charges the customer's default payment method immediately ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - Using the specs - UI manual test cases image image ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Shivam Mishra --- .../api/enterprise/specs/account.spec.js | 26 +++ .../dashboard/i18n/locale/en/settings.json | 10 +- .../dashboard/settings/billing/Index.vue | 10 +- .../components/PurchaseCreditsModal.vue | 184 +++++++++++++----- config/locales/en.yml | 2 + config/routes.rb | 1 - .../enterprise/api/v1/accounts_controller.rb | 13 +- .../billing/handle_stripe_event_service.rb | 27 --- .../billing/topup_checkout_service.rb | 106 +++++----- .../billing/topup_fulfillment_service.rb | 2 + .../api/v1/accounts_controller_spec.rb | 72 +++++++ .../billing/topup_checkout_service_spec.rb | 60 ++++++ .../billing/topup_fulfillment_service_spec.rb | 36 ++++ 13 files changed, 414 insertions(+), 135 deletions(-) create mode 100644 spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb create mode 100644 spec/enterprise/services/enterprise/billing/topup_fulfillment_service_spec.rb diff --git a/app/javascript/dashboard/api/enterprise/specs/account.spec.js b/app/javascript/dashboard/api/enterprise/specs/account.spec.js index 9c65b0b67..47d2eb26d 100644 --- a/app/javascript/dashboard/api/enterprise/specs/account.spec.js +++ b/app/javascript/dashboard/api/enterprise/specs/account.spec.js @@ -11,6 +11,8 @@ describe('#enterpriseAccountAPI', () => { expect(accountAPI).toHaveProperty('delete'); expect(accountAPI).toHaveProperty('checkout'); expect(accountAPI).toHaveProperty('toggleDeletion'); + expect(accountAPI).toHaveProperty('createTopupCheckout'); + expect(accountAPI).toHaveProperty('getLimits'); }); describe('API calls', () => { @@ -59,5 +61,29 @@ describe('#enterpriseAccountAPI', () => { { action_type: 'undelete' } ); }); + + it('#createTopupCheckout with credits', () => { + accountAPI.createTopupCheckout(1000); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/topup_checkout', + { credits: 1000 } + ); + }); + + it('#createTopupCheckout with different credit amounts', () => { + const creditAmounts = [1000, 2500, 6000, 12000]; + creditAmounts.forEach(credits => { + accountAPI.createTopupCheckout(credits); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/topup_checkout', + { credits } + ); + }); + }); + + it('#getLimits', () => { + accountAPI.getLimits(); + expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits'); + }); }); }); diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 64be235a2..4e51ef37a 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -422,7 +422,15 @@ "PURCHASE": "Purchase Credits", "LOADING": "Loading options...", "FETCH_ERROR": "Failed to load credit options. Please try again.", - "PURCHASE_ERROR": "Failed to process purchase. Please try again." + "PURCHASE_ERROR": "Failed to process purchase. Please try again.", + "PURCHASE_SUCCESS": "Successfully added {credits} credits to your account", + "CONFIRM": { + "TITLE": "Confirm Purchase", + "DESCRIPTION": "You are about to purchase {credits} credits for {amount}.", + "INSTANT_DEDUCTION_NOTE": "Your saved card will be charged immediately upon confirmation.", + "GO_BACK": "Go Back", + "CONFIRM_PURCHASE": "Confirm Purchase" + } } }, "SECURITY_SETTINGS": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue index 4f0981967..bcfa46193 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue @@ -132,6 +132,11 @@ const openPurchaseCreditsModal = () => { purchaseCreditsModalRef.value?.open(); }; +const handleTopupSuccess = () => { + // Refresh limits to show updated credit balance + fetchLimits(); +}; + onMounted(handleBillingPageLogic); @@ -254,7 +259,10 @@ onMounted(handleBillingPageLogic); - + diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue index ebbab0aa8..33f299b9b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue @@ -7,7 +7,7 @@ import Button from 'dashboard/components-next/button/Button.vue'; import CreditPackageCard from './CreditPackageCard.vue'; import EnterpriseAccountAPI from 'dashboard/api/enterprise/account'; -const emit = defineEmits(['close']); +const emit = defineEmits(['close', 'success']); const { t } = useI18n(); @@ -19,19 +19,78 @@ const TOPUP_OPTIONS = [ ]; const POPULAR_CREDITS_AMOUNT = 6000; +const STEP_SELECT = 'select'; +const STEP_CONFIRM = 'confirm'; const dialogRef = ref(null); const selectedCredits = ref(null); const isLoading = ref(false); +const currentStep = ref(STEP_SELECT); const selectedOption = computed(() => { return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value); }); +const formattedAmount = computed(() => { + if (!selectedOption.value) return ''; + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: selectedOption.value.currency.toUpperCase(), + }).format(selectedOption.value.amount); +}); + +const formattedCredits = computed(() => { + if (!selectedOption.value) return ''; + return selectedOption.value.credits.toLocaleString(); +}); + +const dialogTitle = computed(() => { + return currentStep.value === STEP_SELECT + ? t('BILLING_SETTINGS.TOPUP.MODAL_TITLE') + : t('BILLING_SETTINGS.TOPUP.CONFIRM.TITLE'); +}); + +const dialogDescription = computed(() => { + return currentStep.value === STEP_SELECT + ? t('BILLING_SETTINGS.TOPUP.MODAL_DESCRIPTION') + : ''; +}); + +const dialogWidth = computed(() => { + return currentStep.value === STEP_SELECT ? 'xl' : 'md'; +}); + const handlePackageSelect = credits => { selectedCredits.value = credits; }; +const open = () => { + const popularOption = TOPUP_OPTIONS.find( + o => o.credits === POPULAR_CREDITS_AMOUNT + ); + selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits; + currentStep.value = STEP_SELECT; + isLoading.value = false; + dialogRef.value?.open(); +}; + +const close = () => { + dialogRef.value?.close(); +}; + +const handleClose = () => { + emit('close'); +}; + +const goToConfirmStep = () => { + if (!selectedOption.value) return; + currentStep.value = STEP_CONFIRM; +}; + +const goBackToSelectStep = () => { + currentStep.value = STEP_SELECT; +}; + const handlePurchase = async () => { if (!selectedOption.value) return; @@ -40,9 +99,14 @@ const handlePurchase = async () => { const response = await EnterpriseAccountAPI.createTopupCheckout( selectedOption.value.credits ); - if (response.data.redirect_url) { - window.location.href = response.data.redirect_url; - } + + close(); + emit('success', response.data); + useAlert( + t('BILLING_SETTINGS.TOPUP.PURCHASE_SUCCESS', { + credits: response.data.credits, + }) + ); } catch (error) { const errorMessage = error.response?.data?.error || t('BILLING_SETTINGS.TOPUP.PURCHASE_ERROR'); @@ -52,61 +116,71 @@ const handlePurchase = async () => { } }; -const handleClose = () => { - emit('close'); -}; - -const open = () => { - // Pre-select the most popular option - const popularOption = TOPUP_OPTIONS.find( - o => o.credits === POPULAR_CREDITS_AMOUNT - ); - selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits; - dialogRef.value?.open(); -}; - -const close = () => { - dialogRef.value?.close(); -}; - defineExpose({ open, close });