From 34892987268d22230e29baf5d60cc4e2961a65a4 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 11 May 2026 16:10:48 +0530 Subject: [PATCH] feat: add `WidgetCreationService` for onboarding web widget setup (#14314) When a new account finishes onboarding we want to land them on a dashboard with a working web widget already configured, branded, named, and assigned to them, instead of an empty inbox list. This PR adds the services that produce that widget. **No user-visible change yet:** the services are dormant until the trigger and background job are wired up in the follow-up PR. ## Context Milestone 1 added `Account::BrandingEnrichmentJob`, which calls context.dev during signup and stores brand data on `account.custom_attributes['brand_info']`, plus the new onboarding form that captures `domain`, `name`, `industry`, etc. Milestone 2 starts using that data, and the first thing we want is a web widget materialized automatically. Splitting the service layer from the orchestration plumbing (Redis key, `onboarding_step` extension, controller wiring, ActionCable) keeps this diff focused and lets the LLM/widget logic merge independently. ## How to test Run against an existing account that already has `brand_info` populated. ```ruby account = Account.find() user = account.administrators.first inbox = WidgetCreationService.new(account, user).perform inbox.channel.widget_color # color from brand_info, or '#1f93ff' inbox.channel.welcome_title # brand_info[:title], or account.name inbox.channel.welcome_tagline # LLM tagline (Enterprise + system key set), # else brand_info[:slogan]/[:description]/nil inbox.inbox_members.pluck(:user_id) ``` Toggle `InstallationConfig['CAPTAIN_OPEN_AI_API_KEY']` to flip between LLM and brand-text tagline paths. To verify failure isolation, raise inside `Captain::Llm::WidgetTaglineService#perform` and confirm widget creation still succeeds with the fallback tagline. --- .../onboarding/web_widget_creation_service.rb | 75 ++++++++++++++++++ .../captain/llm/widget_tagline_schema.rb | 5 ++ .../captain/llm/widget_tagline_service.rb | 78 +++++++++++++++++++ .../onboarding/web_widget_creation_service.rb | 11 +++ .../enterprise/captain/base_task_service.rb | 4 +- lib/captain/base_task_service.rb | 10 +++ .../lib/captain/base_task_service_spec.rb | 32 ++++++++ .../web_widget_creation_service_spec.rb | 59 ++++++++++++++ 8 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 app/services/onboarding/web_widget_creation_service.rb create mode 100644 enterprise/app/services/captain/llm/widget_tagline_schema.rb create mode 100644 enterprise/app/services/captain/llm/widget_tagline_service.rb create mode 100644 enterprise/app/services/enterprise/onboarding/web_widget_creation_service.rb create mode 100644 spec/enterprise/services/enterprise/onboarding/web_widget_creation_service_spec.rb diff --git a/app/services/onboarding/web_widget_creation_service.rb b/app/services/onboarding/web_widget_creation_service.rb new file mode 100644 index 000000000..363643f05 --- /dev/null +++ b/app/services/onboarding/web_widget_creation_service.rb @@ -0,0 +1,75 @@ +class Onboarding::WebWidgetCreationService + DEFAULT_WIDGET_COLOR = '#1f93ff'.freeze + # context.dev descriptions and LLM completions are unbounded; bound the + # stored tagline so a long string doesn't render as a wall of text in the + # widget UI (and so backends that enforce varchar limits don't raise). + WELCOME_TAGLINE_MAX_LENGTH = 255 + + def initialize(account, user) + @account = account + @user = user + end + + def perform + existing = existing_web_widget_inbox + if existing + Rails.logger.info "[WidgetCreation] Reusing existing web widget inbox #{existing.id} for account #{@account.id}" + return existing + end + + if website_url.blank? + Rails.logger.info "[WidgetCreation] Skipping for account #{@account.id}: no website_url available" + return nil + end + + attrs = channel_attributes + + ActiveRecord::Base.transaction do + channel = @account.web_widgets.create!(attrs) + inbox = @account.inboxes.create!(name: @account.name, channel: channel) + InboxMember.find_or_create_by!(inbox: inbox, user: @user) + inbox + end + rescue StandardError => e + Rails.logger.error "[WidgetCreation] #{e.message}" + nil + end + + private + + def existing_web_widget_inbox + @account.inboxes.find_by(channel_type: 'Channel::WebWidget') + end + + def channel_attributes + { + website_url: website_url, + widget_color: widget_color, + welcome_title: welcome_title, + welcome_tagline: welcome_tagline_text&.truncate(WELCOME_TAGLINE_MAX_LENGTH) + } + end + + def brand_info + @brand_info ||= (@account.custom_attributes['brand_info'] || {}).deep_symbolize_keys + end + + def website_url + @account.domain.presence || brand_info[:domain].presence + end + + def widget_color + hex = brand_info[:colors]&.first&.dig(:hex) + hex.to_s.match?(/\A#\h{6}\z/) ? hex : DEFAULT_WIDGET_COLOR + end + + def welcome_title + brand_info[:title].presence || @account.name + end + + def welcome_tagline_text + brand_info[:slogan].presence || brand_info[:description].presence + end +end + +Onboarding::WebWidgetCreationService.prepend_mod_with('Onboarding::WebWidgetCreationService') diff --git a/enterprise/app/services/captain/llm/widget_tagline_schema.rb b/enterprise/app/services/captain/llm/widget_tagline_schema.rb new file mode 100644 index 000000000..cfb1bc4c5 --- /dev/null +++ b/enterprise/app/services/captain/llm/widget_tagline_schema.rb @@ -0,0 +1,5 @@ +class Captain::Llm::WidgetTaglineSchema < RubyLLM::Schema + string :tagline, + description: 'Short marketing tagline for a customer-support chat widget. Plain text, no quotes, no emoji, no trailing punctuation.', + max_length: 60 +end diff --git a/enterprise/app/services/captain/llm/widget_tagline_service.rb b/enterprise/app/services/captain/llm/widget_tagline_service.rb new file mode 100644 index 000000000..230c54165 --- /dev/null +++ b/enterprise/app/services/captain/llm/widget_tagline_service.rb @@ -0,0 +1,78 @@ +class Captain::Llm::WidgetTaglineService < Captain::BaseTaskService + RESPONSE_SCHEMA = Captain::Llm::WidgetTaglineSchema + + pattr_initialize [:account!] + + def perform + response = make_api_call(model: tagline_model, messages: messages, schema: RESPONSE_SCHEMA) + return response if response[:error] + + response.merge(message: extract_tagline(response[:message])) + end + + private + + def extract_tagline(message) + tagline = message.is_a?(Hash) ? (message['tagline'] || message[:tagline]) : message + tagline.to_s.strip + end + + def messages + [ + { role: 'system', content: system_prompt }, + { role: 'user', content: user_prompt } + ] + end + + def system_prompt + <<~PROMPT + You write a short marketing tagline for a company's customer-support chat widget. + Use the provided company context to make the tagline specific and on-brand. + PROMPT + end + + def user_prompt + parts = [ + "Company: #{account.name}", + ("Title: #{brand_info[:title]}" if brand_info[:title].present?), + ("Description: #{brand_info[:description]}" if brand_info[:description].present?), + ("Slogan: #{brand_info[:slogan]}" if brand_info[:slogan].present?), + ("Industries: #{industries_text}" if industries_text.present?) + ].compact + parts.join("\n") + end + + def brand_info + @brand_info ||= (account.custom_attributes['brand_info'] || {}).deep_symbolize_keys + end + + def industries_text + Array(brand_info[:industries]).filter_map { |i| i.is_a?(Hash) ? i[:industry] : i }.join(', ').presence + end + + def event_name + 'widget_tagline' + end + + def llm_credential + @llm_credential ||= system_llm_credential + end + + def captain_tasks_enabled? + true + end + + # Tagline generation runs on the operator's OpenAI key during onboarding; + # the customer should not have captain_responses quota deducted for it. + def counts_toward_usage? + false + end + + def tagline_model + @tagline_model ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL + end + + def build_follow_up_context? + false + end +end diff --git a/enterprise/app/services/enterprise/onboarding/web_widget_creation_service.rb b/enterprise/app/services/enterprise/onboarding/web_widget_creation_service.rb new file mode 100644 index 000000000..b108dd5e7 --- /dev/null +++ b/enterprise/app/services/enterprise/onboarding/web_widget_creation_service.rb @@ -0,0 +1,11 @@ +module Enterprise::Onboarding::WebWidgetCreationService + private + + def welcome_tagline_text + response = Captain::Llm::WidgetTaglineService.new(account: @account).perform + response&.dig(:message).to_s.strip.presence || super + rescue StandardError => e + Rails.logger.error "[WidgetCreation] LLM tagline failed: #{e.message}" + super + end +end diff --git a/enterprise/lib/enterprise/captain/base_task_service.rb b/enterprise/lib/enterprise/captain/base_task_service.rb index 9845359f5..427aeb06d 100644 --- a/enterprise/lib/enterprise/captain/base_task_service.rb +++ b/enterprise/lib/enterprise/captain/base_task_service.rb @@ -1,6 +1,6 @@ module Enterprise::Captain::BaseTaskService def perform - return { error: I18n.t('captain.copilot_limit'), error_code: 429 } unless responses_available? + return { error: I18n.t('captain.copilot_limit'), error_code: 429 } if counts_toward_usage? && !responses_available? unless captain_tasks_enabled? return { error: I18n.t('captain.upgrade') } if ChatwootApp.chatwoot_cloud? @@ -9,7 +9,7 @@ module Enterprise::Captain::BaseTaskService end result = super - increment_usage if successful_result?(result) + increment_usage if counts_toward_usage? && successful_result?(result) result end diff --git a/lib/captain/base_task_service.rb b/lib/captain/base_task_service.rb index 123377ea0..a043d38e2 100644 --- a/lib/captain/base_task_service.rb +++ b/lib/captain/base_task_service.rb @@ -149,6 +149,16 @@ class Captain::BaseTaskService account.feature_enabled?('captain_tasks') end + # Extension point consulted by the Enterprise quota wrapper. Subclasses + # whose calls run on the operator's key (e.g. internal/onboarding tasks) + # should override this to return false. When false, the wrapper neither + # blocks the call on an exhausted captain_responses quota nor decrements + # it on success — the call participates in the quota system in neither + # direction. + def counts_toward_usage? + true + end + def api_key_configured? llm_credential.present? end diff --git a/spec/enterprise/lib/captain/base_task_service_spec.rb b/spec/enterprise/lib/captain/base_task_service_spec.rb index a018a7c84..b3dc473eb 100644 --- a/spec/enterprise/lib/captain/base_task_service_spec.rb +++ b/spec/enterprise/lib/captain/base_task_service_spec.rb @@ -165,5 +165,37 @@ RSpec.describe Captain::BaseTaskService, type: :model do service.perform end end + + context 'when subclass opts out via counts_toward_usage?' do + let(:test_service_class) do + result = perform_result + klass = Class.new(described_class) do + define_method(:perform) { result } + define_method(:event_name) { 'test_event' } + define_method(:counts_toward_usage?) { false } + end + klass.prepend(Enterprise::Captain::BaseTaskService) + klass + end + + it 'does not increment usage even on a successful result' do + expect(account).not_to receive(:increment_response_usage) + service.perform + end + + context 'when the captain_responses quota is exhausted on Cloud' do + before do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) + allow(account).to receive(:usage_limits).and_return({ + captain: { responses: { current_available: 0 } } + }) + end + + it 'bypasses the 429 gate and returns the underlying result' do + result = service.perform + expect(result).to eq(perform_result) + end + end + end end end diff --git a/spec/enterprise/services/enterprise/onboarding/web_widget_creation_service_spec.rb b/spec/enterprise/services/enterprise/onboarding/web_widget_creation_service_spec.rb new file mode 100644 index 000000000..90272f5ef --- /dev/null +++ b/spec/enterprise/services/enterprise/onboarding/web_widget_creation_service_spec.rb @@ -0,0 +1,59 @@ +require 'rails_helper' + +# Simulate the prepend_mod_with overlay for testing. +test_klass = Class.new(Onboarding::WebWidgetCreationService) do + prepend Enterprise::Onboarding::WebWidgetCreationService +end + +RSpec.describe Enterprise::Onboarding::WebWidgetCreationService do + let(:account) do + create(:account, name: 'Acme Inc', domain: 'acme.com', custom_attributes: { + 'brand_info' => { 'slogan' => 'Fallback slogan', 'description' => 'Fallback description' } + }) + end + let(:user) { create(:user) } + let(:service) { test_klass.new(account, user) } + + before { create(:account_user, account: account, user: user, role: :administrator) } + + describe '#welcome_tagline_text via #perform' do + let(:llm_double) { instance_double(Captain::Llm::WidgetTaglineService) } + + before do + allow(Captain::Llm::WidgetTaglineService).to receive(:new).and_return(llm_double) + end + + context 'when the LLM returns a tagline' do + before { allow(llm_double).to receive(:perform).and_return(message: ' LLM tagline ') } + + it 'uses the (stripped) LLM-generated tagline' do + expect(service.perform.channel.welcome_tagline).to eq('LLM tagline') + end + end + + context 'when the LLM returns a blank message' do + before { allow(llm_double).to receive(:perform).and_return(message: '') } + + it 'falls back to brand_info text' do + expect(service.perform.channel.welcome_tagline).to eq('Fallback slogan') + end + end + + context 'when the LLM returns an error response' do + before { allow(llm_double).to receive(:perform).and_return(error: 'LLM timeout', error_code: 500) } + + it 'falls back to brand_info text' do + expect(service.perform.channel.welcome_tagline).to eq('Fallback slogan') + end + end + + context 'when the LLM raises an exception' do + before { allow(llm_double).to receive(:perform).and_raise(StandardError, 'boom') } + + it 'still creates the widget with brand_info fallback (no transaction rollback)' do + expect { service.perform }.to change(Channel::WebWidget, :count).by(1) + expect(service.perform.channel.welcome_tagline).to eq('Fallback slogan') + end + end + end +end