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(<account_id>)
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.
This commit is contained in:
Shivam Mishra
2026-05-11 16:10:48 +05:30
committed by GitHub
parent 2e13f69fdf
commit 3489298726
8 changed files with 272 additions and 2 deletions
@@ -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')
@@ -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
@@ -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
@@ -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
@@ -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
+10
View File
@@ -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
@@ -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
@@ -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