From cc612e755b5bda1bfe67d478c62798a19f3147b5 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 8 May 2026 21:18:50 +0530 Subject: [PATCH 01/42] fix: SafeFetch dependency loading (#14408) The SafeFetch spec suite was failing in CI with `NameError: uninitialized constant SafeFetch::Fetcher` across every example that exercised `SafeFetch.fetch`. From a product perspective, this made the external-file fetch path look unreliable even though the failure happened before any network validation, SSRF protection, content-type checks, or tempfile handling could run. The symptom pointed to a load-order issue rather than an actual fetch behavior regression. `SafeFetch.fetch` referenced `Fetcher` from the top-level module, but that nested class was not guaranteed to be loaded in every test execution path before the method was invoked. This change keeps the existing SafeFetch split between the public API and the implementation classes, but makes the public entry point responsible for loading the implementation it needs before use. That is intentionally smaller than folding all of the fetcher logic into `lib/safe_fetch.rb`; the separate files still keep the request option parsing and streaming implementation readable, while the public API no longer depends on Rails or the test runner having loaded nested constants in a particular order. The file also now uses a single `SafeFetch` module declaration. That removes the awkward reopen pattern and makes the dependency boundary easier to see: constants and errors are defined first, then the public `fetch` method loads and delegates to the implementation classes. --- lib/safe_fetch.rb | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/safe_fetch.rb b/lib/safe_fetch.rb index d664dcf6a..7f89c03c4 100644 --- a/lib/safe_fetch.rb +++ b/lib/safe_fetch.rb @@ -22,16 +22,11 @@ module SafeFetch class FileTooLargeError < Error; end class UnsupportedContentTypeError < Error; end class UnsupportedMethodError < Error; end -end -require_relative 'safe_fetch/request_options' -require_relative 'safe_fetch/fetcher' - -module SafeFetch def self.fetch(url, **, &) raise ArgumentError, 'block required' unless block_given? - Fetcher.new(RequestOptions.new(url: url, **)).fetch(&) + SafeFetch::Fetcher.new(SafeFetch::RequestOptions.new(url: url, **)).fetch(&) rescue SsrfFilter::InvalidUriScheme, URI::InvalidURIError => e raise InvalidUrlError, e.message rescue SsrfFilter::Error, Resolv::ResolvError => e From bc768bf04f4199bebf6d0e30ed226e02405deeb3 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 11 May 2026 10:58:23 +0530 Subject: [PATCH 02/42] chore: verbosely log errors for leadsquare activity failure (#14407) --- .../crm/leadsquared/processor_service.rb | 16 ++++++++++++---- .../crm/leadsquared/processor_service_spec.rb | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/app/services/crm/leadsquared/processor_service.rb b/app/services/crm/leadsquared/processor_service.rb index ef33718f2..9ffa3d12c 100644 --- a/app/services/crm/leadsquared/processor_service.rb +++ b/app/services/crm/leadsquared/processor_service.rb @@ -89,11 +89,19 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService metadata[metadata_key] = activity_id store_conversation_metadata(conversation, metadata) rescue Crm::Leadsquared::Api::BaseClient::ApiError => e - ChatwootExceptionTracker.new(e, account: @account).capture_exception - Rails.logger.error "LeadSquared API error in #{activity_type} activity: #{e.message}" + log_activity_error(e, activity_type, conversation, payload: { lead_id: lead_id, activity_code: activity_code, activity_note: activity_note }) rescue StandardError => e - ChatwootExceptionTracker.new(e, account: @account).capture_exception - Rails.logger.error "Error creating #{activity_type} activity in LeadSquared: #{e.message}" + log_activity_error(e, activity_type, conversation) + end + + def log_activity_error(error, activity_type, conversation, payload: nil) + ChatwootExceptionTracker.new(error, account: @account).capture_exception + context = "account_id=#{conversation.account_id}, conversation_display_id=#{conversation.display_id}" + if payload + context += ", http_status=#{error.code}, prospect_id=#{payload[:lead_id]}, " \ + "activity_event=#{payload[:activity_code]}, note_bytes=#{payload[:activity_note].to_s.bytesize}" + end + Rails.logger.error("LeadSquared #{activity_type} activity failed: #{error.message} (#{context})") end def get_activity_code(key) diff --git a/spec/services/crm/leadsquared/processor_service_spec.rb b/spec/services/crm/leadsquared/processor_service_spec.rb index 7008eb064..7b99721c5 100644 --- a/spec/services/crm/leadsquared/processor_service_spec.rb +++ b/spec/services/crm/leadsquared/processor_service_spec.rb @@ -157,7 +157,7 @@ RSpec.describe Crm::Leadsquared::ProcessorService do it 'logs the error' do service.handle_conversation_created(conversation) - expect(Rails.logger).to have_received(:error).with(/Error creating conversation activity/) + expect(Rails.logger).to have_received(:error).with(/LeadSquared conversation activity failed/) end end end From 2e13f69fdf61805fbd6f3cb2cfe7813fed744648 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 11 May 2026 16:09:45 +0530 Subject: [PATCH 03/42] chore: log errors from context.dev (#14310) This PR updates the way we log errors and results from context.dev to have better visibility on the enrichment process for onboarding --- app/jobs/account/branding_enrichment_job.rb | 5 ++++- .../app/services/enterprise/website_branding_service.rb | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/jobs/account/branding_enrichment_job.rb b/app/jobs/account/branding_enrichment_job.rb index 2898604ca..3deb81821 100644 --- a/app/jobs/account/branding_enrichment_job.rb +++ b/app/jobs/account/branding_enrichment_job.rb @@ -3,7 +3,10 @@ class Account::BrandingEnrichmentJob < ApplicationJob def perform(account_id, email) result = WebsiteBrandingService.new(email).perform - return if result.blank? + if result.blank? + Rails.logger.info "[BrandingEnrichment] Enrichment failed for account=#{account_id} email=#{email}" + return + end account = Account.find(account_id) account.name = result[:title] if result[:title].present? diff --git a/enterprise/app/services/enterprise/website_branding_service.rb b/enterprise/app/services/enterprise/website_branding_service.rb index a1925e80d..553317c43 100644 --- a/enterprise/app/services/enterprise/website_branding_service.rb +++ b/enterprise/app/services/enterprise/website_branding_service.rb @@ -34,7 +34,10 @@ module Enterprise::WebsiteBrandingService def process_response(response) @http_status = response.code - raise "API Error: #{response.message} (Status: #{response.code})" unless response.success? + unless response.success? + Rails.logger.warn "[WebsiteBranding] Context.dev returned #{response.code}: #{response.parsed_response}" + return nil + end brand = response.parsed_response&.dig('brand') return nil if brand.blank? From 34892987268d22230e29baf5d60cc4e2961a65a4 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 11 May 2026 16:10:48 +0530 Subject: [PATCH 04/42] 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 From f6be0d80efbf373e166f7f5b4211016c659d7a78 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Mon, 11 May 2026 20:13:29 +0530 Subject: [PATCH 05/42] feat: UI changes for document auto sync [AI-153] (#14258) # Pull Request Template ## Description FE code for document sync Adds: - UI to show counts (stats) of available web pages, stale and synced documents and last synced at - Bulk action and manual ways to sync web documents - index to stats related columns ## Type of change Please delete options that are not relevant. - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. https://linear.app/chatwoot/issue/AI-153/fe-document-auto-sync Documents dashboard: CleanShot 2026-05-11 at 17 57 09@2x Filters: CleanShot 2026-05-11 at 17 58 13@2x Needs update: CleanShot 2026-05-11 at 17 57 53@2x pdfs: CleanShot 2026-05-11 at 17 58 30@2x bulk actions: CleanShot 2026-05-11 at 17 58 57@2x single url sync: CleanShot 2026-05-11 at 17 59 19@2x ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin Co-authored-by: Muhsin Keloth Co-authored-by: Sony Mathew Co-authored-by: Vishnu Narayanan --- .../dashboard/api/captain/document.js | 11 +- .../captain/assistant/DocumentBulkActions.vue | 113 +++++++ .../captain/assistant/DocumentCard.vue | 107 ++++++- .../captain/assistant/DocumentFilter.vue | 76 +++++ .../captain/assistant/DocumentFiltersBar.vue | 165 +++++++++++ .../captain/assistant/DocumentSyncStatus.vue | 153 ++++++++++ .../document/CreateDocumentDialog.vue | 3 +- app/javascript/dashboard/featureFlags.js | 1 + .../i18n/locale/en/integrations.json | 50 ++++ .../dashboard/captain/documents/Index.vue | 276 ++++++++++++++---- .../dashboard/store/captain/bulkActions.js | 13 + .../dashboard/store/captain/document.js | 40 +++ .../shared/helpers/documentHelper.js | 44 ++- .../helpers/specs/documentHelper.spec.js | 48 ++- ...d_sync_stats_index_to_captain_documents.rb | 12 + db/schema.rb | 3 +- .../captain/bulk_actions_controller.rb | 7 +- .../accounts/captain/documents_controller.rb | 67 ++++- .../captain/documents/perform_sync_job.rb | 26 +- .../captain/documents/schedule_syncs_job.rb | 11 +- enterprise/app/models/captain/document.rb | 11 + .../app/policies/captain/assistant_policy.rb | 4 + .../captain/documents/sync_service.rb | 12 +- .../captain/documents/index.json.jbuilder | 1 + .../v1/models/captain/_document.json.jbuilder | 2 + 25 files changed, 1155 insertions(+), 101 deletions(-) create mode 100644 app/javascript/dashboard/components-next/captain/assistant/DocumentBulkActions.vue create mode 100644 app/javascript/dashboard/components-next/captain/assistant/DocumentFilter.vue create mode 100644 app/javascript/dashboard/components-next/captain/assistant/DocumentFiltersBar.vue create mode 100644 app/javascript/dashboard/components-next/captain/assistant/DocumentSyncStatus.vue create mode 100644 db/migrate/20260429043000_add_sync_stats_index_to_captain_documents.rb diff --git a/app/javascript/dashboard/api/captain/document.js b/app/javascript/dashboard/api/captain/document.js index dc22b0c32..e23a8c460 100644 --- a/app/javascript/dashboard/api/captain/document.js +++ b/app/javascript/dashboard/api/captain/document.js @@ -6,15 +6,22 @@ class CaptainDocument extends ApiClient { super('captain/documents', { accountScoped: true }); } - get({ page = 1, searchKey, assistantId } = {}) { + get({ page = 1, searchKey, assistantId, filter, source, sort } = {}) { return axios.get(this.url, { params: { page, - searchKey, + search_key: searchKey, assistant_id: assistantId, + filter, + source, + sort, }, }); } + + sync(id) { + return axios.post(`${this.url}/${id}/sync`); + } } export default new CaptainDocument(); diff --git a/app/javascript/dashboard/components-next/captain/assistant/DocumentBulkActions.vue b/app/javascript/dashboard/components-next/captain/assistant/DocumentBulkActions.vue new file mode 100644 index 000000000..378860b3e --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/assistant/DocumentBulkActions.vue @@ -0,0 +1,113 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue index 30ebfc448..c8a011b9a 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue @@ -5,14 +5,17 @@ import { useI18n } from 'vue-i18n'; import { dynamicTime } from 'shared/helpers/timeHelper'; import { usePolicy } from 'dashboard/composables/usePolicy'; import { - isPdfDocument, + isSafeHttpLink, formatDocumentLink, + getDocumentDisplayPath, } from 'shared/helpers/documentHelper'; +import Icon from 'dashboard/components-next/icon/Icon.vue'; import CardLayout from 'dashboard/components-next/CardLayout.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import Button from 'dashboard/components-next/button/Button.vue'; import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; +import DocumentSyncStatus from 'dashboard/components-next/captain/assistant/DocumentSyncStatus.vue'; const props = defineProps({ id: { @@ -31,10 +34,38 @@ const props = defineProps({ type: String, required: true, }, + pdfDocument: { + type: Boolean, + default: false, + }, createdAt: { type: Number, required: true, }, + status: { + type: String, + default: null, + }, + syncStatus: { + type: String, + default: null, + }, + lastSyncedAt: { + type: Number, + default: null, + }, + lastSyncErrorCode: { + type: String, + default: null, + }, + syncInProgress: { + type: Boolean, + default: false, + }, + syncStaleAfterHours: { + type: Number, + default: null, + }, isSelected: { type: Boolean, default: false, @@ -64,6 +95,20 @@ const modelValue = computed({ set: () => emit('select', props.id), }); +const isPdf = computed(() => props.pdfDocument); +const hasSafeLink = computed(() => isSafeHttpLink(props.externalLink)); +const canManage = computed(() => checkPermissions(['administrator'])); +const isAvailable = computed(() => props.status === 'available'); +const canSync = computed( + () => canManage.value && !isPdf.value && isAvailable.value +); +const isSyncing = computed(() => props.syncStatus === 'syncing'); +const isFailed = computed(() => props.syncStatus === 'failed'); +const isRetryableSync = computed( + () => isFailed.value || (isSyncing.value && !props.syncInProgress) +); +const showSyncStatus = computed(() => !isPdf.value); + const menuItems = computed(() => { const allOptions = [ { @@ -74,7 +119,19 @@ const menuItems = computed(() => { }, ]; - if (checkPermissions(['administrator'])) { + if (canSync.value) { + allOptions.push({ + label: isRetryableSync.value + ? t('CAPTAIN.DOCUMENTS.OPTIONS.RETRY_SYNC') + : t('CAPTAIN.DOCUMENTS.OPTIONS.SYNC_NOW'), + value: 'sync', + action: 'sync', + icon: 'i-lucide-refresh-cw', + disabled: props.syncInProgress, + }); + } + + if (canManage.value) { allOptions.push({ label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'), value: 'delete', @@ -86,17 +143,25 @@ const menuItems = computed(() => { return allOptions; }); -const createdAt = computed(() => dynamicTime(props.createdAt)); +const createdAtLabel = computed(() => dynamicTime(props.createdAt)); -const displayLink = computed(() => formatDocumentLink(props.externalLink)); +const displayLink = computed(() => + isPdf.value + ? formatDocumentLink(props.externalLink) + : getDocumentDisplayPath(props.externalLink) +); const linkIcon = computed(() => - isPdfDocument(props.externalLink) ? 'i-ph-file-pdf' : 'i-ph-link-simple' + isPdf.value ? 'i-ph-file-pdf' : 'i-ph-link-simple' ); const handleAction = ({ action, value }) => { toggleDropdown(false); emit('action', { action, value, id: props.id }); }; + +const handleRetry = () => { + emit('action', { action: 'sync', id: props.id }); +};