diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index e6f2993e4..ad1edb049 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -5,6 +5,7 @@ import { BUS_EVENTS } from 'shared/constants/busEvents'; import { emitter } from 'shared/helpers/mitt'; import { useImpersonation } from 'dashboard/composables/useImpersonation'; import { useCallsStore } from 'dashboard/stores/calls'; +import { useHelpCenterGenerationStore } from 'dashboard/stores/helpCenterGeneration'; import { applyOutboundAnswer, armOutboundRecorder, @@ -49,6 +50,8 @@ class ActionCableConnector extends BaseActionCableConnector { this.onConversationUnreadCountChanged, 'account.cache_invalidated': this.onCacheInvalidate, 'account.enrichment_completed': this.onEnrichmentCompleted, + 'help_center.article_generated': this.onHelpCenterArticleGenerated, + 'help_center.generation_completed': this.onHelpCenterGenerationCompleted, 'copilot.message.created': this.onCopilotMessageCreated, 'voice_call.incoming': this.onVoiceCallIncoming, 'voice_call.outbound_connected': this.onVoiceCallOutboundConnected, @@ -268,6 +271,16 @@ class ActionCableConnector extends BaseActionCableConnector { this.app.$store.dispatch('accounts/get', { silent: true }); }; + // eslint-disable-next-line class-methods-use-this + onHelpCenterArticleGenerated = data => { + useHelpCenterGenerationStore().handleArticleGenerated(data); + }; + + // eslint-disable-next-line class-methods-use-this + onHelpCenterGenerationCompleted = data => { + useHelpCenterGenerationStore().handleGenerationCompleted(data); + }; + onCacheInvalidate = data => { const keys = data.cache_keys; this.app.$store.dispatch('labels/revalidate', { newKey: keys.label }); diff --git a/app/javascript/dashboard/stores/helpCenterGeneration.js b/app/javascript/dashboard/stores/helpCenterGeneration.js new file mode 100644 index 000000000..7370c253c --- /dev/null +++ b/app/javascript/dashboard/stores/helpCenterGeneration.js @@ -0,0 +1,53 @@ +import { defineStore } from 'pinia'; + +// Tracks the onboarding help center article generation, fed live by the +// `help_center.*` ActionCable events (see dashboard/helper/actionCable.js). +// The connector is created at app init, so events accumulate here before the +// onboarding screen mounts. State is in-memory only; a reload restarts the +// count from the next event (the generation_id comes from the account). +export const useHelpCenterGenerationStore = defineStore( + 'helpCenterGeneration', + { + state: () => ({ + generationId: null, + status: null, // 'generating' | 'completed' | 'skipped' | null + articlesCount: 0, + }), + + getters: { + isGenerating: state => state.status === 'generating', + isCompleted: state => state.status === 'completed', + isSkipped: state => state.status === 'skipped', + }, + + actions: { + // Called by the component on mount with the account's generation_id. + // No-op when events have already started populating this generation. + hydrate(generationId) { + if (!generationId || this.generationId === generationId) return; + + this.generationId = generationId; + this.status = 'generating'; + this.articlesCount = 0; + }, + + handleArticleGenerated({ + generation_id: generationId, + articles_finished: articlesFinished, + }) { + if (this.generationId && this.generationId !== generationId) return; + + this.generationId = generationId; + this.articlesCount = articlesFinished ?? this.articlesCount + 1; + if (!this.isCompleted && !this.isSkipped) this.status = 'generating'; + }, + + handleGenerationCompleted({ generation_id: generationId, status }) { + if (this.generationId && this.generationId !== generationId) return; + + this.generationId = generationId; + this.status = status === 'skipped' ? 'skipped' : 'completed'; + }, + }, + } +); diff --git a/app/services/onboarding/web_widget_creation_service.rb b/app/services/onboarding/web_widget_creation_service.rb index 502b67e6c..6f8f92034 100644 --- a/app/services/onboarding/web_widget_creation_service.rb +++ b/app/services/onboarding/web_widget_creation_service.rb @@ -1,8 +1,6 @@ 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). + # context.dev descriptions and LLM completions are unbounded; so we limit in the backend WELCOME_TAGLINE_MAX_LENGTH = 255 def initialize(account, user) diff --git a/app/views/api/v1/models/_account.json.jbuilder b/app/views/api/v1/models/_account.json.jbuilder index 7506592ba..02b3480d5 100644 --- a/app/views/api/v1/models/_account.json.jbuilder +++ b/app/views/api/v1/models/_account.json.jbuilder @@ -14,6 +14,9 @@ if resource.custom_attributes.present? json.referral_source resource.custom_attributes['referral_source'] if resource.custom_attributes['referral_source'].present? json.brand_info resource.custom_attributes['brand_info'] if resource.custom_attributes['brand_info'].present? json.onboarding_step resource.onboarding_step if resource.onboarding_step.present? + if resource.custom_attributes['help_center_generation_id'].present? + json.help_center_generation_id resource.custom_attributes['help_center_generation_id'] + end json.marked_for_deletion_at resource.custom_attributes['marked_for_deletion_at'] if resource.custom_attributes['marked_for_deletion_at'].present? if resource.custom_attributes['marked_for_deletion_reason'].present? json.marked_for_deletion_reason resource.custom_attributes['marked_for_deletion_reason'] diff --git a/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb b/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb index ed561d668..27d624ed4 100644 --- a/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb +++ b/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb @@ -2,10 +2,10 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob queue_as :low retry_on Firecrawl::FirecrawlError, wait: :polynomially_longer, attempts: 3 do |job, error| - _account_id, _portal_id, user_id, generation_id = job.arguments + account_id, _portal_id, user_id, generation_id = job.arguments reason = "firecrawl exhausted: #{error.message}" Rails.logger.warn "[HelpCenterGenerationJob] gen=#{generation_id} #{reason}" - job.send(:skip_and_broadcast, user: User.find_by(id: user_id), generation_id: generation_id, reason: reason) + job.send(:skip_and_broadcast, account_id: account_id, user: User.find_by(id: user_id), generation_id: generation_id, reason: reason) end def perform(account_id, portal_id, user_id, generation_id) @@ -19,7 +19,7 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob ) rescue Onboarding::HelpCenterErrors::CurationSkipped => e Rails.logger.info "[HelpCenterGenerationJob] gen=#{generation_id} skipped: #{e.message}" - skip_and_broadcast(user: User.find_by(id: user_id), generation_id: generation_id, reason: e.message) + skip_and_broadcast(account_id: account_id, user: User.find_by(id: user_id), generation_id: generation_id, reason: e.message) end private @@ -94,10 +94,10 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob end end - def skip_and_broadcast(user:, generation_id:, reason:) + def skip_and_broadcast(account_id:, user:, generation_id:, reason:) Onboarding::HelpCenterGenerationState.skip(generation_id, reason: reason) Onboarding::HelpCenterBroadcaster.completed( - user: user, generation_id: generation_id, status: 'skipped', skip_reason: reason + account_id: account_id, user: user, generation_id: generation_id, status: 'skipped', skip_reason: reason ) end end diff --git a/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb b/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb index 2c25b86e9..59e7a2ff5 100644 --- a/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb +++ b/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb @@ -19,33 +19,33 @@ class Onboarding::HelpCenterArticleWriterJob < ApplicationJob article: payload[:article] ).perform - finalize(user: user, generation_id: generation_id, article: article) + finalize(account_id: account_id, user: user, generation_id: generation_id, article: article) end private def on_writer_failure(error) - user, generation_id = failure_context + account_id, user, generation_id = failure_context Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} failed: #{error.class} #{error.message}" - finalize(user: user, generation_id: generation_id, article: nil) + finalize(account_id: account_id, user: user, generation_id: generation_id, article: nil) end def failure_context - _account_id, _portal_id, user_id, generation_id = arguments - [User.find_by(id: user_id), generation_id] + account_id, _portal_id, user_id, generation_id = arguments + [account_id, User.find_by(id: user_id), generation_id] end - def finalize(user:, generation_id:, article:) + def finalize(account_id:, user:, generation_id:, article:) result = Onboarding::HelpCenterGenerationState.record_article_finished(generation_id) if article Onboarding::HelpCenterBroadcaster.article_generated( - user: user, generation_id: generation_id, article: article, articles_finished: result[:finished] + account_id: account_id, user: user, generation_id: generation_id, article: article, articles_finished: result[:finished] ) end return unless result[:completed] - Onboarding::HelpCenterBroadcaster.completed(user: user, generation_id: generation_id, status: 'completed') + Onboarding::HelpCenterBroadcaster.completed(account_id: account_id, user: user, generation_id: generation_id, status: 'completed') rescue Onboarding::HelpCenterGenerationState::Missing => e Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} #{e.message}" end diff --git a/enterprise/app/services/onboarding/help_center_broadcaster.rb b/enterprise/app/services/onboarding/help_center_broadcaster.rb index e12ed4287..4ba1d6230 100644 --- a/enterprise/app/services/onboarding/help_center_broadcaster.rb +++ b/enterprise/app/services/onboarding/help_center_broadcaster.rb @@ -4,16 +4,18 @@ module Onboarding::HelpCenterBroadcaster module_function - def article_generated(user:, generation_id:, article:, articles_finished:) + def article_generated(account_id:, user:, generation_id:, article:, articles_finished:) broadcast(user, ARTICLE_GENERATED, { + account_id: account_id, generation_id: generation_id, article_id: article.id, articles_finished: articles_finished }) end - def completed(user:, generation_id:, status:, skip_reason: nil) + def completed(account_id:, user:, generation_id:, status:, skip_reason: nil) broadcast(user, GENERATION_COMPLETED, { + account_id: account_id, generation_id: generation_id, status: status, skip_reason: skip_reason diff --git a/enterprise/app/services/onboarding/help_center_creation_service.rb b/enterprise/app/services/onboarding/help_center_creation_service.rb index 7ccd9bff3..37617eb0e 100644 --- a/enterprise/app/services/onboarding/help_center_creation_service.rb +++ b/enterprise/app/services/onboarding/help_center_creation_service.rb @@ -76,6 +76,7 @@ class Onboarding::HelpCenterCreationService return if homepage_link.blank? generation_id = SecureRandom.uuid + @account.update!(custom_attributes: @account.custom_attributes.merge('help_center_generation_id' => generation_id)) Onboarding::HelpCenterArticleGenerationJob.perform_later(@account.id, portal.id, @user.id, generation_id) rescue StandardError => e Rails.logger.error "[HelpCenterCreation] Failed to enqueue article generation for account #{@account.id}: #{e.class} - #{e.message}" diff --git a/lib/tasks/onboarding.rake b/lib/tasks/onboarding.rake index d61a77cc3..dbc132797 100644 --- a/lib/tasks/onboarding.rake +++ b/lib/tasks/onboarding.rake @@ -1,14 +1,66 @@ -namespace :onboarding do - desc 'Reset onboarding for an account (triggers the onboarding flow again). Usage: rake onboarding:reset[account_id]' - task :reset, [:account_id] => :environment do |_task, args| - abort 'Error: Please provide an account ID' if args[:account_id].blank? +namespace :onboarding do # rubocop:disable Metrics/BlockLength + # Resets onboarding for an account so the onboarding flow runs again. + # Interactively prompts for an account ID, then resets the onboarding step + # and deletes the account's inboxes and help center (portals, categories, articles). + # + # How to run: + # bundle exec rake onboarding:reset + # + # You will be prompted for the account ID and a confirmation (y/N). + desc 'Reset onboarding for an account (interactive). Resets the onboarding step and deletes inboxes and help center articles.' + task reset: :environment do # rubocop:disable Metrics/BlockLength + print 'Enter the account ID to reset onboarding for: ' + account_id = $stdin.gets&.strip - account = Account.find_by(id: args[:account_id]) - abort "Error: Account with ID '#{args[:account_id]}' not found" unless account + abort 'Error: Please provide an account ID' if account_id.blank? + + account = Account.find_by(id: account_id) + abort "Error: Account with ID '#{account_id}' not found" unless account + + puts "\nAccount: #{account.name} (ID: #{account.id})" + puts "Current onboarding step: #{account.custom_attributes['onboarding_step'] || '(none)'}" + + if account.inboxes.any? + puts "\nInboxes (#{account.inboxes.count}):" + account.inboxes.each { |inbox| puts " - ##{inbox.id} #{inbox.name} [#{inbox.channel_type}]" } + end + + if account.portals.any? + puts "\nPortals (#{account.portals.count}):" + account.portals.each { |portal| puts " - ##{portal.id} #{portal.name}" } + end + + if account.articles.any? + puts "\nHelp center articles (#{account.articles.count}):" + account.articles.each { |article| puts " - ##{article.id} #{article.title}" } + end + + puts "\nTo be deleted: #{account.inboxes.count} inbox(es), #{account.portals.count} portal(s), " \ + "#{account.categories.count} category(ies), #{account.articles.count} article(s)." + + print "\nReset onboarding for '#{account.name}' (ID: #{account.id}) and delete the above? (y/N): " + abort 'Aborted' unless $stdin.gets&.strip&.casecmp?('y') account.custom_attributes['onboarding_step'] = 'account_details' + # Seed the socials the website branding service would have detected so the + # onboarding inbox setup UI has channels to render. Mirrors the shape of + # WebsiteBrandingService#build_socials (keys map to SocialLinkParser). + account.custom_attributes['brand_info'] ||= {} + account.custom_attributes['brand_info']['socials'] = [ + { 'type' => 'whatsapp', 'url' => 'https://wa.me/14155552671' }, + { 'type' => 'facebook', 'url' => 'https://facebook.com/chatwoot' }, + { 'type' => 'telegram', 'url' => 'https://t.me/chatwoot' }, + { 'type' => 'instagram', 'url' => 'https://instagram.com/chatwoot' }, + { 'type' => 'line', 'url' => 'https://line.me/chatwoot' }, + { 'type' => 'tiktok', 'url' => 'https://tiktok.com/@chatwoot' } + ] account.save! - puts "Onboarding has been reset for account '#{account.name}' (ID: #{account.id})" + account.inboxes.destroy_all + account.articles.destroy_all + account.categories.destroy_all + account.portals.destroy_all + + puts "\nOnboarding has been reset for account '#{account.name}' (ID: #{account.id}). Inboxes and help center articles deleted." end end