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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user