Compare commits

...
Author SHA1 Message Date
Shivam Mishra 12831a47d1 feat: add help center generation progress plumbing 2026-06-04 14:44:43 +05:30
9 changed files with 147 additions and 25 deletions
@@ -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 });
@@ -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';
},
},
}
);
@@ -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)
@@ -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']
@@ -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
@@ -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
@@ -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
@@ -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}"
+59 -7
View File
@@ -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